pcp-tgu-service: Ф5 — promote черновика с оптимистичным локом базы (POST /plans/{id}/promote)

This commit is contained in:
Дмитрий Соловьев
2026-06-03 13:36:59 +03:00
parent 8133a5144f
commit ae7effe1fa
6 changed files with 299 additions and 7 deletions
@@ -272,8 +272,16 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
- `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6. - `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6.
- **Приёмка:** `200` с пустым diff; контракт стабилен для фронта. - **Приёмка:** `200` с пустым diff; контракт стабилен для фронта.
### Фаза 5 — «Применить» = promote ### Фаза 5 — «Применить» = promote — ✅ СДЕЛАНО
**Модель: Opus** (сердце корректности живого плана). **Модель: 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` уже выставлен. - `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). - Контроль **полноты перекрытия слота** при `replace` (остаточный риск раздела 10).
- **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен). - **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен).
@@ -59,4 +59,15 @@ class PlanController(
@PostMapping("/{planId}/issue") @PostMapping("/{planId}/issue")
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse = fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
issueDuePlanWorker.issuePlanNow(planId) issueDuePlanWorker.issuePlanNow(planId)
/**
* «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и выдаёт его. Под lock'ом
* состояния КА: оптимистичный лок базы (версия цепочки не уехала), контроль валидности слота
* и гард точки невозврата.
* 404 — черновика нет; 422 — план не DRAFT / слот сместился / точка невозврата пройдена;
* 409 — база изменилась с момента создания черновика либо по КА уже идёт выдача.
*/
@PostMapping("/{draftPlanId}/promote")
fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse =
issueDuePlanWorker.promoteDraftNow(draftPlanId)
} }
@@ -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.MissionCloneRequest
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
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 java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.UUID import java.util.UUID
@@ -36,7 +37,8 @@ class CreateDraftUseCase(
private val plannedPlanRepository: PlannedPlanRepository, private val plannedPlanRepository: PlannedPlanRepository,
private val planProjectionService: PlanProjectionService, private val planProjectionService: PlanProjectionService,
private val missionPlaningClient: MissionPlaningClient, private val missionPlaningClient: MissionPlaningClient,
private val planningProperties: PlanningProperties private val planningProperties: PlanningProperties,
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
@@ -182,6 +184,13 @@ class CreateDraftUseCase(
kppId: String, kppId: String,
clonedMissionId: UUID clonedMissionId: UUID
): CreateDraftResponse { ): 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 { return try {
val draft = plannedPlanRepository.save( val draft = plannedPlanRepository.save(
PlannedPlanEntity( PlannedPlanEntity(
@@ -191,12 +200,13 @@ class CreateDraftUseCase(
endTime = endTime, endTime = endTime,
kppId = kppId, kppId = kppId,
status = PlannedPlanStatus.DRAFT, status = PlannedPlanStatus.DRAFT,
basePlanId = base.planId basePlanId = base.planId,
chainVersion = baseChainVersion
) )
) )
log.info( log.info(
"Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}", "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
draft.planId, base.planId, base.spacecraftId draft.planId, base.planId, base.spacecraftId, baseChainVersion
) )
CreateDraftResponse(draftPlanId = draft.planId) CreateDraftResponse(draftPlanId = draft.planId)
} catch (ex: Exception) { } catch (ex: Exception) {
@@ -59,6 +59,29 @@ class IssueDuePlanWorker(
return startPreparedAttempt(prepared) 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). */ /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now(ZoneOffset.UTC)
@@ -156,6 +179,87 @@ class IssueDuePlanWorker(
return PreparedAttempt(plan.planId, attempt.attemptId) 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], * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия: * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
@@ -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.dto.DraftMode
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.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.MissionCloneResponse
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
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 java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.Optional import java.util.Optional
@@ -50,6 +52,19 @@ class CreateDraftUseCaseTest {
assertEquals(base.kppId, saved.kppId) 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 @Test
fun `replace rejects a DRAFT base`() { fun `replace rejects a DRAFT base`() {
val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT) val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
@@ -209,7 +224,8 @@ class CreateDraftUseCaseTest {
cloneResult: UUID = UUID.randomUUID(), cloneResult: UUID = UUID.randomUUID(),
projection: List<CalculatedPlan> = emptyList(), projection: List<CalculatedPlan> = emptyList(),
existingDrafts: List<PlannedPlanEntity> = emptyList(), existingDrafts: List<PlannedPlanEntity> = emptyList(),
failSave: Boolean = false failSave: Boolean = false,
planningState: SpacecraftPlanningStateEntity? = null
): Fixture { ): Fixture {
val repo = mock(PlannedPlanRepository::class.java) val repo = mock(PlannedPlanRepository::class.java)
val basePlanId = base?.planId ?: UUID.randomUUID() val basePlanId = base?.planId ?: UUID.randomUUID()
@@ -231,7 +247,10 @@ class CreateDraftUseCaseTest {
`when`(client.cloneMission(anyUuid(), anyNonNull())) `when`(client.cloneMission(anyUuid(), anyNonNull()))
.thenReturn(MissionCloneResponse(newMissionId = cloneResult)) .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) return Fixture(useCase, client, saved)
} }
@@ -285,6 +285,146 @@ class IssueDuePlanWorkerOnDemandTest {
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
} }
// --- Применить черновик: promoteDraftNow (Ф5, оптимистичный лок базы) ---
@Test
fun `promoteDraftNow promotes a fresh draft into the chain and issues it`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Заменяемый базовый план (даёт окно для проверки валидности слота при 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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Старт через 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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Нет базового плана и пустая проекция → окно слота не подтверждается (снапшот сместился).
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 = private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
PlannedPlanEntity( PlannedPlanEntity(
planId = UUID.randomUUID(), planId = UUID.randomUUID(),