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