pcp-tgu-service: боевой POST /api/plans/{planId}/issue + гард точки невозврата

- ручная выдача вынесена из TestTguController в PlanController
- assertReissuableInSlot: 422 при LAID_IN и заморозке (now >= start - layinFreezeMinutes),
  409 при активной выдаче другого плана КА; перевыдача того же плана поверх своей
  in-flight attempt разрешена (новая attemptNo++ замещает летящую)
- REISSUABLE_STATUSES удалён — гард по времени/статусу его заместил
- layinFreezeMinutes (деф. 10) в PlanningProperties + config-repo/application yaml
- граничные тесты в IssueDuePlanWorkerOnDemandTest, переписан PlanIssueAndDecisionFlowTest
- дизайн-док версионирования (фаза 1 отмечена сделанной)
This commit is contained in:
Дмитрий Соловьев
2026-06-03 12:40:03 +03:00
parent c32e13e09c
commit e060e13b85
12 changed files with 489 additions and 41 deletions
@@ -46,5 +46,13 @@ data class PlanningProperties(
* без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION,
* но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки.
*/
val waitingDecisionGraceMinutes: Long
val waitingDecisionGraceMinutes: Long,
/**
* Порог заморозки перед стартом плана для ручной перевыдачи в свой слот (replace).
* Если до начала плана осталось меньше этого числа минут (now >= startTime - layinFreezeMinutes),
* план считается невозвратным: заменить его в своём слоте уже нельзя (доступен только fork-forward).
* Вместе со статусом LAID_IN образует «точку невозврата».
*/
val layinFreezeMinutes: Long
)
@@ -1,18 +1,23 @@
package space.nstart.pcp_tgu_service.controller
/** Содержит методы API для чтения текущих планов. */
/** Содержит методы API для чтения текущих планов и ручной выдачи плана. */
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp_tgu_service.dto.PlanResponse
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
import space.nstart.pcp_tgu_service.service.PlanQueryService
import java.util.UUID
@RestController
@RequestMapping("/api/plans")
class PlanController(
private val planQueryService: PlanQueryService
private val planQueryService: PlanQueryService,
private val issueDuePlanWorker: IssueDuePlanWorker
) {
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
@@ -27,4 +32,14 @@ class PlanController(
@RequestParam(defaultValue = "7") historyDays: Long
): List<PlanResponse> =
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
/**
* Ручная выдача плана оператором («Применить / Отправить вручную»). Стартует новую attempt
* и Camunda-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки,
* не заложен (≠ LAID_IN) и по КА не должно быть активной выдачи.
* 404 — плана нет; 409 — по КА уже идёт выдача; 422 — точка невозврата пройдена (нужен fork-forward).
*/
@PostMapping("/{planId}/issue")
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
issueDuePlanWorker.issuePlanNow(planId)
}
@@ -12,10 +12,8 @@ import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanResponse
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
import space.nstart.pcp_tgu_service.service.PlanProjectionService
import space.nstart.pcp_tgu_service.service.PlanQueryService
import space.nstart.pcp_tgu_service.service.PlatformService
@@ -35,7 +33,6 @@ import java.util.UUID
class TestTguController(
private val visibilityRefreshJobService: VisibilityRefreshJobService,
private val planProjectionService: PlanProjectionService,
private val issueDuePlanWorker: IssueDuePlanWorker,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
private val testPlanDecisionService: TestPlanDecisionService,
private val externalPointsClient: ExternalPointsClient,
@@ -56,11 +53,6 @@ class TestTguController(
fun getProjection(@PathVariable spacecraftId: String): List<PlanResponse> =
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */
@PostMapping("/plans/{planId}/issue")
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
issueDuePlanWorker.issuePlanNow(planId)
/** Инжектит decision event через тот же use case, что Kafka consumer. */
@PostMapping("/plan-decisions")
fun handleDecision(@RequestBody message: PlanDecisionMessage) {
@@ -48,10 +48,14 @@ class IssueDuePlanWorker(
}
}
/** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */
/**
* Ручная выдача конкретного persisted plan: создаёт новую attempt и стартует её.
* Гард «точки невозврата» (см. [assertReissuableInSlot]) выполняется под lock'ом состояния КА.
* Бросает 404, если плана нет; 409/422 — если выдать его нельзя.
*/
fun issuePlanNow(planId: UUID): ProcessStartResponse {
val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found or cannot be issued")
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
return startPreparedAttempt(prepared)
}
@@ -136,12 +140,7 @@ class IssueDuePlanWorker(
val now = LocalDateTime.now(ZoneOffset.UTC)
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId)
if (state.activeAttemptId != null) {
return null
}
if (plan.status !in REISSUABLE_STATUSES) {
return null
}
assertReissuableInSlot(plan, state, now)
plan.status = PlannedPlanStatus.ISSUING
plan.updatedAt = now
@@ -157,6 +156,44 @@ class IssueDuePlanWorker(
return PreparedAttempt(plan.planId, attempt.attemptId)
}
/**
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
* - по КА нет активной выдачи ДРУГОГО плана (иначе 409 CONFLICT — две выдачи разом недопустимы);
* - план не подтверждён как заложенный (`status != LAID_IN` — иначе 422, доступен только fork-forward);
* - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 — окно заморожено).
*
* Важно: повторную ручную выдачу ТОГО ЖЕ плана разрешаем, даже если по нему уже летит attempt
* (например, план только что ушёл в авто-выдачу за `notificationBeforeStartMinutes`). Оператор сохраняет
* право перевыдать именно этот план вплоть до порога заморозки — новая attempt замещает текущую in-flight
* (запоздалое решение по старой attempt запишется как stale и цепочку не тронет).
*
* Узкого списка «перевыдаваемых» статусов больше нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN,
* пока план в будущем за порогом и не заложен.
*/
private fun assertReissuableInSlot(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity, now: LocalDateTime) {
if (state.activeAttemptId != null && state.activePlanId != plan.planId) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"По КА ${plan.spacecraftId} уже идёт выдача другого плана (${state.activePlanId}) — дождитесь её завершения"
)
}
if (plan.status == PlannedPlanStatus.LAID_IN) {
throw ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"План ${plan.planId} уже заложен (LAID_IN) — перевыдача в свой слот невозможна, используйте fork-forward"
)
}
val freezeAt = plan.startTime.minusMinutes(planningProperties.layinFreezeMinutes)
if (!now.isBefore(freezeAt)) {
throw ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"До старта плана ${plan.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин — " +
"окно выдачи заморожено (точка невозврата), используйте fork-forward"
)
}
}
private fun materializeIssuingPlan(
head: CalculatedPlan,
state: SpacecraftPlanningStateEntity,
@@ -255,15 +292,4 @@ class IssueDuePlanWorker(
val planId: UUID,
val attemptId: UUID
)
private companion object {
/** Статусы persisted-плана, который допустимо выдать вручную через issuePlanNow (test-only). */
val REISSUABLE_STATUSES = setOf(
PlannedPlanStatus.PLANNED,
PlannedPlanStatus.REJECTED,
PlannedPlanStatus.LAYIN_FAILED,
PlannedPlanStatus.NOT_ACCEPTED,
PlannedPlanStatus.START_AMBIGUOUS
)
}
}
@@ -25,6 +25,7 @@ planning:
reissue-prep-minutes: 30
worker-lock-ttl-seconds: 300
waiting-decision-grace-minutes: 30
layin-freeze-minutes: 10
topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
@@ -116,6 +116,7 @@ class ExternalPointsClientTest {
minReissueLeadMinutes = 5,
reissuePrepMinutes = 30,
workerLockTtlSeconds = 300,
waitingDecisionGraceMinutes = 30
waitingDecisionGraceMinutes = 30,
layinFreezeMinutes = 10
)
}
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
starter: AcceptedPlanSlotsUpdateProcessStarter
): AcceptedPlanSlotsUpdateWorker =
AcceptedPlanSlotsUpdateWorker(
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30),
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10),
acceptedPlanSlotsUpdateJobRepository = repository,
acceptedPlanSlotsUpdateProcessStarter = starter,
transactionTemplate = transactionTemplate()
@@ -1,10 +1,14 @@
package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
@@ -18,6 +22,7 @@ import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
@@ -160,6 +165,126 @@ class IssueDuePlanWorkerOnDemandTest {
assertEquals(attemptId, state.activeAttemptId)
}
// --- Ручная выдача issuePlanNow + гард «точки невозврата» (layinFreezeMinutes = 10) ---
@Test
fun `issuePlanNow issues a future not-laid-in plan past the freeze threshold`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Принятый план в будущем (старт через 30 мин > порога 10 мин), активной выдачи нет.
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
planStore[target.planId] = target
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val worker = worker(planStore, attemptStore, state)
worker.issuePlanNow(target.planId)
// Выдача стартовала: новая attempt, план ушёл в WAITING_DECISION (markStarted), КА залочен.
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
assertEquals(1, attemptStore.size)
assertEquals(target.planId, state.activePlanId)
assertNotNull(state.activeAttemptId)
}
@Test
fun `issuePlanNow rejects a plan inside the freeze window`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// Старт через 5 мин (< порога 10 мин) → точка невозврата пройдена.
val target = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3))
planStore[target.planId] = target
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val worker = worker(planStore, attemptStore, state)
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
assertEquals(PlannedPlanStatus.ACCEPTED, planStore[target.planId]!!.status)
assertTrue(attemptStore.isEmpty())
assertNull(state.activeAttemptId)
}
@Test
fun `issuePlanNow rejects a laid-in plan`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// В будущем за порогом, но уже подтверждён как заложенный → только fork-forward.
val target = persistedPlan(PlannedPlanStatus.LAID_IN, now().plusMinutes(30), now().plusHours(3))
planStore[target.planId] = target
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val worker = worker(planStore, attemptStore, state)
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
assertTrue(attemptStore.isEmpty())
assertNull(state.activeAttemptId)
}
@Test
fun `issuePlanNow rejects when spacecraft has an active attempt for another plan`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
planStore[target.planId] = target
// Активная выдача идёт по ДРУГОМУ плану того же КА — две выдачи разом недопустимы.
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
activePlanId = UUID.randomUUID(),
activeAttemptId = UUID.randomUUID()
)
val worker = worker(planStore, attemptStore, state)
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
assertTrue(attemptStore.isEmpty())
}
@Test
fun `issuePlanNow re-issues the same plan over its own in-flight attempt`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// План уже ушёл в авто-выдачу (attempt #1 в полёте), но до старта далеко (> порога).
val target = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
planStore[target.planId] = target
val inFlight = PlanIssueAttemptEntity(
attemptId = UUID.randomUUID(),
planId = target.planId,
attemptNo = 1,
status = PlanIssueAttemptStatus.STARTED,
createdAt = now(),
updatedAt = now()
)
attemptStore[inFlight.attemptId] = inFlight
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
activePlanId = target.planId,
activeAttemptId = inFlight.attemptId
)
val worker = worker(planStore, attemptStore, state)
worker.issuePlanNow(target.planId)
// Новая attempt #2 замещает летящую #1; план снова WAITING_DECISION, КА указывает на новую attempt.
assertEquals(2, attemptStore.size)
assertEquals(target.planId, state.activePlanId)
assertNotEquals(inFlight.attemptId, state.activeAttemptId)
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
}
@Test
fun `issuePlanNow returns 404 for unknown plan`() {
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.issuePlanNow(UUID.randomUUID()) }
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
PlannedPlanEntity(
planId = UUID.randomUUID(),
@@ -264,7 +389,7 @@ class IssueDuePlanWorkerOnDemandTest {
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30)
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, 10)
private fun transactionTemplate(): TransactionTemplate =
TransactionTemplate(
@@ -340,7 +340,8 @@ class PlanCalculationServiceTest {
minReissueLeadMinutes = 5,
reissuePrepMinutes = 30,
workerLockTtlSeconds = 300,
waitingDecisionGraceMinutes = 30
waitingDecisionGraceMinutes = 30,
layinFreezeMinutes = 10
)
)
@@ -38,8 +38,9 @@ import java.util.UUID
class PlanIssueAndDecisionFlowTest {
@Test
fun `issuePlanNow creates one active attempt and repeated manual issue creates a new attempt after state cleared`() {
fun `repeated manual issue of the same plan supersedes its in-flight attempt`() {
val spacecraftId = "25544"
// Старт через 12 часов — далеко за порогом заморозки, перевыдача того же плана разрешена.
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED)
val planStore = linkedMapOf(plan.planId to plan)
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
@@ -47,17 +48,14 @@ class PlanIssueAndDecisionFlowTest {
val worker = issueWorker(planStore, attemptStore, state)
val firstResponse = worker.issuePlanNow(plan.planId)
val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull()
state.activePlanId = null
state.activeAttemptId = null
plan.status = PlannedPlanStatus.REJECTED
// Тот же план, attempt #1 ещё «в полёте» — ручная перевыдача не блокируется, а замещает её.
val secondResponse = worker.issuePlanNow(plan.planId)
assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
assertNotEquals(firstResponse.attemptId, secondResponse.attemptId)
assertEquals(secondResponse.attemptId, state.activeAttemptId)
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true)
}
@Test
@@ -333,7 +331,7 @@ class PlanIssueAndDecisionFlowTest {
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
private fun planningProperties(): PlanningProperties =
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30)
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10)
private fun transactionTemplate(): TransactionTemplate =
TransactionTemplate(