pcp-tgu-service: не дублировать выданный план проекцией
Проекция будущей цепочки якорилась только на принятый базовый план (currentBasePlanId) и игнорировала выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION). Из-за этого она заново выводила уже выданный слот, и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) точечный дедуп по businessKey в PlanQueryService не срабатывал — на вкладке «Планы» появлялся призрак-дубль PLANNED поверх WAITING_DECISION. Теперь якорь проекции — самая дальняя зафиксированная точка цепочки: если есть план в полёте, цепочку строим после него. На путь выдачи не влияет: там activePlanId к моменту вызова проекции уже снят.
This commit is contained in:
+33
-4
@@ -4,6 +4,8 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||||
|
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||||
|
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
||||||
import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser
|
import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser
|
||||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||||
@@ -31,16 +33,38 @@ class PlanProjectionService(
|
|||||||
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
||||||
|
|
||||||
val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
||||||
val basePlan = state?.currentBasePlanId
|
val anchorPlan = resolveAnchorPlan(state)
|
||||||
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
|
|
||||||
|
|
||||||
return if (basePlan == null) {
|
return if (anchorPlan == null) {
|
||||||
planCalculationService.calculatePlans(points)
|
planCalculationService.calculatePlans(points)
|
||||||
} else {
|
} else {
|
||||||
planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan())
|
planCalculationService.calculatePlansAfter(points, anchorPlan.toCalculatedPlan())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка.
|
||||||
|
*
|
||||||
|
* Если есть выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION) — цепочку
|
||||||
|
* продолжаем ПОСЛЕ него, а не после принятого базового плана. Иначе проекция заново вывела бы
|
||||||
|
* уже выданный слот и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) нарисовала бы
|
||||||
|
* его призрак-дубль PLANNED рядом с реальным WAITING_DECISION: точечный дедуп по
|
||||||
|
* businessKey в [PlanQueryService] такой рассинхрон не ловит.
|
||||||
|
*
|
||||||
|
* `activePlanId` заполнен только вместе с активной выдачей; в пути выдачи он к моменту
|
||||||
|
* вызова проекции уже снят, поэтому якорение на план в полёте не влияет на выбор головы.
|
||||||
|
*/
|
||||||
|
private fun resolveAnchorPlan(state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? {
|
||||||
|
val inFlight = state?.activePlanId
|
||||||
|
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
|
||||||
|
?.takeIf { plan -> plan.status in IN_FLIGHT_STATUSES }
|
||||||
|
if (inFlight != null) {
|
||||||
|
return inFlight
|
||||||
|
}
|
||||||
|
return state?.currentBasePlanId
|
||||||
|
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan =
|
private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan =
|
||||||
CalculatedPlan(
|
CalculatedPlan(
|
||||||
spacecraftId = spacecraftId,
|
spacecraftId = spacecraftId,
|
||||||
@@ -48,4 +72,9 @@ class PlanProjectionService(
|
|||||||
endTime = endTime,
|
endTime = endTime,
|
||||||
kppId = kppId
|
kppId = kppId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Статусы выданного, но ещё не принятого плана — он уже фиксирует слот цепочки. */
|
||||||
|
val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
@@ -76,10 +76,66 @@ class PlanProjectionServiceTest {
|
|||||||
verify(calculation, never()).calculatePlans(anyNonNull())
|
verify(calculation, never()).calculatePlans(anyNonNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchors the tail after the in-flight issued plan, not the accepted base`() {
|
||||||
|
val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN)
|
||||||
|
val inFlight = persistedPlan(time(2), time(5), "KPP-inflight", PlannedPlanStatus.WAITING_DECISION)
|
||||||
|
val calculation = mock(PlanCalculationService::class.java)
|
||||||
|
val expected = listOf(plan(time(4), time(7)))
|
||||||
|
var anchor: CalculatedPlan? = null
|
||||||
|
`when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||||
|
anchor = invocation.getArgument(1)
|
||||||
|
expected
|
||||||
|
}
|
||||||
|
val service = service(
|
||||||
|
state = SpacecraftPlanningStateEntity(
|
||||||
|
spacecraftId = spacecraftId,
|
||||||
|
currentBasePlanId = base.planId,
|
||||||
|
activePlanId = inFlight.planId
|
||||||
|
),
|
||||||
|
plansById = mapOf(base.planId to base, inFlight.planId to inFlight),
|
||||||
|
calculation = calculation
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = service.project(spacecraftId)
|
||||||
|
|
||||||
|
assertEquals(expected, result)
|
||||||
|
assertEquals(inFlight.startTime, anchor?.startTime)
|
||||||
|
assertEquals(inFlight.endTime, anchor?.endTime)
|
||||||
|
assertEquals(inFlight.kppId, anchor?.kppId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to the base plan when the active plan is no longer in flight`() {
|
||||||
|
val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN)
|
||||||
|
val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN)
|
||||||
|
val calculation = mock(PlanCalculationService::class.java)
|
||||||
|
var anchor: CalculatedPlan? = null
|
||||||
|
`when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||||
|
anchor = invocation.getArgument(1)
|
||||||
|
emptyList<CalculatedPlan>()
|
||||||
|
}
|
||||||
|
val service = service(
|
||||||
|
state = SpacecraftPlanningStateEntity(
|
||||||
|
spacecraftId = spacecraftId,
|
||||||
|
currentBasePlanId = base.planId,
|
||||||
|
activePlanId = accepted.planId
|
||||||
|
),
|
||||||
|
plansById = mapOf(base.planId to base, accepted.planId to accepted),
|
||||||
|
calculation = calculation
|
||||||
|
)
|
||||||
|
|
||||||
|
service.project(spacecraftId)
|
||||||
|
|
||||||
|
assertEquals(base.startTime, anchor?.startTime)
|
||||||
|
assertEquals(base.kppId, anchor?.kppId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun service(
|
private fun service(
|
||||||
snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(),
|
snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(),
|
||||||
state: SpacecraftPlanningStateEntity? = null,
|
state: SpacecraftPlanningStateEntity? = null,
|
||||||
basePlan: PlannedPlanEntity? = null,
|
basePlan: PlannedPlanEntity? = null,
|
||||||
|
plansById: Map<UUID, PlannedPlanEntity>? = null,
|
||||||
calculation: PlanCalculationService
|
calculation: PlanCalculationService
|
||||||
): PlanProjectionService {
|
): PlanProjectionService {
|
||||||
val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java)
|
val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java)
|
||||||
@@ -87,12 +143,33 @@ class PlanProjectionServiceTest {
|
|||||||
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
|
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
|
||||||
`when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state))
|
`when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state))
|
||||||
val plannedRepository = mock(PlannedPlanRepository::class.java)
|
val plannedRepository = mock(PlannedPlanRepository::class.java)
|
||||||
|
if (plansById != null) {
|
||||||
|
`when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation ->
|
||||||
|
Optional.ofNullable(plansById[invocation.getArgument(0)])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
`when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan))
|
`when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan))
|
||||||
|
}
|
||||||
val parser = mock(VisibilityPayloadParser::class.java)
|
val parser = mock(VisibilityPayloadParser::class.java)
|
||||||
`when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList()))
|
`when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList()))
|
||||||
return PlanProjectionService(snapshotRepository, stateRepository, plannedRepository, parser, calculation)
|
return PlanProjectionService(snapshotRepository, stateRepository, plannedRepository, parser, calculation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun persistedPlan(
|
||||||
|
start: LocalDateTime,
|
||||||
|
end: LocalDateTime,
|
||||||
|
kppId: String,
|
||||||
|
status: PlannedPlanStatus
|
||||||
|
): PlannedPlanEntity =
|
||||||
|
PlannedPlanEntity(
|
||||||
|
planId = UUID.randomUUID(),
|
||||||
|
spacecraftId = spacecraftId,
|
||||||
|
startTime = start,
|
||||||
|
endTime = end,
|
||||||
|
kppId = kppId,
|
||||||
|
status = status
|
||||||
|
)
|
||||||
|
|
||||||
private fun snapshot(): SpacecraftVisibilitySnapshotEntity =
|
private fun snapshot(): SpacecraftVisibilitySnapshotEntity =
|
||||||
SpacecraftVisibilitySnapshotEntity(
|
SpacecraftVisibilitySnapshotEntity(
|
||||||
spacecraftId = spacecraftId,
|
spacecraftId = spacecraftId,
|
||||||
|
|||||||
Reference in New Issue
Block a user