pcp-tgu: якорь цепочки на LAID_IN, параллельные выдачи, SUPERSEDED

Базой цепочки план становится на ЗАКЛАДКЕ (LAID_IN), а не на приёмке.
После принятого плана можно отправить коррекцию — она тоже примется;
несколько выдач «в полёте» по одному КА теперь норма, а не конфликт.

- ACCEPT больше не двигает currentBasePlanId/chainVersion и не запирает
  слот — только переводит план в AWAITING_LAYIN.
- Новый позитивный топик layin-confirmed + LayinConfirmedConsumer →
  handleLayinConfirmed: AWAITING_LAYIN → LAID_IN, план становится базой
  (chainVersion++), конкуренты на пересекающемся окне гасятся в новый
  терминальный статус SUPERSEDED. Гард ±10 мин (LAYIN_MIN_SEPARATION_MINUTES):
  нельзя заложить план со стартом в пределах 10 мин от уже заложенного (409).
- «В полёте» считается из статусов планов (ISSUING/WAITING_DECISION), а не
  из единственного active-слота; снят гард «идёт выдача другого плана».
  IssueDuePlanWorker гейтится hasBlockingInFlight, базу нового плана берёт
  как latestCommittedPlanId; grace больше не двигает базу.
- PlanProjectionService якорит по самому дальнему committed-окну
  {ISSUING, WAITING_DECISION, AWAITING_LAYIN, LAID_IN}.
- REJECT ставит паузу только если по КА не осталось живых кандидатов.
- CreateDraftUseCase: replace-черновик наследует ПРЕДШЕСТВЕННИКА исходного
  плана (base.basePlanId), а не сам план — коррекция занимает тот же слот;
  fork-forward по-прежнему ссылается на базу. Идемпотентность черновиков —
  по слоту (КА + окно), а не по basePlanId.
- Тест-контроллер: POST /test/tgu/plans/{id}/confirm-layin (через BFF
  pcp-ui-service) — имитация события «план заложен».
- Док PCP_TGU_PLAN_STATUS_LIFECYCLE обновлён (LAID_IN-переход, SUPERSEDED).
This commit is contained in:
Дмитрий Соловьев
2026-06-04 16:13:02 +03:00
parent 5235142fa6
commit 87905311b1
21 changed files with 558 additions and 240 deletions
+1
View File
@@ -57,6 +57,7 @@ topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1
layin-confirmed: pcp.tgu.layin-confirmed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1
+34 -24
View File
@@ -27,9 +27,10 @@
┌────────────────────────────────┼────────────────────────────────┐
событие «заложен» событие «не заложен» событие «не принят»
(пока не реализовано) ▼ ▼
LAYIN_FAILED NOT_ACCEPTED
LAID_IN (срочная перевыдача) (срочная перевыдача)
▼ ▼
LAID_IN LAYIN_FAILED NOT_ACCEPTED
(база цепочки; конкуренты (срочная перевыдача) (срочная перевыдача)
того же окна → SUPERSEDED)
при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS
```
@@ -75,21 +76,25 @@
### AWAITING_LAYIN — принят, ждёт подтверждения закладки
- **Когда.** Пришло решение ACCEPTED (`acceptPlan`).
- **Что значит.** План принят и **оптимистично считается заложенным**; это текущий **базовый план
цепочки** (`state.currentBasePlanId = plan.planId`, `chainVersion += 1`). Запускается джоб
обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- **Дальше.** При подтверждении закладки → `LAID_IN` (терминальный успех; **событие пока не
реализовано**, см. ниже). При негативе — `LAYIN_FAILED`/`NOT_ACCEPTED`.
- В легенде это де-факто «принятый/заложенный» статус (зелёный). Перевыдача в свой слот ещё
- **Что значит.** План принят и ждёт закладки. **ACCEPT больше НЕ делает план базой и не двигает
`chainVersion`** — это разъединено намеренно: после приёмки можно отправить и принять коррекцию
того же окна, поэтому по одному окну может одновременно «лететь» несколько AWAITING_LAYIN/
WAITING_DECISION. Базой и версией цепочки управляет только закладка (`LAID_IN`). На ACCEPT
запускается джоб обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- **Дальше.** При подтверждении закладки → `LAID_IN` (становится базой). При негативе —
`LAYIN_FAILED`/`NOT_ACCEPTED`. Если базой стала **другая** коррекция того же окна — этот план
гасится в `SUPERSEDED`.
- В легенде это «принятый, ждёт закладки» статус (зелёный). Перевыдача/коррекция в свой слот ещё
возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только
fork-forward).
### REJECTED — отклонён
- **Когда.** Пришло решение REJECTED (`rejectPlan`).
- **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план
бессмысленно): активная выдача снимается, срочная перевыдача гасится, цепочка ставится **на паузу**
(`pausedAfterReject = true`) — проекция становится пустой, воркер ничего не выдаёт. Базовый план не
трогается.
бессмысленно). Цепочка ставится **на паузу** (`pausedAfterReject = true`) — **только если по КА не
осталось других живых кандидатов** (в полёте/приняты, но не заложены): при нескольких выдачах
отказ одной не глушит цепочку, пока жив другой кандидат. На паузе проекция пуста, воркер ничего не
выдаёт. Базовый план не трогается.
- **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу.
- В UI у КА с паузой рисуется пометка «⏸ на паузе».
@@ -112,18 +117,23 @@
- **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором.
Опасный статус, в UI выделен пунктиром.
### LAID_IN — заложен (терминальный успех)
- **Когда (целевое).** При подтверждении закладки базового плана из `AWAITING_LAYIN`.
- **Что значит.** Подтверждённая закладка, терминальный успех; «точка невозврата» — дальше план
меняется только через fork-forward (используется в гардах `CreateDraftUseCase`,
`assertReissuableInSlot`).
- **⚠️ TODO — не реализовано.** Потребителя события «план заложен» сейчас **нет**, поэтому план в
`LAID_IN` не переходит и остаётся в `AWAITING_LAYIN`. Нужно: Kafka-consumer (по аналогии с
`LayinFailedConsumer`/`PlanNotAcceptedConsumer`) + `handleLayinConfirmed` в
`HandlePlanDecisionUseCase` (`AWAITING_LAYIN → LAID_IN` по текущему базовому плану). Маркер-TODO
стоит в коде `HandlePlanDecisionUseCase` рядом с обработчиками негативного фидбека.
- В легенде статус показан (зелёный, темнее `AWAITING_LAYIN`) — заранее, чтобы при появлении события
цвет уже был.
### LAID_IN — заложен (терминальный успех, база цепочки)
- **Когда.** Пришло внешнее событие «план заложен» по плану в `AWAITING_LAYIN`
(`LayinConfirmedConsumer``handleLayinConfirmed`, топик `layin-confirmed`).
- **Что значит.** Подтверждённая закладка — **именно она делает план базой цепочки**
(`state.currentBasePlanId = plan.planId`, `chainVersion += 1`, снимается пауза/срочная
перевыдача). «Точка невозврата»: дальше план меняется только через fork-forward (гарды
`CreateDraftUseCase`, `assertReissuableInSlot`).
- **Supersede конкурентов.** Все ещё не заложенные планы КА (ISSUING/WAITING_DECISION/AWAITING_LAYIN)
с окном, пересекающим окно заложенного, переводятся в `SUPERSEDED` (`supersedeCompetingPlans`).
- Событие по плану не в `AWAITING_LAYIN` (устаревшее/дубль/уже вытеснён) — игнорируется.
- В легенде зелёный, темнее `AWAITING_LAYIN`.
### SUPERSEDED — вытеснён заложенной коррекцией (терминал)
- **Когда.** По тому же окну КА другой план ушёл в `LAID_IN`, а этот был конкурирующей выдачей/
коррекцией (`supersedeCompetingPlans`).
- **Что значит.** Терминал, цепочку не двигает. Запоздавшее решение по его attempt записывается, но
игнорируется (план уже не in-flight). Базовый план не трогается.
## Зарезервированные / не возникающие сейчас статусы
+14 -7
View File
@@ -1,11 +1,18 @@
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
2. Ошибка при попытке принять план во вкладке планы
6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
TODO:
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
- Проставлять статусы слотов только после того, как план заложен на КА
- Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
- Ошибка при попытке принять план во вкладке планы
- Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
- Отображение предыдущих планов и следующих. Предлагаю не просто сделать отображение как рамку, я делать еле заметные все другие планы, а эти обводить яркими рамками, чтобы было видно какой план прошлый, а какой будущий.
Прошлый: #94A3B8 slate-400
Текущий: #2563EB blue-600
Следующий: #F59E0B amber-500
Предлагаю использовать эти цвета
- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик
- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план.
- Деактивировать отмененный план, чтобы он не портил статистику
- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы
@@ -14,6 +14,9 @@ data class TopicsProperties(
/** Topic-событие «не заложен» по принятому плану. */
val layinFailed: String,
/** Topic-событие «план заложен» (позитивный фидбек закладки) — переводит план в LAID_IN. */
val layinConfirmed: String,
/** Topic-событие «не принят» по принятому плану. */
val planNotAccepted: String,
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam
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.PlanExecutionFeedbackMessage
import space.nstart.pcp_tgu_service.dto.PlanResponse
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
@@ -68,6 +69,14 @@ class TestTguController(
): PlanDecisionMessage =
testPlanDecisionService.generateDecision(planId, decision, reason)
/** Генерирует событие «план заложен» (AWAITING_LAYIN → LAID_IN) для указанного persisted plan. */
@PostMapping("/plans/{planId}/confirm-layin")
fun confirmLayin(
@PathVariable planId: UUID,
@RequestParam(required = false) reason: String?
): PlanExecutionFeedbackMessage =
testPlanDecisionService.generateLayinConfirmed(planId, reason)
/** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */
@PostMapping("/platforms/load-from-file")
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse {
@@ -29,6 +29,13 @@ enum class PlannedPlanStatus {
/** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
NOT_ACCEPTED,
/**
* Вытеснён: по тому же окну КА другой план ушёл в LAID_IN (заложен), а этот — конкурирующая
* выдача/коррекция (ISSUING/WAITING_DECISION/AWAITING_LAYIN) на пересекающееся окно. Терминал,
* цепочку не двигает. Решение по запоздавшей attempt такого плана записывается, но игнорируется.
*/
SUPERSEDED,
REJECTED,
START_AMBIGUOUS
}
@@ -0,0 +1,25 @@
package space.nstart.pcp_tgu_service.integration.kafka
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp_tgu_service.config.TopicsProperties
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
/**
* Kafka consumer события «план заложен» (позитивный фидбек закладки); делегирует перевод
* принятого плана в LAID_IN (новая база цепочки) в use case.
*/
@Component
class LayinConfirmedConsumer(
val topicsProperties: TopicsProperties,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
) {
@KafkaListener(
topics = ["#{__listener.topicsProperties.layinConfirmed}"],
groupId = "\${spring.kafka.consumer.group-id}"
)
fun consume(message: PlanExecutionFeedbackMessage) {
handlePlanDecisionUseCase.handleLayinConfirmed(message)
}
}
@@ -16,6 +16,15 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
/**
* Планы КА в одном из статусов — для вычисления «в полёте» (ISSUING/WAITING_DECISION) и набора
* конкурентов при закладке (supersede). Заменяет опору на единственный active-слот состояния.
*/
fun findAllBySpacecraftIdAndStatusIn(
spacecraftId: String,
statuses: Collection<PlannedPlanStatus>
): List<PlannedPlanEntity>
/**
* Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков:
* сочетание (КА + начало + конец) уникально среди DRAFT.
@@ -47,18 +47,6 @@ class CreateDraftUseCase(
val now = LocalDateTime.now()
// Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик»
// не плодит вторую миссию-клон и вторую DRAFT-строку.
val existingDraft = plannedPlanRepository
.findAllByBasePlanIdAndStatus(base.planId, PlannedPlanStatus.DRAFT)
.firstOrNull()
if (existingDraft != null) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"У плана ${base.planId} уже есть черновик ${existingDraft.planId} — завершите или отмените его"
)
}
return when (req.mode) {
DraftMode.REPLACE -> createReplaceDraft(base, now)
DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, now)
@@ -85,14 +73,23 @@ class CreateDraftUseCase(
)
}
// Идемпотентность: один открытый черновик на слот (КА + окно). Повторный клик не плодит
// вторую миссию-клон и вторую DRAFT-строку. Окно replace-черновика 1:1 совпадает с базой.
assertNoOpenDraftForWindow(base.spacecraftId, base.startTime, base.endTime)
val cloneResponse = missionPlaningClient.cloneMission(
missionId = base.planId,
req = MissionCloneRequest(mode = DraftMode.REPLACE)
)
// Replace-черновик — это КОРРЕКЦИЯ исходного плана: он занимает тот же слот цепочки, что и
// база, поэтому его предшественник (basePlanId) = предшественник базы (base.basePlanId), а
// НЕ сама база. Так после закладки коррекция вытеснит исходный план на том же окне, а на
// таймлайне стрелка коррекции ведёт к тому же предыдущему плану, что и у исходного.
return saveDraftWithCompensation(
draftPlanId = cloneResponse.newMissionId,
base = base,
draftBasePlanId = base.basePlanId,
startTime = base.startTime,
endTime = base.endTime,
kppId = base.kppId,
@@ -128,14 +125,20 @@ class CreateDraftUseCase(
// Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции.
assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId)
// Идемпотентность: один открытый черновик на слот (КА + окно будущей ЗРВ).
assertNoOpenDraftForWindow(base.spacecraftId, windowStart, windowStop)
val cloneResponse = missionPlaningClient.cloneMission(
missionId = base.planId,
req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop)
)
// Fork-forward — это ПРОДОЛЖЕНИЕ цепочки на будущую ЗРВ ПОСЛЕ базы (база заложена/в прошлом),
// поэтому предшественник нового плана = сама база (base.planId).
return saveDraftWithCompensation(
draftPlanId = cloneResponse.newMissionId,
base = base,
draftBasePlanId = base.planId,
startTime = windowStart,
endTime = windowStop,
kppId = windowKppId,
@@ -143,6 +146,22 @@ class CreateDraftUseCase(
)
}
/**
* Гард дублей: на (КА + окно) допускается не более одного открытого черновика. Заменяет прежний
* гард «один черновик на базовый план» — теперь ключ слота это окно, а не исходный план.
*/
private fun assertNoOpenDraftForWindow(spacecraftId: String, start: LocalDateTime, end: LocalDateTime) {
val existing = plannedPlanRepository
.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(spacecraftId, start, end, PlannedPlanStatus.DRAFT)
.firstOrNull()
if (existing != null) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"По КА $spacecraftId уже есть черновик ${existing.planId} на это окно — завершите или отмените его"
)
}
}
/**
* Проверяет, что тройка (windowStart, windowStop, kppId) соответствует хотя бы одному
* плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность
@@ -178,6 +197,7 @@ class CreateDraftUseCase(
private fun saveDraftWithCompensation(
draftPlanId: UUID,
base: PlannedPlanEntity,
draftBasePlanId: UUID?,
startTime: LocalDateTime,
endTime: LocalDateTime,
kppId: String,
@@ -199,13 +219,13 @@ class CreateDraftUseCase(
endTime = endTime,
kppId = kppId,
status = PlannedPlanStatus.DRAFT,
basePlanId = base.planId,
basePlanId = draftBasePlanId,
chainVersion = baseChainVersion
)
)
log.info(
"Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
draft.planId, base.planId, base.spacecraftId, baseChainVersion
"Создан черновик плана: draftPlanId={}, srcPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
draft.planId, base.planId, draftBasePlanId, base.spacecraftId, baseChainVersion
)
CreateDraftResponse(draftPlanId = draft.planId)
} catch (ex: Exception) {
@@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.Duration
import java.time.LocalDateTime
/**
@@ -70,24 +71,107 @@ class HandlePlanDecisionUseCase(
if (attempt.status.isTerminal()) {
return
}
if (state.activeAttemptId != attempt.attemptId) {
// Несколько выдач по КА могут лететь одновременно (исходный план + коррекция того же окна),
// поэтому решение применяем к своему плану по его статусу, а не сверяя с единственным
// active-слотом. Если план уже терминальный (вытеснен заложенной коррекцией / отклонён) —
// запоздавшее решение просто пишем на attempt, цепочку им не двигаем.
if (plan.status !in IN_FLIGHT_STATUSES) {
recordDecisionOnAttempt(message, attempt)
planIssueAttemptRepository.save(attempt)
return
}
when (message.decision) {
PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan, state)
PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan)
PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state)
}
}
// TODO(layin-confirmed): реализовать приём события «план заложен» (положительный фидбек по закладке).
// Сейчас принятый план остаётся в AWAITING_LAYIN (оптимистично «заложен») и в LAID_IN никогда не
// переходит — нет потребителя этого события. Нужно: Kafka-consumer (по аналогии с LayinFailedConsumer)
// + handleLayinConfirmed, который по текущему базовому плану в AWAITING_LAYIN переводит его в
// PlannedPlanStatus.LAID_IN (терминальный успех, «точка невозврата»: дальше только fork-forward).
// См. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md.
/**
* Событие «план заложен» (позитивный фидбек закладки): принятый план переходит в LAID_IN и
* становится базой цепочки. Именно закладка — а не ACCEPT — двигает якорь и chainVersion,
* поэтому до неё по одному окну могут одновременно «лететь» исходный план и его коррекция.
* Все ещё не заложенные конкуренты на пересекающееся окно гасятся в SUPERSEDED.
*/
@Transactional
fun handleLayinConfirmed(message: PlanExecutionFeedbackMessage) {
if (!claimEvent(message.eventId)) {
return
}
val plan = plannedPlanRepository.lockByPlanId(message.planId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found")
if (plan.spacecraftId != message.spacecraftId) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Layin spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}"
)
}
val state = getOrCreateLockedState(message.spacecraftId)
if (plan.status != PlannedPlanStatus.AWAITING_LAYIN) {
// Закладка по плану не в статусе «ждёт закладки» (устаревшее/дубль/уже вытеснен) — игнор.
log.info("Layin-confirmed ignored: plan {} is not AWAITING_LAYIN (status={}).", plan.planId, plan.status)
return
}
// Нельзя заложить план, если по КА уже есть заложенный (LAID_IN) со стартом в пределах
// ±[LAYIN_MIN_SEPARATION_MINUTES] минут — две закладки на практически одно и то же время
// недопустимы (один заложенный слот на окно).
val nearbyLaidIn = plannedPlanRepository
.findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, setOf(PlannedPlanStatus.LAID_IN))
.firstOrNull { laid ->
laid.planId != plan.planId &&
Duration.between(laid.startTime, plan.startTime).abs().toMinutes() < LAYIN_MIN_SEPARATION_MINUTES
}
if (nearbyLaidIn != null) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"По КА ${plan.spacecraftId} уже заложен план ${nearbyLaidIn.planId} со стартом в пределах " +
"$LAYIN_MIN_SEPARATION_MINUTES мин — закладка на то же время недопустима"
)
}
val now = LocalDateTime.now()
plan.status = PlannedPlanStatus.LAID_IN
plan.updatedAt = now
// База/версия двигаются только здесь. Снимаем паузу и срочную перевыдачу — окно закрыто.
state.currentBasePlanId = plan.planId
state.chainVersion += 1
state.urgentReissueFrom = null
state.pausedAfterReject = false
state.activePlanId = null
state.activeAttemptId = null
state.updatedAt = now
supersedeCompetingPlans(plan, now)
plannedPlanRepository.save(plan)
spacecraftPlanningStateJpaRepository.save(state)
}
/** Гасит в SUPERSEDED ещё не заложенные планы КА, чьё окно пересекается с заложенным. */
private fun supersedeCompetingPlans(laidIn: PlannedPlanEntity, now: LocalDateTime) {
val competitors = plannedPlanRepository
.findAllBySpacecraftIdAndStatusIn(laidIn.spacecraftId, LIVE_UNLAID_STATUSES)
.filter { it.planId != laidIn.planId && overlaps(it, laidIn) }
if (competitors.isEmpty()) {
return
}
for (competitor in competitors) {
competitor.status = PlannedPlanStatus.SUPERSEDED
competitor.updatedAt = now
}
plannedPlanRepository.saveAll(competitors)
log.info(
"Plan {} laid in; superseded {} competing plan(s) on overlapping window.",
laidIn.planId, competitors.size
)
}
private fun overlaps(a: PlannedPlanEntity, b: PlannedPlanEntity): Boolean =
a.startTime.isBefore(b.endTime) && b.startTime.isBefore(a.endTime)
/** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */
@Transactional
@@ -151,8 +235,7 @@ class HandlePlanDecisionUseCase(
private fun acceptPlan(
message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity,
plan: PlannedPlanEntity,
state: SpacecraftPlanningStateEntity
plan: PlannedPlanEntity
) {
val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt)
@@ -161,17 +244,11 @@ class HandlePlanDecisionUseCase(
plan.acceptedAt = message.decisionTime
plan.updatedAt = now
state.currentBasePlanId = plan.planId
state.activePlanId = null
state.activeAttemptId = null
state.urgentReissueFrom = null
state.pausedAfterReject = false
state.chainVersion += 1
state.updatedAt = now
// ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт
// закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN
// ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна.
planIssueAttemptRepository.save(attempt)
plannedPlanRepository.save(plan)
spacecraftPlanningStateJpaRepository.save(state)
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
}
@@ -187,14 +264,17 @@ class HandlePlanDecisionUseCase(
plan.status = PlannedPlanStatus.REJECTED
plan.updatedAt = now
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный», и повторно выдавать тот же
// план бессмысленно. Снимаем активную выдачу, гасим срочную перевыдачу и ставим цепочку на
// паузу (pausedAfterReject) — проекция станет пустой, воркер ничего не выдаст, пока оператор
// вручную не создаст исправленный план (его promote/ручная выдача снимут паузу). Базу не трогаем.
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный». Но при нескольких выдачах
// по КА отказ одной не должен глушить цепочку, если живёт другой кандидат (в полёте/принят,
// но не заложен) — он ещё может закрыть окно. Паузу (pausedAfterReject) ставим, только если
// других живых кандидатов не осталось: тогда проекция пуста, и оператор создаёт план вручную.
val hasOtherLiveCandidate = plannedPlanRepository
.findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, LIVE_UNLAID_STATUSES)
.any { it.planId != plan.planId }
state.activePlanId = null
state.activeAttemptId = null
state.urgentReissueFrom = null
state.pausedAfterReject = true
state.pausedAfterReject = !hasOtherLiveCandidate
state.updatedAt = now
planIssueAttemptRepository.save(attempt)
@@ -234,4 +314,19 @@ class HandlePlanDecisionUseCase(
private fun PlanIssueAttemptStatus.isTerminal(): Boolean =
this != PlanIssueAttemptStatus.STARTING && this != PlanIssueAttemptStatus.STARTED
private companion object {
/** Минимальный разнос по старту между двумя заложенными планами КА (минуты). */
const val LAYIN_MIN_SEPARATION_MINUTES = 10L
/** Выданный, но ещё не решённый план — его решение двигает цепочку. */
val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION)
/** Живые, но ещё не заложенные планы КА: кандидаты на supersede и на «есть ли ещё кандидат». */
val LIVE_UNLAID_STATUSES = setOf(
PlannedPlanStatus.ISSUING,
PlannedPlanStatus.WAITING_DECISION,
PlannedPlanStatus.AWAITING_LAYIN
)
}
}
@@ -85,7 +85,10 @@ class IssueDuePlanWorker(
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now()
val state = getOrCreateLockedState(spacecraftId)
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
// Форвард-цепочку не выдаём, пока по КА есть выдача «в работе»: ISSUING или WAITING_DECISION
// в пределах grace после старта. Прошедший grace больше не блокирует — молчаливо продолжаем
// (базу/версию это НЕ двигает, их двигает только LAID_IN).
if (hasBlockingInFlight(spacecraftId, now)) {
return null
}
@@ -114,7 +117,8 @@ class IssueDuePlanWorker(
return null
}
val plan = materializeIssuingPlan(head, state, issueNotBefore, now)
val basePlanId = latestCommittedPlanId(spacecraftId, state.currentBasePlanId)
val plan = materializeIssuingPlan(head, basePlanId, state.chainVersion, issueNotBefore, now)
val attempt = createStartingAttempt(plan.planId, now)
state.activePlanId = plan.planId
@@ -127,45 +131,33 @@ class IssueDuePlanWorker(
}
/**
* Если активная выдача это план в WAITING_DECISION, по которому уже прошло
* [PlanningProperties.waitingDecisionGraceMinutes] минут после старта и решения так и нет,
* перестаём ждать подтверждение: снимаем блокировку (activePlanId/activeAttemptId),
* двигаем якорь цепочки на этот план и идём дальше. Статус плана НЕ меняем он остаётся
* WAITING_DECISION (молчаливое продолжение, а не ACCEPTED). Attempt оставляем как есть:
* запоздалое решение по нему просто запишется на attempt, не трогая ушедшую вперёд цепочку.
*
* @return true, если блокировка снята и можно выдавать следующий план в этом же тике.
* По КА есть выдача «в работе», блокирующая авто-выдачу следующего окна форвард-цепочки:
* ISSUING (только что выдан, решения ещё нет) или WAITING_DECISION в пределах
* [PlanningProperties.waitingDecisionGraceMinutes] после старта. WAITING_DECISION за grace
* больше не блокирует молчаливо продолжаем цепочку (статус плана не меняем; базу/версию
* двигает только LAID_IN). Несколько выдач по КА (исходный план + коррекция) допустимы
* блокирует наличие хотя бы одной «в работе».
*/
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
private fun hasBlockingInFlight(spacecraftId: String, now: LocalDateTime): Boolean =
plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, BLOCKING_INFLIGHT_STATUSES).any { plan ->
plan.status == PlannedPlanStatus.ISSUING ||
now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))
}
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
}
/**
* planId самого дальнего (по endTime) зафиксированного окна КА провенанс (basePlanId) для
* следующего выданного плана форвард-цепочки. Включает ещё не заложенные выдачи, чтобы цепочка
* связывалась с непосредственно предыдущим окном, а не с последней заложенной базой.
*/
private fun latestCommittedPlanId(spacecraftId: String, fallback: UUID?): UUID? =
plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
.maxByOrNull { plan -> plan.endTime }?.planId ?: fallback
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now()
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId)
assertReissuableInSlot(plan, state, now)
assertReissuableInSlot(plan, now)
plan.status = PlannedPlanStatus.ISSUING
plan.updatedAt = now
@@ -196,7 +188,7 @@ class IssueDuePlanWorker(
val state = getOrCreateLockedState(plan.spacecraftId)
assertDraftBaseUnchanged(plan, state)
assertDraftSlotStillValid(plan)
assertReissuableInSlot(plan, state, now)
assertReissuableInSlot(plan, now)
plan.status = PlannedPlanStatus.ISSUING
plan.updatedAt = now
@@ -255,25 +247,17 @@ class IssueDuePlanWorker(
/**
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
* - по КА нет активной выдачи ДРУГОГО плана (иначе 409 CONFLICT две выдачи разом недопустимы);
* - план не подтверждён как заложенный (`status != LAID_IN` иначе 422, доступен только fork-forward);
* - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 окно заморожено).
*
* Важно: повторную ручную выдачу ТОГО ЖЕ плана разрешаем, даже если по нему уже летит attempt
* (например, план только что ушёл в авто-выдачу за `notificationBeforeStartMinutes`). Оператор сохраняет
* право перевыдать именно этот план вплоть до порога заморозки новая attempt замещает текущую in-flight
* (запоздалое решение по старой attempt запишется как stale и цепочку не тронет).
* Гарда «по КА уже идёт выдача другого плана» больше НЕТ: несколько выдач по КА одновременно
* норма (исходный план + коррекция того же окна). Конкуренция разрешается на закладке: ушедший
* в LAID_IN план гасит остальные в SUPERSEDED ([HandlePlanDecisionUseCase.handleLayinConfirmed]).
*
* Узкого списка «перевыдаваемых» статусов больше нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN,
* пока план в будущем за порогом и не заложен.
* Узкого списка «перевыдаваемых» статусов нет: правим в т.ч. 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}) — дождитесь её завершения"
)
}
private fun assertReissuableInSlot(plan: PlannedPlanEntity, now: LocalDateTime) {
if (plan.status == PlannedPlanStatus.LAID_IN) {
throw ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
@@ -292,7 +276,8 @@ class IssueDuePlanWorker(
private fun materializeIssuingPlan(
head: CalculatedPlan,
state: SpacecraftPlanningStateEntity,
basePlanId: UUID?,
chainVersion: Long,
issueNotBefore: LocalDateTime,
now: LocalDateTime
): PlannedPlanEntity =
@@ -303,8 +288,8 @@ class IssueDuePlanWorker(
endTime = head.endTime,
kppId = head.kppId,
issueNotBefore = issueNotBefore,
basePlanId = state.currentBasePlanId,
chainVersion = state.chainVersion,
basePlanId = basePlanId,
chainVersion = chainVersion,
now = now
)
@@ -388,4 +373,20 @@ class IssueDuePlanWorker(
val planId: UUID,
val attemptId: UUID
)
private companion object {
/** Выдачи «в работе», по которым решение ещё ожидается (для гейта авто-выдачи). */
val BLOCKING_INFLIGHT_STATUSES = setOf(
PlannedPlanStatus.ISSUING,
PlannedPlanStatus.WAITING_DECISION
)
/** Зафиксированные окна КА (выданы/приняты/заложены) — источник провенанса форвард-цепочки. */
val COMMITTED_STATUSES = setOf(
PlannedPlanStatus.ISSUING,
PlannedPlanStatus.WAITING_DECISION,
PlannedPlanStatus.AWAITING_LAYIN,
PlannedPlanStatus.LAID_IN
)
}
}
@@ -41,7 +41,7 @@ class PlanProjectionService(
return emptyList()
}
val anchorPlan = resolveAnchorPlan(state)
val anchorPlan = resolveAnchorPlan(spacecraftId, state)
return if (anchorPlan == null) {
planCalculationService.calculatePlans(points)
} else {
@@ -77,23 +77,21 @@ class PlanProjectionService(
}
/**
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка.
* Якорь, после которого строится будущая цепочка: самое дальнее (по endTime) зафиксированное
* окно КА среди ISSUING/WAITING_DECISION/AWAITING_LAYIN/LAID_IN.
*
* Если есть выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION) цепочку
* продолжаем ПОСЛЕ него, а не после принятого базового плана. Иначе проекция заново вывела бы
* уже выданный слот и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) нарисовала бы
* его призрак-дубль PLANNED рядом с реальным WAITING_DECISION: точечный дедуп по
* businessKey в [PlanQueryService] такой рассинхрон не ловит.
*
* `activePlanId` заполнен только вместе с активной выдачей; в пути выдачи он к моменту
* вызова проекции уже снят, поэтому якорение на план в полёте не влияет на выбор головы.
* Берём именно крайнее зафиксированное окно (а не «принятую базу»), потому что выдач по КА может
* быть несколько одновременно (исходный план + коррекция), и базой цепочка становится только на
* закладке (LAID_IN). Якорение на крайнее окно не даёт проекции заново вывести уже выданный слот
* и нарисовать его призрак-дубль PLANNED при дрейфе снапшота ЗРВ. Если зафиксированных окон нет
* откатываемся на `currentBasePlanId` (последняя заложенная база), иначе строим с нуля.
*/
private fun resolveAnchorPlan(state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? {
val inFlight = state?.activePlanId
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
?.takeIf { plan -> plan.status in IN_FLIGHT_STATUSES }
if (inFlight != null) {
return inFlight
private fun resolveAnchorPlan(spacecraftId: String, state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? {
val committed = plannedPlanRepository
.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
.maxByOrNull { plan -> plan.endTime }
if (committed != null) {
return committed
}
return state?.currentBasePlanId
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
@@ -108,7 +106,12 @@ class PlanProjectionService(
)
private companion object {
/** Статусы выданного, но ещё не принятого плана — он уже фиксирует слот цепочки. */
val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION)
/** Зафиксированные окна КА (выданы/приняты/заложены) — кандидаты в якорь будущей цепочки. */
val COMMITTED_STATUSES = setOf(
PlannedPlanStatus.ISSUING,
PlannedPlanStatus.WAITING_DECISION,
PlannedPlanStatus.AWAITING_LAYIN,
PlannedPlanStatus.LAID_IN
)
}
}
@@ -6,53 +6,37 @@ import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.util.UUID
/**
* Test-only helper: генерирует decision event для текущей active attempt persisted plan.
* Test-only helper: генерирует decision event для последней (живой) attempt persisted plan.
*/
@ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true")
@Service
class TestPlanDecisionService(
private val plannedPlanRepository: PlannedPlanRepository,
private val planIssueAttemptRepository: PlanIssueAttemptRepository,
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
) {
/**
* Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer.
* Решение адресуется последней attempt плана напрямую выдач по КА может быть несколько разом,
* единого active-слота больше нет.
*/
fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage {
val plan = plannedPlanRepository.findById(planId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
val state = spacecraftPlanningStateJpaRepository.findById(plan.spacecraftId).orElse(null)
?: throw ResponseStatusException(
HttpStatus.CONFLICT,
"Planning state for spacecraftId=${plan.spacecraftId} was not found"
)
if (state.activePlanId != planId || state.activeAttemptId == null) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Plan $planId is not the active plan waiting for decision"
)
}
val attempt = planIssueAttemptRepository.findById(state.activeAttemptId!!).orElse(null)
val attempt = planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)
?: throw ResponseStatusException(
HttpStatus.CONFLICT,
"Active attempt ${state.activeAttemptId} for plan $planId was not found"
"Plan $planId has no issue attempt to decide on"
)
if (attempt.planId != planId) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"Active attempt ${attempt.attemptId} does not belong to plan $planId"
)
}
if (attempt.status !in ACTIVE_ATTEMPT_STATUSES) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
@@ -73,6 +57,26 @@ class TestPlanDecisionService(
return message
}
/**
* Создаёт synthetic событие «план заложен» и проводит его через тот же use case, что Kafka
* consumer ([HandlePlanDecisionUseCase.handleLayinConfirmed]) для ручной проверки перехода
* AWAITING_LAYIN LAID_IN из UI.
*/
fun generateLayinConfirmed(planId: UUID, reason: String?): PlanExecutionFeedbackMessage {
val plan = plannedPlanRepository.findById(planId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
val message = PlanExecutionFeedbackMessage(
eventId = UUID.randomUUID(),
spacecraftId = plan.spacecraftId,
planId = plan.planId,
occurredAt = LocalDateTime.now(),
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
)
handlePlanDecisionUseCase.handleLayinConfirmed(message)
return message
}
private companion object {
val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED)
const val DEFAULT_REASON = "TEST_CONTROLLER"
@@ -31,6 +31,7 @@ topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1
layin-confirmed: pcp.tgu.layin-confirmed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1
@@ -34,8 +34,11 @@ class CreateDraftUseCaseTest {
// --- replace ---
@Test
fun `replace creates DRAFT with cloned missionId as planId, base window and basePlanId`() {
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
fun `replace creates DRAFT inheriting the source's base (predecessor), not the source itself`() {
// База исходного плана — это его предшественник в цепочке. Replace-черновик — коррекция,
// занимающая тот же слот, поэтому он наследует ИМЕННО предшественника, а не сам исходный план.
val predecessor = UUID.randomUUID()
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = predecessor)
val newMissionId = UUID.randomUUID()
val fixture = fixture(base, cloneResult = newMissionId)
@@ -45,12 +48,22 @@ class CreateDraftUseCaseTest {
val saved = fixture.lastSaved!!
assertEquals(newMissionId, saved.planId)
assertEquals(PlannedPlanStatus.DRAFT, saved.status)
assertEquals(base.planId, saved.basePlanId)
assertEquals(predecessor, saved.basePlanId)
assertEquals(base.startTime, saved.startTime)
assertEquals(base.endTime, saved.endTime)
assertEquals(base.kppId, saved.kppId)
}
@Test
fun `replace of a chain-start plan inherits a null base`() {
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = null)
val fixture = fixture(base)
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
assertEquals(null, fixture.lastSaved!!.basePlanId)
}
@Test
fun `draft snapshots the current chain version as its optimistic token`() {
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
@@ -185,7 +198,7 @@ class CreateDraftUseCaseTest {
}
@Test
fun `an existing open DRAFT on the same base yields 409`() {
fun `an existing open DRAFT on the same slot (spacecraft + window) yields 409`() {
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
val fixture = fixture(base, existingDrafts = listOf(existing))
@@ -227,9 +240,12 @@ class CreateDraftUseCaseTest {
planningState: SpacecraftPlanningStateEntity? = null
): Fixture {
val repo = mock(PlannedPlanRepository::class.java)
val basePlanId = base?.planId ?: UUID.randomUUID()
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
`when`(repo.findAllByBasePlanIdAndStatus(basePlanId, PlannedPlanStatus.DRAFT)).thenReturn(existingDrafts)
`when`(
repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull()
)
).thenReturn(existingDrafts)
val saved = arrayOfNulls<PlannedPlanEntity>(1)
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
@@ -253,14 +269,20 @@ class CreateDraftUseCaseTest {
return Fixture(useCase, client, saved)
}
private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
private fun plan(
start: LocalDateTime,
end: LocalDateTime,
status: PlannedPlanStatus,
basePlanId: UUID? = null
): PlannedPlanEntity =
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = spacecraftId,
startTime = start,
endTime = end,
kppId = kppId,
status = status
status = status,
basePlanId = basePlanId
)
private fun props(freeze: Long): PlanningProperties =
@@ -93,10 +93,12 @@ class IssueDuePlanWorkerOnDemandTest {
}
@Test
fun `skips spacecraft that already has an active attempt`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
fun `skips spacecraft that has an in-flight plan being issued`() {
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activeAttemptId = UUID.randomUUID())
// По КА есть план «в работе» (ISSUING) → авто-выдача следующего окна блокируется (до решения).
val inFlight = persistedPlan(PlannedPlanStatus.ISSUING, now().plusMinutes(10), now().plusHours(1))
val planStore = linkedMapOf(inFlight.planId to inFlight)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val head = plan(now().plusMinutes(10), now().plusHours(3))
val projection = mock(PlanProjectionService::class.java)
`when`(projection.project(spacecraftId)).thenReturn(listOf(head))
@@ -104,7 +106,8 @@ class IssueDuePlanWorkerOnDemandTest {
worker.issueDuePlans()
assertTrue(planStore.isEmpty())
// Новый план не материализован, проекция даже не запрашивалась (блокировка раньше).
assertEquals(1, planStore.size)
verify(projection, never()).project(spacecraftId)
}
@@ -119,9 +122,10 @@ class IssueDuePlanWorkerOnDemandTest {
end = now().plusHours(1)
)
planStore[unconfirmed.planId] = unconfirmed
val baseId = UUID.randomUUID()
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
currentBasePlanId = UUID.randomUUID(),
currentBasePlanId = baseId,
activePlanId = unconfirmed.planId,
activeAttemptId = UUID.randomUUID()
)
@@ -132,8 +136,9 @@ class IssueDuePlanWorkerOnDemandTest {
// Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION.
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status)
// Якорь цепочки сдвинут на него, следующий план выдан с новым attempt.
assertEquals(unconfirmed.planId, state.currentBasePlanId)
// Базу/версию grace НЕ двигает (это делает только LAID_IN) — currentBasePlanId не меняется.
assertEquals(baseId, state.currentBasePlanId)
// Следующий план всё равно выдан с новым attempt (grace перестал блокировать).
assertEquals(2, planStore.size)
assertNotNull(state.activeAttemptId)
assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime)
@@ -237,23 +242,23 @@ class IssueDuePlanWorkerOnDemandTest {
}
@Test
fun `issuePlanNow rejects when spacecraft has an active attempt for another plan`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
fun `issuePlanNow allows a parallel issue while another plan is in flight`() {
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
// По КА уже летит другой план (WAITING_DECISION) — это больше НЕ блокирует: несколько выдач
// по КА разом допустимы (исходный + коррекция), конкуренция разрешается на закладке.
val other = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(2))
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 planStore = linkedMapOf(other.planId to other, target.planId to target)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val worker = worker(planStore, attemptStore, state)
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
worker.issuePlanNow(target.planId)
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
assertTrue(attemptStore.isEmpty())
// Выдача target стартовала параллельно: новый attempt, target в WAITING_DECISION.
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
assertEquals(1, attemptStore.size)
assertEquals(target.planId, state.activePlanId)
assertNotNull(state.activeAttemptId)
}
@Test
@@ -391,27 +396,22 @@ class IssueDuePlanWorkerOnDemandTest {
}
@Test
fun `promoteDraftNow rejects with 409 when another plan is being issued`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
fun `promoteDraftNow promotes a correction while the base is still in flight`() {
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
planStore[base.planId] = base
// База того же окна ещё «в полёте» (WAITING_DECISION) — это больше НЕ блокирует promote
// коррекции: оба плана летят параллельно, конкуренция разрешается на закладке (LAID_IN).
val base = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
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()
)
// Слот валиден — отказ именно 409 (чужая выдача), а не 422 (слот).
val planStore = linkedMapOf(base.planId to base, draft.planId to draft)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
worker.promoteDraftNow(draft.planId)
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
assertTrue(attemptStore.isEmpty())
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status)
assertEquals(1, attemptStore.size)
assertEquals(draft.planId, state.activePlanId)
assertNotNull(state.activeAttemptId)
}
@Test
@@ -522,6 +522,12 @@ class IssueDuePlanWorkerOnDemandTest {
store[entity.planId] = entity
entity
}
`when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
val sc = invocation.arguments[0] as String
@Suppress("UNCHECKED_CAST")
val statuses = invocation.arguments[1] as Collection<PlannedPlanStatus>
store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses }
}
return repository
}
@@ -4,12 +4,15 @@ 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.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
@@ -58,7 +61,7 @@ class PlanIssueAndDecisionFlowTest {
}
@Test
fun `accepted decision moves plan to AWAITING_LAYIN sets base and enqueues slots update once`() {
fun `accepted decision moves plan to AWAITING_LAYIN and enqueues slots without anchoring base`() {
val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
@@ -74,13 +77,74 @@ class PlanIssueAndDecisionFlowTest {
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
assertEquals(plan.planId, state.currentBasePlanId)
assertNull(state.activePlanId)
assertNull(state.activeAttemptId)
assertNull(state.urgentReissueFrom)
// ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN.
assertNull(state.currentBasePlanId)
assertEquals(0, state.chainVersion)
verify(slotsUpdateJobService).enqueue(plan.planId)
}
@Test
fun `layin confirmed makes plan the base bumps version and supersedes overlapping competitors`() {
val spacecraftId = "25544"
val laidIn = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
// Конкурент-коррекция того же окна (пересекается) — должна уйти в SUPERSEDED.
val competitor = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
// Несвязанное будущее окно — не трогаем.
val unrelated = plannedPlan(spacecraftId, time(20), time(21), PlannedPlanStatus.AWAITING_LAYIN)
val store = linkedMapOf(laidIn.planId to laidIn, competitor.planId to competitor, unrelated.planId to unrelated)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 4)
val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
`when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
val useCase = HandlePlanDecisionUseCase(
processedPlanDecisionEventRepository = processedRepository,
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
plannedPlanRepository = plannedRepository(store),
spacecraftPlanningStateJpaRepository = stateRepository(state),
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
planningProperties = planningProperties()
)
val message = feedbackMessage(spacecraftId, laidIn.planId)
useCase.handleLayinConfirmed(message)
useCase.handleLayinConfirmed(message) // повтор того же eventId — no-op (идемпотентность)
assertEquals(PlannedPlanStatus.LAID_IN, laidIn.status)
assertEquals(laidIn.planId, state.currentBasePlanId)
assertEquals(5, state.chainVersion)
assertEquals(PlannedPlanStatus.SUPERSEDED, competitor.status)
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, unrelated.status)
}
@Test
fun `layin confirmed rejected with 409 when another plan is already laid in within 10 minutes`() {
val spacecraftId = "25544"
val target = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
// Уже заложенный план со стартом на 5 минут позже — в пределах ±10 мин → подтвердить нельзя.
val alreadyLaidIn = plannedPlan(spacecraftId, time(12).plusMinutes(5), time(13), PlannedPlanStatus.LAID_IN)
val store = linkedMapOf(target.planId to target, alreadyLaidIn.planId to alreadyLaidIn)
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
currentBasePlanId = alreadyLaidIn.planId,
chainVersion = 1
)
val useCase = HandlePlanDecisionUseCase(
processedPlanDecisionEventRepository = processedRepository(),
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
plannedPlanRepository = plannedRepository(store),
spacecraftPlanningStateJpaRepository = stateRepository(state),
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
planningProperties = planningProperties()
)
val ex = assertThrows(ResponseStatusException::class.java) {
useCase.handleLayinConfirmed(feedbackMessage(spacecraftId, target.planId))
}
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, target.status) // не заложен
assertEquals(1, state.chainVersion) // версия цепочки не двигалась
}
@Test
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
val spacecraftId = "25544"
@@ -138,21 +202,20 @@ class PlanIssueAndDecisionFlowTest {
}
@Test
fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() {
fun `decision for a superseded plan records the attempt but does not move the chain`() {
val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
// План уже вытеснен заложенной коррекцией (SUPERSEDED, не in-flight) — запоздавшее решение
// по его attempt записывается, но цепочку не двигает и базу не трогает.
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.SUPERSEDED)
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val activeAttemptId = UUID.randomUUID()
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED))
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
assertEquals(plan.planId, state.activePlanId)
assertEquals(activeAttemptId, state.activeAttemptId)
assertEquals(PlannedPlanStatus.SUPERSEDED, plan.status)
assertNull(state.currentBasePlanId)
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
}
@@ -255,6 +318,18 @@ class PlanIssueAndDecisionFlowTest {
store[plan.planId] = plan
plan
}
`when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
val sc = invocation.arguments[0] as String
@Suppress("UNCHECKED_CAST")
val statuses = invocation.arguments[1] as Collection<PlannedPlanStatus>
store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses }
}
`when`(repository.saveAll(anyNonNull<MutableList<PlannedPlanEntity>>())).thenAnswer { invocation ->
@Suppress("UNCHECKED_CAST")
val plans = invocation.arguments[0] as Iterable<PlannedPlanEntity>
plans.forEach { plan -> store[plan.planId] = plan }
plans.toMutableList()
}
return repository
}
@@ -106,7 +106,9 @@ class PlanProjectionServiceTest {
}
@Test
fun `falls back to the base plan when the active plan is no longer in flight`() {
fun `anchors on the furthest accepted window among several not-yet-laid-in plans`() {
// Принят и базовый, и более поздняя коррекция (оба AWAITING_LAYIN) — якорь берём по крайнему
// окну (endTime), чтобы форвард-цепочка строилась после самого дальнего зафиксированного слота.
val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN)
val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN)
val calculation = mock(PlanCalculationService::class.java)
@@ -118,8 +120,7 @@ class PlanProjectionServiceTest {
val service = service(
state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
currentBasePlanId = base.planId,
activePlanId = accepted.planId
currentBasePlanId = base.planId
),
plansById = mapOf(base.planId to base, accepted.planId to accepted),
calculation = calculation
@@ -127,8 +128,8 @@ class PlanProjectionServiceTest {
service.project(spacecraftId)
assertEquals(base.startTime, anchor?.startTime)
assertEquals(base.kppId, anchor?.kppId)
assertEquals(accepted.startTime, anchor?.startTime)
assertEquals(accepted.kppId, anchor?.kppId)
}
@Test
@@ -162,6 +163,11 @@ class PlanProjectionServiceTest {
`when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation ->
Optional.ofNullable(plansById[invocation.getArgument(0)])
}
`when`(plannedRepository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
@Suppress("UNCHECKED_CAST")
val statuses = invocation.getArgument<Collection<PlannedPlanStatus>>(1)
plansById.values.filter { plan -> plan.status in statuses }
}
} else {
`when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan))
}
@@ -16,10 +16,8 @@ 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
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
@@ -27,16 +25,11 @@ import java.util.UUID
class TestPlanDecisionServiceTest {
@Test
fun `generate decision builds message for active attempt and delegates to use case`() {
fun `generate decision builds message for latest attempt and delegates to use case`() {
val plan = plannedPlan()
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(
spacecraftId = plan.spacecraftId,
activePlanId = plan.planId,
activeAttemptId = attempt.attemptId
)
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
val service = service(plan, attempt, handlePlanDecisionUseCase)
val message = service.generateDecision(plan.planId, PlanDecision.ACCEPTED, "manual accept")
@@ -50,12 +43,10 @@ class TestPlanDecisionServiceTest {
}
@Test
fun `generate decision rejects plan without active attempt`() {
fun `generate decision rejects plan without any attempt`() {
val plan = plannedPlan()
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(spacecraftId = plan.spacecraftId)
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
val service = service(plan, attempt = null, handlePlanDecisionUseCase)
val exception = assertThrows(ResponseStatusException::class.java) {
service.generateDecision(plan.planId, PlanDecision.REJECTED, null)
@@ -67,22 +58,18 @@ class TestPlanDecisionServiceTest {
private fun service(
plan: PlannedPlanEntity,
attempt: PlanIssueAttemptEntity,
state: SpacecraftPlanningStateEntity,
attempt: PlanIssueAttemptEntity?,
handlePlanDecisionUseCase: HandlePlanDecisionUseCase
): TestPlanDecisionService {
val plannedPlanRepository = mock(PlannedPlanRepository::class.java)
val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java)
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
`when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan))
`when`(planIssueAttemptRepository.findById(attempt.attemptId)).thenReturn(Optional.of(attempt))
`when`(stateRepository.findById(plan.spacecraftId)).thenReturn(Optional.of(state))
`when`(planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)).thenReturn(attempt)
return TestPlanDecisionService(
plannedPlanRepository = plannedPlanRepository,
planIssueAttemptRepository = planIssueAttemptRepository,
spacecraftPlanningStateJpaRepository = stateRepository,
handlePlanDecisionUseCase = handlePlanDecisionUseCase
)
}
@@ -47,6 +47,13 @@ class TguPlanningRestController(
@RequestParam(required = false) reason: String?
) = tguPlanningService.sendDecision(planId, decision, reason)
/** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */
@PostMapping("/plans/{planId}/confirm-layin")
fun confirmLayin(
@PathVariable planId: UUID,
@RequestParam(required = false) reason: String?
) = tguPlanningService.confirmLayin(planId, reason)
@GetMapping("/spacecraft/{noradId}/flight-line")
fun spacecraftFlightLine(
@PathVariable noradId: Long,
@@ -87,6 +87,26 @@ class TguPlanningService(webClientBuilderProvider: ObjectProvider<WebClient.Buil
throw error
}
fun confirmLayin(planId: UUID, reason: String?) {
runCatching {
webClientBuilder.build()
.post()
.uri(buildUri("/test/tgu/plans/{planId}/confirm-layin") { builder ->
reason?.takeIf { it.isNotBlank() }?.let { builder.queryParam("reason", it) }
builder.buildAndExpand(planId)
})
.retrieve()
.onStatus({ status -> status.isError }, ::mapReadError)
.bodyToMono(Void::class.java)
.block()
}.getOrElse { error ->
if (error is WebClientRequestException) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, TEST_CONTROLLER_UNAVAILABLE_MESSAGE, error)
}
throw error
}
}
private fun buildUri(
path: String,
customizer: (UriComponentsBuilder) -> Any = { builder -> builder.build() }