From ae7effe1fa9fe21aead8d61972f1ced65823dad5 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: Wed, 3 Jun 2026 13:36:59 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-service:=20=D0=A45=20=E2=80=94=20promot?= =?UTF-8?q?e=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=81=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=BC=20=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D1=8B=20(POST=20/plans/{id}/promote)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 10 +- .../controller/PlanController.kt | 11 ++ .../service/CreateDraftUseCase.kt | 18 ++- .../service/IssueDuePlanWorker.kt | 104 +++++++++++++ .../service/CreateDraftUseCaseTest.kt | 23 ++- .../service/IssueDuePlanWorkerOnDemandTest.kt | 140 ++++++++++++++++++ 6 files changed, 299 insertions(+), 7 deletions(-) diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 0a1e1fa..852d7b1 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -272,8 +272,16 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6. - **Приёмка:** `200` с пустым diff; контракт стабилен для фронта. -### Фаза 5 — «Применить» = promote +### Фаза 5 — «Применить» = promote — ✅ СДЕЛАНО **Модель: Opus** (сердце корректности живого плана). + +> **Реализовано:** +> - `POST /api/plans/{draftPlanId}/promote` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [IssueDuePlanWorker.promoteDraftNow](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуя `startPreparedAttempt`). +> - **Оптимистичный лок базы:** [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt) снимает `spacecraft_planning_state.chain_version` на момент создания черновика и хранит его в `planned_plan.chain_version` DRAFT-строки. На promote [assertDraftBaseUnchanged](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) требует совпадения с текущей версией цепочки; любой ACCEPT/grace-сдвиг базы её инкрементит → **409** «база уехала, посмотрите дифф». Прежние версии остаются в БД (откат = новый черновик из старой версии). +> - **Контроль слота/дрейфа ЗРВ** ([assertDraftSlotStillValid](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt)): окно черновика должно совпадать с реальной будущей ЗРВ — для fork-forward слот берётся из проекции, для replace учитывается окно заменяемого базового плана (проекция, заякоренная после плана в полёте, его не возвращает). Сместился снапшот → **422**. +> - **Гард точки невозврата** переиспользован (`assertReissuableInSlot`): заморожено / активная выдача другого плана → 422 / 409. Вход в цепочку штатный: на внешний ACCEPT существующий `acceptPlan` делает супершединг (`chainVersion++`); `basePlanId` черновика не трогаем (провенанс — что замещаем/продолжаем). +> - Не-DRAFT план → 422, неизвестный черновик → 404. +> - Тесты: 7 кейсов promote в `IssueDuePlanWorkerOnDemandTest` (успех; конкурентный сдвиг базы → 409; не-DRAFT → 422; неизвестный → 404; заморозка → 422; чужая активная выдача → 409; дрейф окна → 422) + 1 кейс снимка версии в `CreateDraftUseCaseTest`. Весь модуль зелёный. - `pcp-tgu-service`: promote `draftPlanId` → выдача с **оптимистичным локом базы** (проверить, что `P0`/`currentBasePlanId` не изменились с момента создания черновика; иначе `409` «план изменился, посмотрите дифф»). Вход в цепочку: при внешнем `ACCEPT` существующий [acceptPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt#L142) делает супершединг (`chainVersion++`); для `fork-forward` `base_plan_id` уже выставлен. - Контроль **полноты перекрытия слота** при `replace` (остаточный риск раздела 10). - **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен). diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 35d79bb..322d3e8 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -59,4 +59,15 @@ class PlanController( @PostMapping("/{planId}/issue") fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse = issueDuePlanWorker.issuePlanNow(planId) + + /** + * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и выдаёт его. Под lock'ом + * состояния КА: оптимистичный лок базы (версия цепочки не уехала), контроль валидности слота + * и гард точки невозврата. + * 404 — черновика нет; 422 — план не DRAFT / слот сместился / точка невозврата пройдена; + * 409 — база изменилась с момента создания черновика либо по КА уже идёт выдача. + */ + @PostMapping("/{draftPlanId}/promote") + fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse = + issueDuePlanWorker.promoteDraftNow(draftPlanId) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt index 506d027..531ba1a 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -13,6 +13,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime import java.time.ZoneOffset import java.util.UUID @@ -36,7 +37,8 @@ class CreateDraftUseCase( private val plannedPlanRepository: PlannedPlanRepository, private val planProjectionService: PlanProjectionService, private val missionPlaningClient: MissionPlaningClient, - private val planningProperties: PlanningProperties + private val planningProperties: PlanningProperties, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository ) { private val log = LoggerFactory.getLogger(javaClass) @@ -182,6 +184,13 @@ class CreateDraftUseCase( kppId: String, clonedMissionId: UUID ): CreateDraftResponse { + // Снимок версии цепочки КА на момент создания черновика — оптимистичный токен для promote (Ф5): + // любой ACCEPT/grace-сдвиг базы инкрементит chain_version, и «Применить» по устаревшему черновику + // упадёт 409 «база уехала». chain_version у DRAFT хранит именно этот снимок (== живой при promote). + val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(base.spacecraftId) + .map { it.chainVersion } + .orElse(0L) + return try { val draft = plannedPlanRepository.save( PlannedPlanEntity( @@ -191,12 +200,13 @@ class CreateDraftUseCase( endTime = endTime, kppId = kppId, status = PlannedPlanStatus.DRAFT, - basePlanId = base.planId + basePlanId = base.planId, + chainVersion = baseChainVersion ) ) log.info( - "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}", - draft.planId, base.planId, base.spacecraftId + "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}", + draft.planId, base.planId, base.spacecraftId, baseChainVersion ) CreateDraftResponse(draftPlanId = draft.planId) } catch (ex: Exception) { 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 fa9ea8e..e4985c4 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 @@ -59,6 +59,29 @@ class IssueDuePlanWorker( return startPreparedAttempt(prepared) } + /** + * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и сразу выдаёт его. + * + * Под lock'ом состояния КА проверяет (в порядке): + * 1. план существует и действительно DRAFT (иначе 404 / 422); + * 2. **оптимистичный лок базы** — версия цепочки не изменилась с момента создания черновика + * ([assertDraftBaseUnchanged]); иначе 409 «база уехала, посмотрите дифф»; + * 3. **слот ещё валиден** — окно черновика по-прежнему соответствует реальной будущей ЗРВ + * (дрейф снапшота / перекрытие слота при replace, [assertDraftSlotStillValid]); иначе 422; + * 4. гард точки невозврата ([assertReissuableInSlot]) — не заморожено и нет активной выдачи + * другого плана; иначе 422 / 409. + * + * Затем DRAFT→ISSUING, новая attempt, и старт BPMN. Цепочка продолжается штатно: на внешний + * ACCEPT существующий acceptPlan сделает супершединг (chainVersion++). Прежние версии остаются + * в БД (откат = новый черновик из старой версии). basePlanId черновика не трогаем — это его + * провенанс (какой план он замещает/продолжает). + */ + fun promoteDraftNow(draftPlanId: UUID): ProcessStartResponse { + val prepared = transactionTemplate.execute { prepareDraftPromotion(draftPlanId) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Draft plan with planId=$draftPlanId was not found") + return startPreparedAttempt(prepared) + } + /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { val now = LocalDateTime.now(ZoneOffset.UTC) @@ -156,6 +179,87 @@ class IssueDuePlanWorker( return PreparedAttempt(plan.planId, attempt.attemptId) } + private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? { + val now = LocalDateTime.now(ZoneOffset.UTC) + val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null + if (plan.status != PlannedPlanStatus.DRAFT) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $draftPlanId не является черновиком (status=${plan.status}) — «Применить» доступно только для DRAFT" + ) + } + + val state = getOrCreateLockedState(plan.spacecraftId) + assertDraftBaseUnchanged(plan, state) + assertDraftSlotStillValid(plan) + assertReissuableInSlot(plan, state, now) + + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + val attempt = createStartingAttempt(plan.planId, now) + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + log.info( + "Черновик {} применён (promote): spacecraft={}, окно [{}–{}], chainVersion={}", + plan.planId, plan.spacecraftId, plan.startTime, plan.endTime, plan.chainVersion + ) + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + /** + * Оптимистичный лок базы для promote: версия цепочки, снятая при создании черновика + * ([PlannedPlanEntity.chainVersion] у DRAFT), должна совпадать с текущей версией цепочки КА. + * Любой ACCEPT/grace-сдвиг базы между созданием черновика и «Применить» инкрементит версию — + * тогда черновик строился от устаревшей базы и его нужно пересоздать (посмотрев дифф). + */ + private fun assertDraftBaseUnchanged(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity) { + if (plan.chainVersion != state.chainVersion) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "База КА ${plan.spacecraftId} изменилась с момента создания черновика ${plan.planId} " + + "(цепочка v${plan.chainVersion} → v${state.chainVersion}) — посмотрите дифф и пересоздайте черновик" + ) + } + } + + /** + * Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно + * по-прежнему соответствовать реальной будущей ЗРВ. Для fork-forward слот есть в проекции; + * для replace слот = окно заменяемого базового плана, которое проекция (заякоренная ПОСЛЕ + * плана в полёте) не возвращает, поэтому окно базы учитывается явно. Сверка по КПП и времени + * с точностью до секунды (как при создании черновика). Не совпало → снапшот сместился → 422. + */ + private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) { + val candidates = planProjectionService.project(plan.spacecraftId) + .map { Triple(it.kppId, it.startTime, it.endTime) } + .toMutableList() + plan.basePlanId + ?.let { plannedPlanRepository.findById(it).orElse(null) } + ?.let { candidates.add(Triple(it.kppId, it.startTime, it.endTime)) } + + val matches = candidates.any { (kppId, start, end) -> + kppId == plan.kppId && + diffSeconds(start, plan.startTime) < 1 && + diffSeconds(end, plan.endTime) < 1 + } + if (!matches) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " + + "больше не соответствует ни одной будущей ЗРВ — снапшот сместился, пересоздайте черновик" + ) + } + } + + private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = + Math.abs(java.time.Duration.between(a, b).toSeconds()) + /** * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException], * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия: diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt index 6b167ff..15e1b34 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt @@ -17,9 +17,11 @@ import space.nstart.pcp_tgu_service.dto.CreateDraftRequest import space.nstart.pcp_tgu_service.dto.DraftMode 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.MissionCloneResponse import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime import java.time.ZoneOffset import java.util.Optional @@ -50,6 +52,19 @@ class CreateDraftUseCaseTest { assertEquals(base.kppId, saved.kppId) } + @Test + fun `draft snapshots the current chain version as its optimistic token`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val fixture = fixture( + base, + planningState = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 7) + ) + + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(7L, fixture.lastSaved!!.chainVersion) + } + @Test fun `replace rejects a DRAFT base`() { val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT) @@ -209,7 +224,8 @@ class CreateDraftUseCaseTest { cloneResult: UUID = UUID.randomUUID(), projection: List = emptyList(), existingDrafts: List = emptyList(), - failSave: Boolean = false + failSave: Boolean = false, + planningState: SpacecraftPlanningStateEntity? = null ): Fixture { val repo = mock(PlannedPlanRepository::class.java) val basePlanId = base?.planId ?: UUID.randomUUID() @@ -231,7 +247,10 @@ class CreateDraftUseCaseTest { `when`(client.cloneMission(anyUuid(), anyNonNull())) .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) - val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10)) + val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepo.findById(spacecraftId)).thenReturn(Optional.ofNullable(planningState)) + + val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10), stateRepo) return Fixture(useCase, client, saved) } 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 ad05243..e941485 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 @@ -285,6 +285,146 @@ class IssueDuePlanWorkerOnDemandTest { assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } + // --- Применить черновик: promoteDraftNow (Ф5, оптимистичный лок базы) --- + + @Test + fun `promoteDraftNow promotes a fresh draft into the chain and issues it`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Заменяемый базовый план (даёт окно для проверки валидности слота при replace). + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + worker.promoteDraftNow(draft.planId) + + // DRAFT ушёл в выдачу: markStarted перевёл его в WAITING_DECISION, КА указывает на новую attempt. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status) + assertEquals(1, attemptStore.size) + assertEquals(draft.planId, state.activePlanId) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects with 409 when the chain moved since the draft was created`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + // Черновик снят на версии цепочки 0, а база уже уехала вперёд (accept/grace) → состояние v1. + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 1) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects a non-draft plan with 422`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val live = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[live.planId] = live + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(live.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow returns 404 for unknown draft`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(UUID.randomUUID()) } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `promoteDraftNow rejects a draft inside the freeze window with 422`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт через 5 мин (< порога 10 мин): слот валиден (база даёт окно), но точка невозврата пройдена. + val base = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow rejects with 409 when another plan is being issued`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + // По КА уже идёт активная выдача другого плана. + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + chainVersion = 0, + activePlanId = UUID.randomUUID(), + activeAttemptId = UUID.randomUUID() + ) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow rejects with 422 when the draft window no longer matches any future ZRV`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Нет базового плана и пустая проекция → окно слота не подтверждается (снапшот сместился). + val draft = draftPlan(now().plusMinutes(30), now().plusHours(3), chainVersion = 0, basePlanId = null) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + } + + private fun draftPlan( + start: LocalDateTime, + end: LocalDateTime, + chainVersion: Long, + basePlanId: UUID? + ): PlannedPlanEntity = + persistedPlan(PlannedPlanStatus.DRAFT, start, end).apply { + this.chainVersion = chainVersion + this.basePlanId = basePlanId + } + private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(),