diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 0df08a4..50c3800 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -41,6 +41,7 @@ planning: min-reissue-lead-minutes: 5 reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 + waiting-decision-grace-minutes: 30 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index bd8241f..0ac4329 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -39,5 +39,12 @@ data class PlanningProperties( val reissuePrepMinutes: Long, /** TTL lease'а worker job claim в секундах. */ - val workerLockTtlSeconds: Long + val workerLockTtlSeconds: Long, + + /** + * Сколько минут после начала плана ждать решения оператора, прежде чем продолжить цепочку + * без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION, + * но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки. + */ + val waitingDecisionGraceMinutes: Long ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index 79d8968..c361ee6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -58,7 +58,7 @@ class IssueDuePlanWorker( private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { val now = LocalDateTime.now() val state = getOrCreateLockedState(spacecraftId) - if (state.activeAttemptId != null) { + if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) { return null } @@ -96,6 +96,41 @@ class IssueDuePlanWorker( return PreparedAttempt(plan.planId, attempt.attemptId) } + /** + * Если активная выдача — это план в WAITING_DECISION, по которому уже прошло + * [PlanningProperties.waitingDecisionGraceMinutes] минут после старта и решения так и нет, + * перестаём ждать подтверждение: снимаем блокировку (activePlanId/activeAttemptId), + * двигаем якорь цепочки на этот план и идём дальше. Статус плана НЕ меняем — он остаётся + * WAITING_DECISION (молчаливое продолжение, а не ACCEPTED). Attempt оставляем как есть: + * запоздалое решение по нему просто запишется на attempt, не трогая ушедшую вперёд цепочку. + * + * @return true, если блокировка снята и можно выдавать следующий план в этом же тике. + */ + private fun tryAdvancePastUnconfirmedPlan(state: SpacecraftPlanningStateEntity, now: LocalDateTime): Boolean { + val activePlanId = state.activePlanId ?: return false + val plan = plannedPlanRepository.findById(activePlanId).orElse(null) ?: return false + if (plan.status != PlannedPlanStatus.WAITING_DECISION) { + return false + } + if (now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))) { + return false + } + + state.currentBasePlanId = plan.planId + state.activePlanId = null + state.activeAttemptId = null + state.urgentReissueFrom = null + state.chainVersion += 1 + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + log.info( + "Plan {} (spacecraft {}) stayed WAITING_DECISION past grace; continuing chain without confirmation.", + plan.planId, plan.spacecraftId + ) + return true + } + private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { val now = LocalDateTime.now() val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 14e21ef..99adb35 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -24,6 +24,7 @@ planning: min-reissue-lead-minutes: 5 reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 + waiting-decision-grace-minutes: 30 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index d90e0a4..5cd1237 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -115,6 +115,7 @@ class ExternalPointsClientTest { notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, reissuePrepMinutes = 30, - workerLockTtlSeconds = 300 + workerLockTtlSeconds = 300, + waitingDecisionGraceMinutes = 30 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt index 52f6246..5f00dad 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest { starter: AcceptedPlanSlotsUpdateProcessStarter ): AcceptedPlanSlotsUpdateWorker = AcceptedPlanSlotsUpdateWorker( - planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300), + planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30), acceptedPlanSlotsUpdateJobRepository = repository, acceptedPlanSlotsUpdateProcessStarter = starter, transactionTemplate = transactionTemplate() diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index c2c381c..1604334 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -103,6 +103,72 @@ class IssueDuePlanWorkerOnDemandTest { verify(projection, never()).project(spacecraftId) } + @Test + fun `advances chain past WAITING_DECISION plan once grace elapsed and issues next without accepting it`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Активный план висит в WAITING_DECISION; старт был 40 минут назад (grace=30 → истёк). + val unconfirmed = persistedPlan( + status = PlannedPlanStatus.WAITING_DECISION, + start = now().minusMinutes(40), + end = now().plusHours(1) + ) + planStore[unconfirmed.planId] = unconfirmed + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = UUID.randomUUID(), + activePlanId = unconfirmed.planId, + activeAttemptId = UUID.randomUUID() + ) + val next = plan(now().plusMinutes(10), now().plusHours(3)) + val worker = worker(planStore, attemptStore, state, projection = listOf(next)) + + worker.issueDuePlans() + + // Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status) + // Якорь цепочки сдвинут на него, следующий план выдан с новым attempt. + assertEquals(unconfirmed.planId, state.currentBasePlanId) + assertEquals(2, planStore.size) + assertNotNull(state.activeAttemptId) + assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime) + } + + @Test + fun `keeps blocking on WAITING_DECISION plan before grace elapses`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт был всего 10 минут назад (grace=30 → ещё не истёк) → блокировка держится. + val unconfirmed = persistedPlan( + status = PlannedPlanStatus.WAITING_DECISION, + start = now().minusMinutes(10), + end = now().plusHours(1) + ) + planStore[unconfirmed.planId] = unconfirmed + val attemptId = UUID.randomUUID() + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + activePlanId = unconfirmed.planId, + activeAttemptId = attemptId + ) + val worker = worker(planStore, attemptStore, state, projection = listOf(plan(now().plusMinutes(10), now().plusHours(3)))) + + worker.issueDuePlans() + + assertEquals(1, planStore.size) + assertEquals(attemptId, state.activeAttemptId) + } + + private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status + ) + private fun worker( planStore: MutableMap, attemptStore: MutableMap, @@ -195,7 +261,7 @@ class IssueDuePlanWorkerOnDemandTest { private fun now(): LocalDateTime = LocalDateTime.now() private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300) + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index b3315df..f2c1877 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -339,7 +339,8 @@ class PlanCalculationServiceTest { notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, reissuePrepMinutes = 30, - workerLockTtlSeconds = 300 + workerLockTtlSeconds = 300, + waitingDecisionGraceMinutes = 30 ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 3f1e376..87e1b88 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -332,7 +332,7 @@ class PlanIssueAndDecisionFlowTest { private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300) + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate(