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 visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1 layin-failed: pcp.tgu.layin-failed.v1
layin-confirmed: pcp.tgu.layin-confirmed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1 plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1 station-params-changed: pcp.tgu.station-params-changed.v1
+34 -24
View File
@@ -27,9 +27,10 @@
┌────────────────────────────────┼────────────────────────────────┐ ┌────────────────────────────────┼────────────────────────────────┐
событие «заложен» событие «не заложен» событие «не принят» событие «заложен» событие «не заложен» событие «не принят»
(пока не реализовано) ▼ ▼ ▼ ▼
LAYIN_FAILED NOT_ACCEPTED LAID_IN LAYIN_FAILED NOT_ACCEPTED
LAID_IN (срочная перевыдача) (срочная перевыдача) (база цепочки; конкуренты (срочная перевыдача) (срочная перевыдача)
того же окна → SUPERSEDED)
при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS
``` ```
@@ -75,21 +76,25 @@
### AWAITING_LAYIN — принят, ждёт подтверждения закладки ### AWAITING_LAYIN — принят, ждёт подтверждения закладки
- **Когда.** Пришло решение ACCEPTED (`acceptPlan`). - **Когда.** Пришло решение ACCEPTED (`acceptPlan`).
- **Что значит.** План принят и **оптимистично считается заложенным**; это текущий **базовый план - **Что значит.** План принят и ждёт закладки. **ACCEPT больше НЕ делает план базой и не двигает
цепочки** (`state.currentBasePlanId = plan.planId`, `chainVersion += 1`). Запускается джоб `chainVersion`** — это разъединено намеренно: после приёмки можно отправить и принять коррекцию
обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`). того же окна, поэтому по одному окну может одновременно «лететь» несколько AWAITING_LAYIN/
- **Дальше.** При подтверждении закладки → `LAID_IN` (терминальный успех; **событие пока не WAITING_DECISION. Базой и версией цепочки управляет только закладка (`LAID_IN`). На ACCEPT
реализовано**, см. ниже). При негативе — `LAYIN_FAILED`/`NOT_ACCEPTED`. запускается джоб обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- В легенде это де-факто «принятый/заложенный» статус (зелёный). Перевыдача в свой слот ещё - **Дальше.** При подтверждении закладки → `LAID_IN` (становится базой). При негативе —
`LAYIN_FAILED`/`NOT_ACCEPTED`. Если базой стала **другая** коррекция того же окна — этот план
гасится в `SUPERSEDED`.
- В легенде это «принятый, ждёт закладки» статус (зелёный). Перевыдача/коррекция в свой слот ещё
возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только
fork-forward). fork-forward).
### REJECTED — отклонён ### REJECTED — отклонён
- **Когда.** Пришло решение REJECTED (`rejectPlan`). - **Когда.** Пришло решение REJECTED (`rejectPlan`).
- **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план - **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план
бессмысленно): активная выдача снимается, срочная перевыдача гасится, цепочка ставится **на паузу** бессмысленно). Цепочка ставится **на паузу** (`pausedAfterReject = true`) — **только если по КА не
(`pausedAfterReject = true`) — проекция становится пустой, воркер ничего не выдаёт. Базовый план не осталось других живых кандидатов** (в полёте/приняты, но не заложены): при нескольких выдачах
трогается. отказ одной не глушит цепочку, пока жив другой кандидат. На паузе проекция пуста, воркер ничего не
выдаёт. Базовый план не трогается.
- **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу. - **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу.
- В UI у КА с паузой рисуется пометка «⏸ на паузе». - В UI у КА с паузой рисуется пометка «⏸ на паузе».
@@ -112,18 +117,23 @@
- **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором. - **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором.
Опасный статус, в UI выделен пунктиром. Опасный статус, в UI выделен пунктиром.
### LAID_IN — заложен (терминальный успех) ### LAID_IN — заложен (терминальный успех, база цепочки)
- **Когда (целевое).** При подтверждении закладки базового плана из `AWAITING_LAYIN`. - **Когда.** Пришло внешнее событие «план заложен» по плану в `AWAITING_LAYIN`
- **Что значит.** Подтверждённая закладка, терминальный успех; «точка невозврата» — дальше план (`LayinConfirmedConsumer``handleLayinConfirmed`, топик `layin-confirmed`).
меняется только через fork-forward (используется в гардах `CreateDraftUseCase`, - **Что значит.** Подтверждённая закладка — **именно она делает план базой цепочки**
`assertReissuableInSlot`). (`state.currentBasePlanId = plan.planId`, `chainVersion += 1`, снимается пауза/срочная
- **⚠️ TODO — не реализовано.** Потребителя события «план заложен» сейчас **нет**, поэтому план в перевыдача). «Точка невозврата»: дальше план меняется только через fork-forward (гарды
`LAID_IN` не переходит и остаётся в `AWAITING_LAYIN`. Нужно: Kafka-consumer (по аналогии с `CreateDraftUseCase`, `assertReissuableInSlot`).
`LayinFailedConsumer`/`PlanNotAcceptedConsumer`) + `handleLayinConfirmed` в - **Supersede конкурентов.** Все ещё не заложенные планы КА (ISSUING/WAITING_DECISION/AWAITING_LAYIN)
`HandlePlanDecisionUseCase` (`AWAITING_LAYIN → LAID_IN` по текущему базовому плану). Маркер-TODO с окном, пересекающим окно заложенного, переводятся в `SUPERSEDED` (`supersedeCompetingPlans`).
стоит в коде `HandlePlanDecisionUseCase` рядом с обработчиками негативного фидбека. - Событие по плану не в `AWAITING_LAYIN` (устаревшее/дубль/уже вытеснён) — игнорируется.
- В легенде статус показан (зелёный, темнее `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: TODO:
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца. - Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например) - Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо - Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
- Проставлять статусы слотов только после того, как план заложен на КА
- Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
- Ошибка при попытке принять план во вкладке планы
- Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
- Отображение предыдущих планов и следующих. Предлагаю не просто сделать отображение как рамку, я делать еле заметные все другие планы, а эти обводить яркими рамками, чтобы было видно какой план прошлый, а какой будущий.
Прошлый: #94A3B8 slate-400
Текущий: #2563EB blue-600
Следующий: #F59E0B amber-500
Предлагаю использовать эти цвета
- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик
- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план.
- Деактивировать отмененный план, чтобы он не портил статистику
- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы
@@ -14,6 +14,9 @@ data class TopicsProperties(
/** Topic-событие «не заложен» по принятому плану. */ /** Topic-событие «не заложен» по принятому плану. */
val layinFailed: String, val layinFailed: String,
/** Topic-событие «план заложен» (позитивный фидбек закладки) — переводит план в LAID_IN. */
val layinConfirmed: String,
/** Topic-событие «не принят» по принятому плану. */ /** Topic-событие «не принят» по принятому плану. */
val planNotAccepted: String, val planNotAccepted: String,
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage 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.dto.PlanResponse
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
@@ -68,6 +69,14 @@ class TestTguController(
): PlanDecisionMessage = ): PlanDecisionMessage =
testPlanDecisionService.generateDecision(planId, decision, reason) 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 НСИ вместо запроса в НСИ. */ /** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */
@PostMapping("/platforms/load-from-file") @PostMapping("/platforms/load-from-file")
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse { fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse {
@@ -29,6 +29,13 @@ enum class PlannedPlanStatus {
/** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ /** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
NOT_ACCEPTED, NOT_ACCEPTED,
/**
* Вытеснён: по тому же окну КА другой план ушёл в LAID_IN (заложен), а этот — конкурирующая
* выдача/коррекция (ISSUING/WAITING_DECISION/AWAITING_LAYIN) на пересекающееся окно. Терминал,
* цепочку не двигает. Решение по запоздавшей attempt такого плана записывается, но игнорируется.
*/
SUPERSEDED,
REJECTED, REJECTED,
START_AMBIGUOUS 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). */ /** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity> fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
/**
* Планы КА в одном из статусов — для вычисления «в полёте» (ISSUING/WAITING_DECISION) и набора
* конкурентов при закладке (supersede). Заменяет опору на единственный active-слот состояния.
*/
fun findAllBySpacecraftIdAndStatusIn(
spacecraftId: String,
statuses: Collection<PlannedPlanStatus>
): List<PlannedPlanEntity>
/** /**
* Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков: * Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков:
* сочетание (КА + начало + конец) уникально среди DRAFT. * сочетание (КА + начало + конец) уникально среди DRAFT.
@@ -47,18 +47,6 @@ class CreateDraftUseCase(
val now = LocalDateTime.now() 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) { return when (req.mode) {
DraftMode.REPLACE -> createReplaceDraft(base, now) DraftMode.REPLACE -> createReplaceDraft(base, now)
DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, 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( val cloneResponse = missionPlaningClient.cloneMission(
missionId = base.planId, missionId = base.planId,
req = MissionCloneRequest(mode = DraftMode.REPLACE) req = MissionCloneRequest(mode = DraftMode.REPLACE)
) )
// Replace-черновик — это КОРРЕКЦИЯ исходного плана: он занимает тот же слот цепочки, что и
// база, поэтому его предшественник (basePlanId) = предшественник базы (base.basePlanId), а
// НЕ сама база. Так после закладки коррекция вытеснит исходный план на том же окне, а на
// таймлайне стрелка коррекции ведёт к тому же предыдущему плану, что и у исходного.
return saveDraftWithCompensation( return saveDraftWithCompensation(
draftPlanId = cloneResponse.newMissionId, draftPlanId = cloneResponse.newMissionId,
base = base, base = base,
draftBasePlanId = base.basePlanId,
startTime = base.startTime, startTime = base.startTime,
endTime = base.endTime, endTime = base.endTime,
kppId = base.kppId, kppId = base.kppId,
@@ -128,14 +125,20 @@ class CreateDraftUseCase(
// Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции. // Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции.
assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId) assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId)
// Идемпотентность: один открытый черновик на слот (КА + окно будущей ЗРВ).
assertNoOpenDraftForWindow(base.spacecraftId, windowStart, windowStop)
val cloneResponse = missionPlaningClient.cloneMission( val cloneResponse = missionPlaningClient.cloneMission(
missionId = base.planId, missionId = base.planId,
req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop) req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop)
) )
// Fork-forward — это ПРОДОЛЖЕНИЕ цепочки на будущую ЗРВ ПОСЛЕ базы (база заложена/в прошлом),
// поэтому предшественник нового плана = сама база (base.planId).
return saveDraftWithCompensation( return saveDraftWithCompensation(
draftPlanId = cloneResponse.newMissionId, draftPlanId = cloneResponse.newMissionId,
base = base, base = base,
draftBasePlanId = base.planId,
startTime = windowStart, startTime = windowStart,
endTime = windowStop, endTime = windowStop,
kppId = windowKppId, 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) соответствует хотя бы одному * Проверяет, что тройка (windowStart, windowStop, kppId) соответствует хотя бы одному
* плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность * плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность
@@ -178,6 +197,7 @@ class CreateDraftUseCase(
private fun saveDraftWithCompensation( private fun saveDraftWithCompensation(
draftPlanId: UUID, draftPlanId: UUID,
base: PlannedPlanEntity, base: PlannedPlanEntity,
draftBasePlanId: UUID?,
startTime: LocalDateTime, startTime: LocalDateTime,
endTime: LocalDateTime, endTime: LocalDateTime,
kppId: String, kppId: String,
@@ -199,13 +219,13 @@ class CreateDraftUseCase(
endTime = endTime, endTime = endTime,
kppId = kppId, kppId = kppId,
status = PlannedPlanStatus.DRAFT, status = PlannedPlanStatus.DRAFT,
basePlanId = base.planId, basePlanId = draftBasePlanId,
chainVersion = baseChainVersion chainVersion = baseChainVersion
) )
) )
log.info( log.info(
"Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}", "Создан черновик плана: draftPlanId={}, srcPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
draft.planId, base.planId, base.spacecraftId, baseChainVersion draft.planId, base.planId, draftBasePlanId, base.spacecraftId, baseChainVersion
) )
CreateDraftResponse(draftPlanId = draft.planId) CreateDraftResponse(draftPlanId = draft.planId)
} catch (ex: Exception) { } 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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
/** /**
@@ -70,24 +71,107 @@ class HandlePlanDecisionUseCase(
if (attempt.status.isTerminal()) { if (attempt.status.isTerminal()) {
return return
} }
if (state.activeAttemptId != attempt.attemptId) { // Несколько выдач по КА могут лететь одновременно (исходный план + коррекция того же окна),
// поэтому решение применяем к своему плану по его статусу, а не сверяя с единственным
// active-слотом. Если план уже терминальный (вытеснен заложенной коррекцией / отклонён) —
// запоздавшее решение просто пишем на attempt, цепочку им не двигаем.
if (plan.status !in IN_FLIGHT_STATUSES) {
recordDecisionOnAttempt(message, attempt) recordDecisionOnAttempt(message, attempt)
planIssueAttemptRepository.save(attempt) planIssueAttemptRepository.save(attempt)
return return
} }
when (message.decision) { when (message.decision) {
PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan, state) PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan)
PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state) PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state)
} }
} }
// TODO(layin-confirmed): реализовать приём события «план заложен» (положительный фидбек по закладке). /**
// Сейчас принятый план остаётся в AWAITING_LAYIN (оптимистично «заложен») и в LAID_IN никогда не * Событие «план заложен» (позитивный фидбек закладки): принятый план переходит в LAID_IN и
// переходит — нет потребителя этого события. Нужно: Kafka-consumer (по аналогии с LayinFailedConsumer) * становится базой цепочки. Именно закладка — а не ACCEPT — двигает якорь и chainVersion,
// + handleLayinConfirmed, который по текущему базовому плану в AWAITING_LAYIN переводит его в * поэтому до неё по одному окну могут одновременно «лететь» исходный план и его коррекция.
// PlannedPlanStatus.LAID_IN (терминальный успех, «точка невозврата»: дальше только fork-forward). * Все ещё не заложенные конкуренты на пересекающееся окно гасятся в SUPERSEDED.
// См. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md. */
@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 @Transactional
@@ -151,8 +235,7 @@ class HandlePlanDecisionUseCase(
private fun acceptPlan( private fun acceptPlan(
message: PlanDecisionMessage, message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity, attempt: PlanIssueAttemptEntity,
plan: PlannedPlanEntity, plan: PlannedPlanEntity
state: SpacecraftPlanningStateEntity
) { ) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt) recordDecisionOnAttempt(message, attempt)
@@ -161,17 +244,11 @@ class HandlePlanDecisionUseCase(
plan.acceptedAt = message.decisionTime plan.acceptedAt = message.decisionTime
plan.updatedAt = now plan.updatedAt = now
state.currentBasePlanId = plan.planId // ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт
state.activePlanId = null // закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN
state.activeAttemptId = null // ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна.
state.urgentReissueFrom = null
state.pausedAfterReject = false
state.chainVersion += 1
state.updatedAt = now
planIssueAttemptRepository.save(attempt) planIssueAttemptRepository.save(attempt)
plannedPlanRepository.save(plan) plannedPlanRepository.save(plan)
spacecraftPlanningStateJpaRepository.save(state)
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
} }
@@ -187,14 +264,17 @@ class HandlePlanDecisionUseCase(
plan.status = PlannedPlanStatus.REJECTED plan.status = PlannedPlanStatus.REJECTED
plan.updatedAt = now plan.updatedAt = now
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный», и повторно выдавать тот же // Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный». Но при нескольких выдачах
// план бессмысленно. Снимаем активную выдачу, гасим срочную перевыдачу и ставим цепочку на // по КА отказ одной не должен глушить цепочку, если живёт другой кандидат (в полёте/принят,
// паузу (pausedAfterReject) — проекция станет пустой, воркер ничего не выдаст, пока оператор // но не заложен) — он ещё может закрыть окно. Паузу (pausedAfterReject) ставим, только если
// вручную не создаст исправленный план (его promote/ручная выдача снимут паузу). Базу не трогаем. // других живых кандидатов не осталось: тогда проекция пуста, и оператор создаёт план вручную.
val hasOtherLiveCandidate = plannedPlanRepository
.findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, LIVE_UNLAID_STATUSES)
.any { it.planId != plan.planId }
state.activePlanId = null state.activePlanId = null
state.activeAttemptId = null state.activeAttemptId = null
state.urgentReissueFrom = null state.urgentReissueFrom = null
state.pausedAfterReject = true state.pausedAfterReject = !hasOtherLiveCandidate
state.updatedAt = now state.updatedAt = now
planIssueAttemptRepository.save(attempt) planIssueAttemptRepository.save(attempt)
@@ -234,4 +314,19 @@ class HandlePlanDecisionUseCase(
private fun PlanIssueAttemptStatus.isTerminal(): Boolean = private fun PlanIssueAttemptStatus.isTerminal(): Boolean =
this != PlanIssueAttemptStatus.STARTING && this != PlanIssueAttemptStatus.STARTED 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? { private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val state = getOrCreateLockedState(spacecraftId) val state = getOrCreateLockedState(spacecraftId)
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) { // Форвард-цепочку не выдаём, пока по КА есть выдача «в работе»: ISSUING или WAITING_DECISION
// в пределах grace после старта. Прошедший grace больше не блокирует — молчаливо продолжаем
// (базу/версию это НЕ двигает, их двигает только LAID_IN).
if (hasBlockingInFlight(spacecraftId, now)) {
return null return null
} }
@@ -114,7 +117,8 @@ class IssueDuePlanWorker(
return null 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) val attempt = createStartingAttempt(plan.planId, now)
state.activePlanId = plan.planId state.activePlanId = plan.planId
@@ -127,45 +131,33 @@ class IssueDuePlanWorker(
} }
/** /**
* Если активная выдача это план в WAITING_DECISION, по которому уже прошло * По КА есть выдача «в работе», блокирующая авто-выдачу следующего окна форвард-цепочки:
* [PlanningProperties.waitingDecisionGraceMinutes] минут после старта и решения так и нет, * ISSUING (только что выдан, решения ещё нет) или WAITING_DECISION в пределах
* перестаём ждать подтверждение: снимаем блокировку (activePlanId/activeAttemptId), * [PlanningProperties.waitingDecisionGraceMinutes] после старта. WAITING_DECISION за grace
* двигаем якорь цепочки на этот план и идём дальше. Статус плана НЕ меняем он остаётся * больше не блокирует молчаливо продолжаем цепочку (статус плана не меняем; базу/версию
* WAITING_DECISION (молчаливое продолжение, а не ACCEPTED). Attempt оставляем как есть: * двигает только LAID_IN). Несколько выдач по КА (исходный план + коррекция) допустимы
* запоздалое решение по нему просто запишется на attempt, не трогая ушедшую вперёд цепочку. * блокирует наличие хотя бы одной «в работе».
*
* @return true, если блокировка снята и можно выдавать следующий план в этом же тике.
*/ */
private fun tryAdvancePastUnconfirmedPlan(state: SpacecraftPlanningStateEntity, now: LocalDateTime): Boolean { private fun hasBlockingInFlight(spacecraftId: String, now: LocalDateTime): Boolean =
val activePlanId = state.activePlanId ?: return false plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, BLOCKING_INFLIGHT_STATUSES).any { plan ->
val plan = plannedPlanRepository.findById(activePlanId).orElse(null) ?: return false plan.status == PlannedPlanStatus.ISSUING ||
if (plan.status != PlannedPlanStatus.WAITING_DECISION) { now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))
return false
}
if (now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))) {
return false
} }
state.currentBasePlanId = plan.planId /**
state.activePlanId = null * planId самого дальнего (по endTime) зафиксированного окна КА провенанс (basePlanId) для
state.activeAttemptId = null * следующего выданного плана форвард-цепочки. Включает ещё не заложенные выдачи, чтобы цепочка
state.urgentReissueFrom = null * связывалась с непосредственно предыдущим окном, а не с последней заложенной базой.
state.chainVersion += 1 */
state.updatedAt = now private fun latestCommittedPlanId(spacecraftId: String, fallback: UUID?): UUID? =
spacecraftPlanningStateJpaRepository.save(state) plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
.maxByOrNull { plan -> plan.endTime }?.planId ?: fallback
log.info(
"Plan {} (spacecraft {}) stayed WAITING_DECISION past grace; continuing chain without confirmation.",
plan.planId, plan.spacecraftId
)
return true
}
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
val state = getOrCreateLockedState(plan.spacecraftId) val state = getOrCreateLockedState(plan.spacecraftId)
assertReissuableInSlot(plan, state, now) assertReissuableInSlot(plan, now)
plan.status = PlannedPlanStatus.ISSUING plan.status = PlannedPlanStatus.ISSUING
plan.updatedAt = now plan.updatedAt = now
@@ -196,7 +188,7 @@ class IssueDuePlanWorker(
val state = getOrCreateLockedState(plan.spacecraftId) val state = getOrCreateLockedState(plan.spacecraftId)
assertDraftBaseUnchanged(plan, state) assertDraftBaseUnchanged(plan, state)
assertDraftSlotStillValid(plan) assertDraftSlotStillValid(plan)
assertReissuableInSlot(plan, state, now) assertReissuableInSlot(plan, now)
plan.status = PlannedPlanStatus.ISSUING plan.status = PlannedPlanStatus.ISSUING
plan.updatedAt = now plan.updatedAt = now
@@ -255,25 +247,17 @@ class IssueDuePlanWorker(
/** /**
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException], * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия: * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
* - по КА нет активной выдачи ДРУГОГО плана (иначе 409 CONFLICT две выдачи разом недопустимы);
* - план не подтверждён как заложенный (`status != LAID_IN` иначе 422, доступен только fork-forward); * - план не подтверждён как заложенный (`status != LAID_IN` иначе 422, доступен только fork-forward);
* - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 окно заморожено). * - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 окно заморожено).
* *
* Важно: повторную ручную выдачу ТОГО ЖЕ плана разрешаем, даже если по нему уже летит attempt * Гарда «по КА уже идёт выдача другого плана» больше НЕТ: несколько выдач по КА одновременно
* (например, план только что ушёл в авто-выдачу за `notificationBeforeStartMinutes`). Оператор сохраняет * норма (исходный план + коррекция того же окна). Конкуренция разрешается на закладке: ушедший
* право перевыдать именно этот план вплоть до порога заморозки новая attempt замещает текущую in-flight * в LAID_IN план гасит остальные в SUPERSEDED ([HandlePlanDecisionUseCase.handleLayinConfirmed]).
* (запоздалое решение по старой attempt запишется как stale и цепочку не тронет).
* *
* Узкого списка «перевыдаваемых» статусов больше нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN, * Узкого списка «перевыдаваемых» статусов нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN, пока план
* пока план в будущем за порогом и не заложен. * в будущем за порогом заморозки и не заложен.
*/ */
private fun assertReissuableInSlot(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity, now: LocalDateTime) { private fun assertReissuableInSlot(plan: PlannedPlanEntity, now: LocalDateTime) {
if (state.activeAttemptId != null && state.activePlanId != plan.planId) {
throw ResponseStatusException(
HttpStatus.CONFLICT,
"По КА ${plan.spacecraftId} уже идёт выдача другого плана (${state.activePlanId}) — дождитесь её завершения"
)
}
if (plan.status == PlannedPlanStatus.LAID_IN) { if (plan.status == PlannedPlanStatus.LAID_IN) {
throw ResponseStatusException( throw ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
@@ -292,7 +276,8 @@ class IssueDuePlanWorker(
private fun materializeIssuingPlan( private fun materializeIssuingPlan(
head: CalculatedPlan, head: CalculatedPlan,
state: SpacecraftPlanningStateEntity, basePlanId: UUID?,
chainVersion: Long,
issueNotBefore: LocalDateTime, issueNotBefore: LocalDateTime,
now: LocalDateTime now: LocalDateTime
): PlannedPlanEntity = ): PlannedPlanEntity =
@@ -303,8 +288,8 @@ class IssueDuePlanWorker(
endTime = head.endTime, endTime = head.endTime,
kppId = head.kppId, kppId = head.kppId,
issueNotBefore = issueNotBefore, issueNotBefore = issueNotBefore,
basePlanId = state.currentBasePlanId, basePlanId = basePlanId,
chainVersion = state.chainVersion, chainVersion = chainVersion,
now = now now = now
) )
@@ -388,4 +373,20 @@ class IssueDuePlanWorker(
val planId: UUID, val planId: UUID,
val attemptId: 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() return emptyList()
} }
val anchorPlan = resolveAnchorPlan(state) val anchorPlan = resolveAnchorPlan(spacecraftId, state)
return if (anchorPlan == null) { return if (anchorPlan == null) {
planCalculationService.calculatePlans(points) planCalculationService.calculatePlans(points)
} else { } else {
@@ -77,23 +77,21 @@ class PlanProjectionService(
} }
/** /**
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка. * Якорь, после которого строится будущая цепочка: самое дальнее (по endTime) зафиксированное
* окно КА среди ISSUING/WAITING_DECISION/AWAITING_LAYIN/LAID_IN.
* *
* Если есть выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION) цепочку * Берём именно крайнее зафиксированное окно (а не «принятую базу»), потому что выдач по КА может
* продолжаем ПОСЛЕ него, а не после принятого базового плана. Иначе проекция заново вывела бы * быть несколько одновременно (исходный план + коррекция), и базой цепочка становится только на
* уже выданный слот и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) нарисовала бы * закладке (LAID_IN). Якорение на крайнее окно не даёт проекции заново вывести уже выданный слот
* его призрак-дубль PLANNED рядом с реальным WAITING_DECISION: точечный дедуп по * и нарисовать его призрак-дубль PLANNED при дрейфе снапшота ЗРВ. Если зафиксированных окон нет
* businessKey в [PlanQueryService] такой рассинхрон не ловит. * откатываемся на `currentBasePlanId` (последняя заложенная база), иначе строим с нуля.
*
* `activePlanId` заполнен только вместе с активной выдачей; в пути выдачи он к моменту
* вызова проекции уже снят, поэтому якорение на план в полёте не влияет на выбор головы.
*/ */
private fun resolveAnchorPlan(state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? { private fun resolveAnchorPlan(spacecraftId: String, state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? {
val inFlight = state?.activePlanId val committed = plannedPlanRepository
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } .findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
?.takeIf { plan -> plan.status in IN_FLIGHT_STATUSES } .maxByOrNull { plan -> plan.endTime }
if (inFlight != null) { if (committed != null) {
return inFlight return committed
} }
return state?.currentBasePlanId return state?.currentBasePlanId
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
@@ -108,7 +106,12 @@ class PlanProjectionService(
) )
private companion object { 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 org.springframework.web.server.ResponseStatusException
import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage 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.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID 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") @ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true")
@Service @Service
class TestPlanDecisionService( class TestPlanDecisionService(
private val plannedPlanRepository: PlannedPlanRepository, private val plannedPlanRepository: PlannedPlanRepository,
private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository,
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
) { ) {
/** /**
* Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer. * Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer.
* Решение адресуется последней attempt плана напрямую выдач по КА может быть несколько разом,
* единого active-слота больше нет.
*/ */
fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage { fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage {
val plan = plannedPlanRepository.findById(planId).orElse(null) val plan = plannedPlanRepository.findById(planId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") ?: 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( ?: throw ResponseStatusException(
HttpStatus.CONFLICT, 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) { if (attempt.status !in ACTIVE_ATTEMPT_STATUSES) {
throw ResponseStatusException( throw ResponseStatusException(
HttpStatus.CONFLICT, HttpStatus.CONFLICT,
@@ -73,6 +57,26 @@ class TestPlanDecisionService(
return message 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 { private companion object {
val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED) val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED)
const val DEFAULT_REASON = "TEST_CONTROLLER" const val DEFAULT_REASON = "TEST_CONTROLLER"
@@ -31,6 +31,7 @@ topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1 layin-failed: pcp.tgu.layin-failed.v1
layin-confirmed: pcp.tgu.layin-confirmed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1 plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1 station-params-changed: pcp.tgu.station-params-changed.v1
@@ -34,8 +34,11 @@ class CreateDraftUseCaseTest {
// --- replace --- // --- replace ---
@Test @Test
fun `replace creates DRAFT with cloned missionId as planId, base window and basePlanId`() { fun `replace creates DRAFT inheriting the source's base (predecessor), not the source itself`() {
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) // База исходного плана — это его предшественник в цепочке. Replace-черновик — коррекция,
// занимающая тот же слот, поэтому он наследует ИМЕННО предшественника, а не сам исходный план.
val predecessor = UUID.randomUUID()
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = predecessor)
val newMissionId = UUID.randomUUID() val newMissionId = UUID.randomUUID()
val fixture = fixture(base, cloneResult = newMissionId) val fixture = fixture(base, cloneResult = newMissionId)
@@ -45,12 +48,22 @@ class CreateDraftUseCaseTest {
val saved = fixture.lastSaved!! val saved = fixture.lastSaved!!
assertEquals(newMissionId, saved.planId) assertEquals(newMissionId, saved.planId)
assertEquals(PlannedPlanStatus.DRAFT, saved.status) assertEquals(PlannedPlanStatus.DRAFT, saved.status)
assertEquals(base.planId, saved.basePlanId) assertEquals(predecessor, saved.basePlanId)
assertEquals(base.startTime, saved.startTime) assertEquals(base.startTime, saved.startTime)
assertEquals(base.endTime, saved.endTime) assertEquals(base.endTime, saved.endTime)
assertEquals(base.kppId, saved.kppId) 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 @Test
fun `draft snapshots the current chain version as its optimistic token`() { fun `draft snapshots the current chain version as its optimistic token`() {
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
@@ -185,7 +198,7 @@ class CreateDraftUseCaseTest {
} }
@Test @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 base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT) val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
val fixture = fixture(base, existingDrafts = listOf(existing)) val fixture = fixture(base, existingDrafts = listOf(existing))
@@ -227,9 +240,12 @@ class CreateDraftUseCaseTest {
planningState: SpacecraftPlanningStateEntity? = null planningState: SpacecraftPlanningStateEntity? = null
): Fixture { ): Fixture {
val repo = mock(PlannedPlanRepository::class.java) val repo = mock(PlannedPlanRepository::class.java)
val basePlanId = base?.planId ?: UUID.randomUUID()
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base)) `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) val saved = arrayOfNulls<PlannedPlanEntity>(1)
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv -> `when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
@@ -253,14 +269,20 @@ class CreateDraftUseCaseTest {
return Fixture(useCase, client, saved) 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( PlannedPlanEntity(
planId = UUID.randomUUID(), planId = UUID.randomUUID(),
spacecraftId = spacecraftId, spacecraftId = spacecraftId,
startTime = start, startTime = start,
endTime = end, endTime = end,
kppId = kppId, kppId = kppId,
status = status status = status,
basePlanId = basePlanId
) )
private fun props(freeze: Long): PlanningProperties = private fun props(freeze: Long): PlanningProperties =
@@ -93,10 +93,12 @@ class IssueDuePlanWorkerOnDemandTest {
} }
@Test @Test
fun `skips spacecraft that already has an active attempt`() { fun `skips spacecraft that has an in-flight plan being issued`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>() 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 head = plan(now().plusMinutes(10), now().plusHours(3))
val projection = mock(PlanProjectionService::class.java) val projection = mock(PlanProjectionService::class.java)
`when`(projection.project(spacecraftId)).thenReturn(listOf(head)) `when`(projection.project(spacecraftId)).thenReturn(listOf(head))
@@ -104,7 +106,8 @@ class IssueDuePlanWorkerOnDemandTest {
worker.issueDuePlans() worker.issueDuePlans()
assertTrue(planStore.isEmpty()) // Новый план не материализован, проекция даже не запрашивалась (блокировка раньше).
assertEquals(1, planStore.size)
verify(projection, never()).project(spacecraftId) verify(projection, never()).project(spacecraftId)
} }
@@ -119,9 +122,10 @@ class IssueDuePlanWorkerOnDemandTest {
end = now().plusHours(1) end = now().plusHours(1)
) )
planStore[unconfirmed.planId] = unconfirmed planStore[unconfirmed.planId] = unconfirmed
val baseId = UUID.randomUUID()
val state = SpacecraftPlanningStateEntity( val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId, spacecraftId = spacecraftId,
currentBasePlanId = UUID.randomUUID(), currentBasePlanId = baseId,
activePlanId = unconfirmed.planId, activePlanId = unconfirmed.planId,
activeAttemptId = UUID.randomUUID() activeAttemptId = UUID.randomUUID()
) )
@@ -132,8 +136,9 @@ class IssueDuePlanWorkerOnDemandTest {
// Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION. // Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION.
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status) assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status)
// Якорь цепочки сдвинут на него, следующий план выдан с новым attempt. // Базу/версию grace НЕ двигает (это делает только LAID_IN) — currentBasePlanId не меняется.
assertEquals(unconfirmed.planId, state.currentBasePlanId) assertEquals(baseId, state.currentBasePlanId)
// Следующий план всё равно выдан с новым attempt (grace перестал блокировать).
assertEquals(2, planStore.size) assertEquals(2, planStore.size)
assertNotNull(state.activeAttemptId) assertNotNull(state.activeAttemptId)
assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime) assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime)
@@ -237,23 +242,23 @@ class IssueDuePlanWorkerOnDemandTest {
} }
@Test @Test
fun `issuePlanNow rejects when spacecraft has an active attempt for another plan`() { fun `issuePlanNow allows a parallel issue while another plan is in flight`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>() 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)) val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
planStore[target.planId] = target val planStore = linkedMapOf(other.planId to other, target.planId to target)
// Активная выдача идёт по ДРУГОМУ плану того же КА — две выдачи разом недопустимы. val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
activePlanId = UUID.randomUUID(),
activeAttemptId = UUID.randomUUID()
)
val worker = worker(planStore, attemptStore, state) 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) // Выдача target стартовала параллельно: новый attempt, target в WAITING_DECISION.
assertTrue(attemptStore.isEmpty()) assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
assertEquals(1, attemptStore.size)
assertEquals(target.planId, state.activePlanId)
assertNotNull(state.activeAttemptId)
} }
@Test @Test
@@ -391,27 +396,22 @@ class IssueDuePlanWorkerOnDemandTest {
} }
@Test @Test
fun `promoteDraftNow rejects with 409 when another plan is being issued`() { fun `promoteDraftNow promotes a correction while the base is still in flight`() {
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>() val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) // База того же окна ещё «в полёте» (WAITING_DECISION) — это больше НЕ блокирует promote
planStore[base.planId] = base // коррекции: оба плана летят параллельно, конкуренция разрешается на закладке (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) val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
planStore[draft.planId] = draft val planStore = linkedMapOf(base.planId to base, draft.planId to draft)
// По КА уже идёт активная выдача другого плана. val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
chainVersion = 0,
activePlanId = UUID.randomUUID(),
activeAttemptId = UUID.randomUUID()
)
// Слот валиден — отказ именно 409 (чужая выдача), а не 422 (слот).
val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) 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) assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status)
assertTrue(attemptStore.isEmpty()) assertEquals(1, attemptStore.size)
assertEquals(draft.planId, state.activePlanId)
assertNotNull(state.activeAttemptId)
} }
@Test @Test
@@ -522,6 +522,12 @@ class IssueDuePlanWorkerOnDemandTest {
store[entity.planId] = entity store[entity.planId] = entity
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 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.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull 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.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.never import org.mockito.Mockito.never
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` 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.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus import org.springframework.transaction.TransactionStatus
@@ -58,7 +61,7 @@ class PlanIssueAndDecisionFlowTest {
} }
@Test @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 spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
@@ -74,13 +77,74 @@ class PlanIssueAndDecisionFlowTest {
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
assertEquals(plan.planId, state.currentBasePlanId) // ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN.
assertNull(state.activePlanId) assertNull(state.currentBasePlanId)
assertNull(state.activeAttemptId) assertEquals(0, state.chainVersion)
assertNull(state.urgentReissueFrom)
verify(slotsUpdateJobService).enqueue(plan.planId) 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 @Test
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() { fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
val spacecraftId = "25544" val spacecraftId = "25544"
@@ -138,21 +202,20 @@ class PlanIssueAndDecisionFlowTest {
} }
@Test @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 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 attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val activeAttemptId = UUID.randomUUID() val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED))
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) assertEquals(PlannedPlanStatus.SUPERSEDED, plan.status)
assertEquals(plan.planId, state.activePlanId)
assertEquals(activeAttemptId, state.activeAttemptId)
assertNull(state.currentBasePlanId) assertNull(state.currentBasePlanId)
verify(slotsUpdateJobService, never()).enqueue(plan.planId) verify(slotsUpdateJobService, never()).enqueue(plan.planId)
} }
@@ -255,6 +318,18 @@ class PlanIssueAndDecisionFlowTest {
store[plan.planId] = plan store[plan.planId] = plan
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 return repository
} }
@@ -106,7 +106,9 @@ class PlanProjectionServiceTest {
} }
@Test @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 base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN)
val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN) val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN)
val calculation = mock(PlanCalculationService::class.java) val calculation = mock(PlanCalculationService::class.java)
@@ -118,8 +120,7 @@ class PlanProjectionServiceTest {
val service = service( val service = service(
state = SpacecraftPlanningStateEntity( state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId, spacecraftId = spacecraftId,
currentBasePlanId = base.planId, currentBasePlanId = base.planId
activePlanId = accepted.planId
), ),
plansById = mapOf(base.planId to base, accepted.planId to accepted), plansById = mapOf(base.planId to base, accepted.planId to accepted),
calculation = calculation calculation = calculation
@@ -127,8 +128,8 @@ class PlanProjectionServiceTest {
service.project(spacecraftId) service.project(spacecraftId)
assertEquals(base.startTime, anchor?.startTime) assertEquals(accepted.startTime, anchor?.startTime)
assertEquals(base.kppId, anchor?.kppId) assertEquals(accepted.kppId, anchor?.kppId)
} }
@Test @Test
@@ -162,6 +163,11 @@ class PlanProjectionServiceTest {
`when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation -> `when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation ->
Optional.ofNullable(plansById[invocation.getArgument(0)]) 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 { } else {
`when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) `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.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -27,16 +25,11 @@ import java.util.UUID
class TestPlanDecisionServiceTest { class TestPlanDecisionServiceTest {
@Test @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 plan = plannedPlan()
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) 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 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") val message = service.generateDecision(plan.planId, PlanDecision.ACCEPTED, "manual accept")
@@ -50,12 +43,10 @@ class TestPlanDecisionServiceTest {
} }
@Test @Test
fun `generate decision rejects plan without active attempt`() { fun `generate decision rejects plan without any attempt`() {
val plan = plannedPlan() val plan = plannedPlan()
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(spacecraftId = plan.spacecraftId)
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java) 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) { val exception = assertThrows(ResponseStatusException::class.java) {
service.generateDecision(plan.planId, PlanDecision.REJECTED, null) service.generateDecision(plan.planId, PlanDecision.REJECTED, null)
@@ -67,22 +58,18 @@ class TestPlanDecisionServiceTest {
private fun service( private fun service(
plan: PlannedPlanEntity, plan: PlannedPlanEntity,
attempt: PlanIssueAttemptEntity, attempt: PlanIssueAttemptEntity?,
state: SpacecraftPlanningStateEntity,
handlePlanDecisionUseCase: HandlePlanDecisionUseCase handlePlanDecisionUseCase: HandlePlanDecisionUseCase
): TestPlanDecisionService { ): TestPlanDecisionService {
val plannedPlanRepository = mock(PlannedPlanRepository::class.java) val plannedPlanRepository = mock(PlannedPlanRepository::class.java)
val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java) val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java)
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
`when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan)) `when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan))
`when`(planIssueAttemptRepository.findById(attempt.attemptId)).thenReturn(Optional.of(attempt)) `when`(planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)).thenReturn(attempt)
`when`(stateRepository.findById(plan.spacecraftId)).thenReturn(Optional.of(state))
return TestPlanDecisionService( return TestPlanDecisionService(
plannedPlanRepository = plannedPlanRepository, plannedPlanRepository = plannedPlanRepository,
planIssueAttemptRepository = planIssueAttemptRepository, planIssueAttemptRepository = planIssueAttemptRepository,
spacecraftPlanningStateJpaRepository = stateRepository,
handlePlanDecisionUseCase = handlePlanDecisionUseCase handlePlanDecisionUseCase = handlePlanDecisionUseCase
) )
} }
@@ -47,6 +47,13 @@ class TguPlanningRestController(
@RequestParam(required = false) reason: String? @RequestParam(required = false) reason: String?
) = tguPlanningService.sendDecision(planId, decision, reason) ) = 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") @GetMapping("/spacecraft/{noradId}/flight-line")
fun spacecraftFlightLine( fun spacecraftFlightLine(
@PathVariable noradId: Long, @PathVariable noradId: Long,
@@ -87,6 +87,26 @@ class TguPlanningService(webClientBuilderProvider: ObjectProvider<WebClient.Buil
throw error 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( private fun buildUri(
path: String, path: String,
customizer: (UriComponentsBuilder) -> Any = { builder -> builder.build() } customizer: (UriComponentsBuilder) -> Any = { builder -> builder.build() }