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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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). Базовый план не трогается.
|
||||||
|
|
||||||
## Зарезервированные / не возникающие сейчас статусы
|
## Зарезервированные / не возникающие сейчас статусы
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
|
|
||||||
2. Ошибка при попытке принять план во вкладке планы
|
|
||||||
|
|
||||||
6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
TODO:
|
TODO:
|
||||||
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
|
- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца.
|
||||||
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
|
- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например)
|
||||||
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
|
- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо
|
||||||
|
- Проставлять статусы слотов только после того, как план заложен на КА
|
||||||
|
- Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте.
|
||||||
|
- Ошибка при попытке принять план во вкладке планы
|
||||||
|
- Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu
|
||||||
|
- Отображение предыдущих планов и следующих. Предлагаю не просто сделать отображение как рамку, я делать еле заметные все другие планы, а эти обводить яркими рамками, чтобы было видно какой план прошлый, а какой будущий.
|
||||||
|
Прошлый: #94A3B8 slate-400
|
||||||
|
Текущий: #2563EB blue-600
|
||||||
|
Следующий: #F59E0B amber-500
|
||||||
|
Предлагаю использовать эти цвета
|
||||||
|
- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик
|
||||||
|
- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план.
|
||||||
|
- Деактивировать отмененный план, чтобы он не портил статистику
|
||||||
|
- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы
|
||||||
|
|
||||||
|
|||||||
+3
@@ -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,
|
||||||
|
|
||||||
|
|||||||
+9
@@ -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 {
|
||||||
|
|||||||
+7
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -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.
|
||||||
|
|||||||
+35
-15
@@ -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) {
|
||||||
|
|||||||
+119
-24
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-51
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-18
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-22
@@ -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
|
||||||
|
|
||||||
|
|||||||
+30
-8
@@ -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 =
|
||||||
|
|||||||
+41
-35
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
-12
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-19
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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,
|
||||||
|
|||||||
+20
@@ -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() }
|
||||||
|
|||||||
Reference in New Issue
Block a user