pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ

Бэкенд (pcp-tgu-service):
- REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ
  взводится, цепочка встаёт на паузу (новый флаг
  SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические
  негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу.
- Пауза централизована в PlanProjectionService.project(): для отклонённой
  цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг
  снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался).
- Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT.
- Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT,
  повторное создание окна -> 409.
- ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с),
  устойчиво к усечению ISO на фронте до целых секунд.

Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана,
панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков.

Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 00:04:47 +03:00
parent 3638afdb85
commit 5049395911
34 changed files with 1062 additions and 458 deletions
@@ -41,6 +41,15 @@ class PlanController(
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List<PlanResponse> =
planQueryService.getAllPlans(historyDays)
/**
* Черновики (DRAFT) для режима «Черновики» во вкладке «Планы»: созданные, но ещё не
* отправленные планы-кандидаты. Отдельный эндпоинт, т.к. остальные чтения DRAFT отсекают.
* Литеральный путь `/drafts` приоритетнее шаблона `/{spacecraftId}`.
*/
@GetMapping("/drafts")
fun getDraftPlans(@RequestParam(required = false) spacecraftId: String?): List<PlanResponse> =
planQueryService.getDraftPlans(spacecraftId)
/** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
@GetMapping("/{spacecraftId}")
fun getPlansBySpacecraftId(
@@ -35,6 +35,13 @@ class SpacecraftPlanningStateEntity(
@Column(name = "urgent_reissue_from")
var urgentReissueFrom: LocalDateTime? = null,
/**
* Цепочка стоит на паузе после REJECTED: проекция пуста, воркер не выдаёт. Снимается при ручной
* выдаче / промоуте черновика / приёмке — то есть когда оператор осознанно вмешался.
*/
@Column(name = "paused_after_reject", nullable = false)
var pausedAfterReject: Boolean = false,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
) {
@@ -16,6 +16,17 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
/**
* Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков:
* сочетание (КА + начало + конец) уникально среди DRAFT.
*/
fun findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
spacecraftId: String,
startTime: LocalDateTime,
endTime: LocalDateTime,
status: PlannedPlanStatus
): List<PlannedPlanEntity>
/** Возвращает все планы в порядке старта (для read-проекции и истории). */
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
@@ -75,6 +75,21 @@ class CreateChainPlanUseCase(
plan
}
// Гард на дубли: сочетание (КА + начало + конец) уникально среди черновиков. Повторное создание
// того же окна не плодит второй черновик — оператор не теряется в одинаковых кандидатах.
val duplicate = plannedPlanRepository
.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
req.spacecraftId, req.windowStart, req.windowStop, PlannedPlanStatus.DRAFT
)
.firstOrNull()
if (duplicate != null) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Черновик на окно [${req.windowStart}${req.windowStop}] для КА ${req.spacecraftId} уже есть " +
"(${duplicate.planId}) — откройте его или удалите"
)
}
// Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой).
val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId)
if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) {
@@ -25,8 +25,11 @@ import java.time.ZoneOffset
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
*
* - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
* - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ
* запускается срочная перевыдача ([reissueOnNearestWindow]).
* - REJECTED — план отклонён как неправильный: помечается терминально, авто-перевыдача НЕ
* запускается. Цепочка встаёт на паузу — оператор должен вручную создать исправленный план
* («Создать план»). Бессмысленно автоматически перевыдавать тот же план на то же окно.
* - «не заложен» / «не принят» — технический сбой доставки (содержимое не отклонялось): план
* помечается терминально, и на ближайшую ЗРВ запускается срочная перевыдача ([reissueOnNearestWindow]).
*/
@Service
class HandlePlanDecisionUseCase(
@@ -156,6 +159,7 @@ class HandlePlanDecisionUseCase(
state.activePlanId = null
state.activeAttemptId = null
state.urgentReissueFrom = null
state.pausedAfterReject = false
state.chainVersion += 1
state.updatedAt = now
@@ -177,10 +181,19 @@ class HandlePlanDecisionUseCase(
plan.status = PlannedPlanStatus.REJECTED
plan.updatedAt = now
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный», и повторно выдавать тот же
// план бессмысленно. Снимаем активную выдачу, гасим срочную перевыдачу и ставим цепочку на
// паузу (pausedAfterReject) — проекция станет пустой, воркер ничего не выдаст, пока оператор
// вручную не создаст исправленный план (его promote/ручная выдача снимут паузу). Базу не трогаем.
state.activePlanId = null
state.activeAttemptId = null
state.urgentReissueFrom = null
state.pausedAfterReject = true
state.updatedAt = now
planIssueAttemptRepository.save(attempt)
plannedPlanRepository.save(plan)
reissueOnNearestWindow(state, plan, now)
spacecraftPlanningStateJpaRepository.save(state)
}
/**
@@ -106,6 +106,9 @@ class IssueDuePlanWorker(
return null
}
// Пауза после REJECTED централизована в PlanProjectionService.project(): для отклонённой
// цепочки проекция приходит пустой (выше уже сработал early-return), поэтому отдельного
// гарда «не выдавать отклонённое окно» здесь не нужно.
val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes)
val due = urgentFrom != null || !now.isBefore(issueNotBefore)
if (!due) {
@@ -173,6 +176,8 @@ class IssueDuePlanWorker(
state.activePlanId = plan.planId
state.activeAttemptId = attempt.attemptId
state.urgentReissueFrom = null
// Оператор вручную выдал план — снимаем паузу после отказа (если была).
state.pausedAfterReject = false
state.updatedAt = now
spacecraftPlanningStateJpaRepository.save(state)
@@ -202,6 +207,8 @@ class IssueDuePlanWorker(
state.activePlanId = plan.planId
state.activeAttemptId = attempt.attemptId
state.urgentReissueFrom = null
// Оператор отправил исправленный черновик — снимаем паузу после отказа (если была).
state.pausedAfterReject = false
state.updatedAt = now
spacecraftPlanningStateJpaRepository.save(state)
@@ -33,8 +33,15 @@ class PlanProjectionService(
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
val anchorPlan = resolveAnchorPlan(state)
// Пауза после REJECTED: цепочка стоит, пока оператор вручную не создаст исправленный план
// (ручная выдача/промоут снимут флаг). Будущего автоматически быть не может — отдаём пусто.
// Это единый источник паузы: воркер выдачи получает пустую проекцию и ничего не материализует.
if (state?.pausedAfterReject == true) {
return emptyList()
}
val anchorPlan = resolveAnchorPlan(state)
return if (anchorPlan == null) {
planCalculationService.calculatePlans(points)
} else {
@@ -53,6 +53,23 @@ class PlanQueryService(
return combine(persisted, projected)
}
/**
* Черновики (DRAFT) — отдельным чтением, т.к. основной read-path их намеренно отсекает.
* Для режима «Черновики» во вкладке «Планы»: список созданных, но ещё не отправленных
* планов-кандидатов (из диалога новой цепочки или из редактора). Опционально по одному КА.
*/
@Transactional(readOnly = true)
fun getDraftPlans(spacecraftId: String?): List<PlanResponse> {
val plans = if (spacecraftId != null) {
plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
} else {
plannedPlanRepository.findAllByOrderByStartTime()
}
return plans
.filter { plan -> plan.status == PlannedPlanStatus.DRAFT }
.map(PlanResponse::from)
}
/** Возвращает persisted plan по ID для test-only/debug API. */
@Transactional(readOnly = true)
fun getByPlanId(planId: UUID): PlannedPlanEntity =
@@ -18,8 +18,14 @@ import java.time.LocalDateTime
*/
object ZrvWindowMatcher {
/** Допуск сравнения времён — компенсирует погрешность сериализации ISO-строк на фронте. */
private const val TOLERANCE_SECONDS = 1L
/**
* Допуск сравнения времён — компенсирует усечение ISO-строк на фронте до целых секунд
* (`.slice(0,19)`): реальная ЗРВ имеет доли секунды, набранное окно — нет, расхождение строго
* меньше секунды. Сравнивается истинная абсолютная длительность (не усечённые секунды): у
* отрицательного `Duration` доли нормализуются в +nanos, поэтому `.toSeconds()` округлял бы 0.18с
* вниз до −1 и ломал совпадение.
*/
private val TOLERANCE = Duration.ofSeconds(1)
fun matches(
windows: List<CalculatedPlan>,
@@ -31,14 +37,14 @@ object ZrvWindowMatcher {
return false
}
val startMatches = windows.any { zrv ->
zrv.kppId == windowKppId && diffSeconds(zrv.startTime, windowStart) < TOLERANCE_SECONDS
zrv.kppId == windowKppId && within(zrv.startTime, windowStart)
}
val stopMatches = windows.any { zrv ->
diffSeconds(zrv.endTime, windowStop) < TOLERANCE_SECONDS
within(zrv.endTime, windowStop)
}
return startMatches && stopMatches
}
private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long =
Math.abs(Duration.between(a, b).toSeconds())
private fun within(a: LocalDateTime, b: LocalDateTime): Boolean =
Duration.between(a, b).abs() <= TOLERANCE
}
@@ -0,0 +1,6 @@
-- Цепочка КА стоит на паузе после REJECTED: пока флаг TRUE, проекция пуста и воркер ничего не
-- выдаёт — оператор должен вручную создать исправленный план («Создать план»). Флаг снимается при
-- ручной выдаче / промоуте черновика / приёмке. В отличие от urgent_reissue_from (тех. негативы
-- LAYIN_FAILED/NOT_ACCEPTED авто-перевыдаются), отказ решением требует осознанного вмешательства.
ALTER TABLE spacecraft_planning_state
ADD COLUMN IF NOT EXISTS paused_after_reject BOOLEAN NOT NULL DEFAULT FALSE;
@@ -137,6 +137,18 @@ class CreateChainPlanUseCaseTest {
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
}
@Test
fun `duplicate window for the same spacecraft yields 409 and no mission is created`() {
val existing = plan(time(5), time(6), PlannedPlanStatus.DRAFT)
val fixture = fixture(base = null, existingDuplicate = existing)
val ex = assertThrows<ResponseStatusException> {
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId))
}
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
}
@Test
fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() {
val newMissionId = UUID.randomUUID()
@@ -165,10 +177,16 @@ class CreateChainPlanUseCaseTest {
projectedTail: List<CalculatedPlan> = emptyList(),
failSave: Boolean = false,
spacecraftIdOverride: String = spacecraftId,
existingDuplicate: PlannedPlanEntity? = null,
window: Pair<LocalDateTime, LocalDateTime> = time(5) to time(6)
): Fixture {
val repo = mock(PlannedPlanRepository::class.java)
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
`when`(
repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull()
)
).thenReturn(listOfNotNull(existingDuplicate))
val saved = arrayOfNulls<PlannedPlanEntity>(1)
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
if (failSave) throw RuntimeException("DB down")
@@ -186,6 +186,22 @@ class IssueDuePlanWorkerOnDemandTest {
assertNotNull(state.activeAttemptId)
}
@Test
fun `issuePlanNow clears the paused-after-reject flag (operator intervened)`() {
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, pausedAfterReject = true)
val worker = worker(planStore, attemptStore, state)
worker.issuePlanNow(target.planId)
assertEquals(false, state.pausedAfterReject)
assertEquals(target.planId, state.activePlanId)
}
@Test
fun `issuePlanNow rejects a plan inside the freeze window`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
@@ -83,7 +83,7 @@ class PlanIssueAndDecisionFlowTest {
}
@Test
fun `rejected decision marks plan REJECTED and arms urgent reissue`() {
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
@@ -95,8 +95,11 @@ class PlanIssueAndDecisionFlowTest {
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
assertEquals(PlannedPlanStatus.REJECTED, plan.status)
// Активная выдача снята, перевыдача НЕ взведена, цепочка на паузе — ждёт ручного исправленного плана.
assertNull(state.activePlanId)
assertNull(state.activeAttemptId)
assertNotNull(state.urgentReissueFrom)
assertNull(state.urgentReissueFrom)
assertTrue(state.pausedAfterReject)
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
}
@@ -132,6 +132,21 @@ class PlanProjectionServiceTest {
assertEquals(base.kppId, anchor?.kppId)
}
@Test
fun `returns empty projection when the chain is paused after reject`() {
val calculation = mock(PlanCalculationService::class.java)
val service = service(
state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true),
calculation = calculation
)
val result = service.project(spacecraftId)
assertTrue(result.isEmpty())
verify(calculation, never()).calculatePlans(anyNonNull())
verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull())
}
private fun service(
snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(),
state: SpacecraftPlanningStateEntity? = null,
@@ -79,6 +79,38 @@ class PlanQueryServiceTest {
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status)
}
@Test
fun `getDraftPlans returns only DRAFT plans`() {
val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT)
val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN)
val plannedRepository = mock(PlannedPlanRepository::class.java)
`when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active))
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
val projection = mock(PlanProjectionService::class.java)
val service = PlanQueryService(plannedRepository, snapshotRepository, projection)
val result = service.getDraftPlans(spacecraftId = null)
assertEquals(1, result.size)
assertEquals(PlannedPlanStatus.DRAFT, result.single().status)
assertEquals(draft.planId, result.single().planId)
}
@Test
fun `getDraftPlans filters by spacecraftId when provided`() {
val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT)
val plannedRepository = mock(PlannedPlanRepository::class.java)
`when`(plannedRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)).thenReturn(listOf(draft))
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
val projection = mock(PlanProjectionService::class.java)
val service = PlanQueryService(plannedRepository, snapshotRepository, projection)
val result = service.getDraftPlans(spacecraftId = spacecraftId)
assertEquals(1, result.size)
assertEquals(draft.planId, result.single().planId)
}
private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
PlannedPlanEntity(
planId = UUID.randomUUID(),
@@ -51,4 +51,17 @@ class ZrvWindowMatcherTest {
fun `empty windows never match`() {
assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1"))
}
@Test
fun `sub-second ZRV matches a window truncated to whole seconds`() {
// Фронт усекает ISO до целых секунд (.slice(0,19)); реальная ЗРВ имеет доли секунды.
// Расхождение строго меньше секунды должно совпадать (регрессия на Duration.toSeconds()).
val fractional = CalculatedPlan(
sc,
at(0).withNano(180_078_000),
at(10).withNano(19_687_000),
"KPP-1"
)
assertTrue(ZrvWindowMatcher.matches(listOf(fractional), at(0), at(10), "KPP-1"))
}
}