From a61e23c0955395ddf5a414d035c9dd39c10ff660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 16:53:24 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-service:=20=D0=BD=D0=B5=20=D0=B4=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=86=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проекция будущей цепочки якорилась только на принятый базовый план (currentBasePlanId) и игнорировала выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION). Из-за этого она заново выводила уже выданный слот, и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) точечный дедуп по businessKey в PlanQueryService не срабатывал — на вкладке «Планы» появлялся призрак-дубль PLANNED поверх WAITING_DECISION. Теперь якорь проекции — самая дальняя зафиксированная точка цепочки: если есть план в полёте, цепочку строим после него. На путь выдачи не влияет: там activePlanId к моменту вызова проекции уже снят. --- .../service/PlanProjectionService.kt | 37 ++++++++- .../service/PlanProjectionServiceTest.kt | 79 ++++++++++++++++++- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt index 64b7d3b..1e9b09a 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt @@ -4,6 +4,8 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.nstart.pcp_tgu_service.domain.CalculatedPlan 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.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository @@ -31,16 +33,38 @@ class PlanProjectionService( val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) - val basePlan = state?.currentBasePlanId - ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + val anchorPlan = resolveAnchorPlan(state) - return if (basePlan == null) { + return if (anchorPlan == null) { planCalculationService.calculatePlans(points) } 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 = CalculatedPlan( spacecraftId = spacecraftId, @@ -48,4 +72,9 @@ class PlanProjectionService( endTime = endTime, kppId = kppId ) + + private companion object { + /** Статусы выданного, но ещё не принятого плана — он уже фиксирует слот цепочки. */ + val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION) + } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt index 4efb71c..c40d53e 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -76,10 +76,66 @@ class PlanProjectionServiceTest { 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() + } + 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( snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(), state: SpacecraftPlanningStateEntity? = null, basePlan: PlannedPlanEntity? = null, + plansById: Map? = null, calculation: PlanCalculationService ): PlanProjectionService { val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) @@ -87,12 +143,33 @@ class PlanProjectionServiceTest { val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java) `when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state)) val plannedRepository = mock(PlannedPlanRepository::class.java) - `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + if (plansById != null) { + `when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation -> + Optional.ofNullable(plansById[invocation.getArgument(0)]) + } + } else { + `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + } val parser = mock(VisibilityPayloadParser::class.java) `when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList())) 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 = SpacecraftVisibilitySnapshotEntity( spacecraftId = spacecraftId,