From c76a823dbc4f12b76403de4ea1cd02e2103fa63e 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: Tue, 2 Jun 2026 14:32:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9?= =?UTF-8?q?=D0=BD=20pcp-tgu-service:=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5,=20=D0=B1=D1=83?= =?UTF-8?q?=D0=B4=D1=83=D1=89=D0=B5=D0=B5=20=D1=81=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=B0=20=D0=BB=D0=B5=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше вся будущая цепочка планов материализовалась в planned_plan как PLANNED-строки, а отдельный воркер пересчёта (RebuildPlanScheduleUseCase + PlanRecalculationJob) держал её в актуальном состоянии и гасил выпавшие планы в SUPERSEDED/EXPIRED. Это плодило вложенные планы, гонки пересчёта и мусор в БД. Новая модель: в БД живёт только выданное (история навсегда), будущее — проекция, вычисляемая по запросу из ЗРВ. Перекрывать (supersede) и протухать (expire) больше нечего. Бэкенд: - Удалены TrackedPlan (entity/source/status + repo) и PlanRecalculationJob (entity/repo/service/worker) — материализация и воркер пересчёта больше не нужны. - Удалён RebuildPlanScheduleUseCase; будущее строит новый PlanProjectionService поверх PlanCalculationService. - PlanQueryService отдаёт проекцию (выданное из БД + будущее на лету). - Новый флоу реакции на смену параметров станции: StationParamsChangedConsumer + HandleStationParamsChangedUseCase (срочный перевыпуск через planning_state). - Новые консьюмеры обратной связи исполнения: LayinFailedConsumer, PlanNotAcceptedConsumer (+ DTO PlanExecutionFeedbackMessage, StationParamsChangedMessage). - PlannedPlanStatus: убраны мёртвые SUPERSEDED/EXPIRED; статусы выровнены под жизненный цикл выдачи/закладки. Миграции (ddl-auto: validate): - V7 drop tracked_plan - V8 drop plan_recalculation_job - V9 planning_state: поля срочного перевыпуска - V10 снять unique-ограничение бизнес-ключа planned_plan - V11 удалить легаси PLANNED-строки (материализованное будущее) - V12 удалить легаси SUPERSEDED/EXPIRED и столбец superseded_by_plan_id Тесты: удалены JobRerunFlowTest и RebuildPlanScheduleUseCaseTest; добавлены PlanProjectionServiceTest, PlanQueryServiceTest, IssueDuePlanWorkerOnDemandTest, HandleStationParamsChangedUseCaseTest, VisibilityPayloadParserTest. Скорректированы PlanIssueAndDecisionFlowTest, PlanCalculationServiceTest, VisibilityFlowTest и др. V11/V12 необратимы — перед миграцией на проде снять дамп planned_plan. --- config-repo/pcp-tgu-service.yaml | 10 +- .../tasks/TASK_tgu-plan-on-demand-redesign.md | 297 +++++++++++++++++ .../config/PlanningProperties.kt | 3 + .../config/TopicsProperties.kt | 11 +- .../controller/PlanController.kt | 8 +- .../controller/TestTguController.kt | 17 +- .../dto/PlanExecutionFeedbackMessage.kt | 27 ++ .../pcp_tgu_service/dto/PlanResponse.kt | 23 ++ .../dto/StationParamsChangedMessage.kt | 24 ++ .../entity/PlanRecalculationJobEntity.kt | 48 --- .../entity/PlannedPlanEntity.kt | 3 - .../entity/PlannedPlanStatus.kt | 16 +- .../entity/SpacecraftPlanningStateEntity.kt | 7 + .../entity/TrackedPlanEntity.kt | 63 ---- .../entity/TrackedPlanSource.kt | 7 - .../entity/TrackedPlanStatus.kt | 9 - .../api/VisibilityPayloadParser.kt | 12 +- .../integration/kafka/LayinFailedConsumer.kt | 24 ++ .../kafka/PlanNotAcceptedConsumer.kt | 24 ++ .../kafka/StationParamsChangedConsumer.kt | 24 ++ .../PlanRecalculationJobRepository.kt | 85 ----- .../repository/PlannedPlanRepository.kt | 138 +++----- .../repository/TrackedPlanJpaRepository.kt | 46 --- .../service/HandlePlanDecisionUseCase.kt | 117 ++++--- .../HandleStationParamsChangedUseCase.kt | 46 +++ .../service/IssueDuePlanWorker.kt | 129 ++++++-- .../service/PlanCalculationService.kt | 26 +- .../service/PlanProjectionService.kt | 51 +++ .../service/PlanQueryService.kt | 52 ++- .../service/PlanRecalculationJobService.kt | 20 -- .../service/PlanRecalculationWorker.kt | 85 ----- .../service/RebuildPlanScheduleUseCase.kt | 113 ------- .../SpacecraftVisibilitySnapshotService.kt | 13 +- .../src/main/resources/application.yml | 10 +- ..._drop_planned_plan_business_key_unique.sql | 5 + .../V11__purge_legacy_planned_rows.sql | 11 + .../V12__drop_legacy_superseded_expired.sql | 19 ++ .../db/migration/V7__drop_tracked_plan.sql | 2 + .../V8__drop_plan_recalculation_job.sql | 2 + .../V9__planning_state_urgent_reissue.sql | 4 + .../api/ExternalPointsClientTest.kt | 1 + .../api/VisibilityPayloadParserTest.kt | 36 +++ .../AcceptedPlanSlotsUpdateFlowTest.kt | 2 +- .../HandleStationParamsChangedUseCaseTest.kt | 52 +++ .../service/IssueDuePlanWorkerOnDemandTest.kt | 220 +++++++++++++ .../service/JobRerunFlowTest.kt | 301 ------------------ .../service/PlanCalculationServiceTest.kt | 32 ++ .../service/PlanIssueAndDecisionFlowTest.kt | 295 ++++++----------- .../service/PlanProjectionServiceTest.kt | 115 +++++++ .../service/PlanQueryServiceTest.kt | 78 +++++ .../service/RebuildPlanScheduleUseCaseTest.kt | 188 ----------- .../service/VisibilityFlowTest.kt | 13 +- 52 files changed, 1587 insertions(+), 1377 deletions(-) create mode 100644 docs/tasks/TASK_tgu-plan-on-demand-redesign.md create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt delete mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt delete mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 6c3cc58..0df08a4 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -31,19 +31,23 @@ springdoc: planning: notification-before-start-minutes: 263 - target-plan-duration-minutes: 180 - target-plan-duration-delta-minutes: 60 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 external-update-enabled: true - external-points-lookahead-days: 3 + external-points-lookahead-days: 10 in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 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 + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 + station-params-changed: pcp.tgu.station-params-changed.v1 tgu: test-controller: diff --git a/docs/tasks/TASK_tgu-plan-on-demand-redesign.md b/docs/tasks/TASK_tgu-plan-on-demand-redesign.md new file mode 100644 index 0000000..88e1713 --- /dev/null +++ b/docs/tasks/TASK_tgu-plan-on-demand-redesign.md @@ -0,0 +1,297 @@ +# Задача: pcp-tgu-service — «храним только выданное» + закладочный флоу + +> ТЗ для кодинг-агента и для приёмки. Раздел 0 — промпт запуска. +> Разделы 1–9 — что и как делаем. Раздел 10 — **критерии приёмки** (по ним проверяем результат). +> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. + +--- + +## 0. Промпт для запуска агента (copy-paste) + +``` +Ты работаешь в монорепозитории observatio-terrae (pcp), модуль services/pcp-tgu-service (Kotlin/Spring Boot). + +Задача: перестроить хранение и выдачу планов ТГУ — перестать материализовать +будущую цепочку планов в БД (хранить только выданные планы и их историю — вечно), +считать будущее на лету как проекцию, и добавить закладочный флоу с реакцией +на два Kafka-события «не заложен» и «не принят». + +Перед началом обязательно прочитай: +1. /AGENTS.md — правила работы, стиль, формат финального ответа. +2. /docs/tasks/TASK_tgu-plan-on-demand-redesign.md — это ТЗ. +3. Текущий код: services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/ + (service/, entity/, repository/, controller/, integration/kafka/, config/). + +Делай строго по фазам раздела 5. Всё — в одной ветке/одном PR. +Ядро расчёта цепочки (PlanCalculationService) НЕ меняем — оно уже монотонно-корректно. +После работы прогоняй ./gradlew :services:pcp-tgu-service:test из корня монорепо. +В конце дай отчёт в формате из AGENTS.md и пройдись по разделу 10 (критерии приёмки). +``` + +--- + +## 1. Контекст и цель + +`pcp-tgu-service` (порт `:7011`) — роллинг-планировщик сеансов управления по каждому операционному КА. Поток: список КА из НСИ → окна видимости (ЗРВ) из баллистики → расчёт цепочки планов (~`target` 1440±240 мин, привязка 1:1 к ЗРВ, строгая монотонность) → выдача дозревшего плана через короткий BPMN → решение из Kafka. UI (через агрегатор `:7008`) рисует таймлайн планов. + +**Проблема сейчас.** Таблица `planned_plan` материализует **всю будущую цепочку** как `PLANNED`-строки и переписывает её на каждое изменение хэша ЗРВ. Это копит «лишнее»: пачки `SUPERSEDED`/`EXPIRED` на каждый пересчёт, рост без границ, отдельная машинерия `plan_recalculation_job`, и целый класс багов рассинхрона цепочки со снапшотом («схлопывание в SUPERSEDED», «зомби-PLANNED»). При этом будущая цепочка — **детерминированная проекция** от (снапшот ЗРВ + базовый план + конфиг). + +**Цель.** +1. Хранить в `planned_plan` **только планы, перешедшие в выдачу**, и их историю — **вечно** (ретеншн не делаем). +2. Будущее (не выданное) — **не хранить вовсе**, считать на лету как проекцию (в т.ч. для UI). +3. Добавить **закладочный флоу**: после `ACCEPTED` план оптимистично считается заложенным; реагируем только на явный негатив — два раздельных Kafka-события «не заложен» и «не принят», оба пока ведут себя одинаково (срочно выдать план на ближайшую ЗРВ), но разнесены для будущего расхождения. +4. Реагировать на **изменение параметров станций** (Kafka-событие): станция могла выйти из строя — нужно заново запросить ЗРВ у баллистики по затронутым КА и пересобрать цепочку. В on-demand-модели это сводится к форсу `visibility_refresh_job` → новый снапшот → проекция пересчитается сама. +5. Снести мёртвый код (`tracked_plan`) и ставшую ненужной машинерию пересчёта (`plan_recalculation_job`). + +Масштаб: **100–150 КА**, горизонт планирования **до 10 суток**, деплой **2–3 реплики** (горизонтальное масштабирование уже обеспечено координацией через БД: `FOR UPDATE SKIP LOCKED` + pessimistic-lock на КА). + +--- + +## 2. Границы задачи (scope) + +**В scope:** +- Перестроение хранения/выдачи планов в `pcp-tgu-service` (backend, Kotlin). +- Новый сервис проекции; переписанный воркер выдачи; read-эндпоинт, отдающий persisted ∪ проекцию. +- Новые статусы плана и закладочный флоу с двумя новыми Kafka-консьюмерами. +- Удаление мёртвого кода и снятие `plan_recalculation_job`. +- Миграции БД, изменения конфига (`application.yml` + `config-repo/pcp-tgu-service.yaml`). +- Юнит-тесты на новую логику. + +**Вне scope (без отдельного запроса):** +- Фронтенд `pcp-tgu-ops-ui` и агрегатор `pcp-ui-service` — контракт `/api/plans` сохраняем неизменным, фронт не трогаем. +- Изменение ядра расчёта `PlanCalculationService` (кроме переиспользования; алгоритм не меняем). +- BPMN-схемы (`issueSatellitePlan`, `updateAcceptedPlanSlots`) — не меняем. +- Источник/продюсер новых Kafka-событий «не заложен»/«не принят» — мы только консьюмеры; контракт ниже фиксируем. +- Ретеншн/архивация — НЕ делаем (выданное храним вечно). + +--- + +## 3. Текущая архитектура (точки изменения) + +- [PlannedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt) / `planned_plan` — хранит всю цепочку, upsert по бизнес-ключу `(spacecraft, start, end, kpp)`. +- [RebuildPlanScheduleUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt) — пересобирает цепочку: `calculatePlans/After` → upsert → `supersedeObsoleteFuturePlans` → `expireMissedPlans`. +- [PlanCalculationService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt) — **ядро, не трогаем**. +- [SpacecraftVisibilitySnapshotService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt) — на изменение хэша ставит `plan_recalculation_job`. +- [PlanRecalculationWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt) / `PlanRecalculationJobService` / `plan_recalculation_job` — **снимаем целиком**. +- [IssueDuePlanWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) — сейчас сканирует `PLANNED` (`findDueForUpdate`). Переписываем на on-demand. +- [HandlePlanDecisionUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt) — обработка `ACCEPTED/REJECTED`; `promoteNextPlanOrEnqueueRebuild` завязан на хранимые `PLANNED` → переписываем. +- [PlanQueryService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) / [PlanController](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) — read-путь, дополняем проекцией. +- Мёртвый код: [TrackedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt), `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository` — на репозиторий нет ссылок. + +--- + +## 4. Целевая архитектура + +- `planned_plan` пополняется строкой **только в момент выдачи** (статус `ISSUING`). Дальше строка живёт по жизненному циклу выдачи и **хранится вечно**. +- **Будущее не хранится.** `PlanProjectionService.project(spacecraftId)` считает цепочку на лету из последнего снапшота ЗРВ + текущей базы + конфига, переиспользуя `PlanCalculationService`. +- **Выдача** ([IssueDuePlanWorker]) идёт по активным КА, считает голову проекции; если голова «дозрела» (`issue_not_before ≤ now`) или взведён `urgent_reissue_from` — материализует **ровно эту голову** в `ISSUING` и выдаёт. Никаких `PLANNED`-строк в БД. +- **Read** ([PlanQueryService]) отдаёт `persisted (вся история) ∪ проекция будущего` (виртуальный `PLANNED`, в БД не пишется), дедуп по бизнес-ключу. Контракт `/api/plans` неизменен. +- **Закладочный флоу**: `ACCEPTED` → `AWAITING_LAYIN` (оптимистично = база, цепочка идёт дальше). Два новых события гасят план терминально (`LAYIN_FAILED` / `NOT_ACCEPTED`) и взводят срочную перевыдачу на ближайшую ЗРВ с лидом `now + reissue-prep-minutes`. + +--- + +## 5. Фазы работ + +### Фаза 0. Чистка мёртвого кода +- Удалить `TrackedPlanEntity`, `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository`. +- Миграция `V7__drop_tracked_plan.sql`: `DROP TABLE IF EXISTS tracked_plan;` +- Убедиться, что компиляция/тесты не ссылались на эти классы. + +### Фаза 1. On-demand проекция, конец материализации цепочки +- Новый `PlanProjectionService` с `project(spacecraftId): List` и `projectHead(spacecraftId): CalculatedPlan?`: + читает `spacecraft_visibility_snapshot`, текущую базу (`spacecraft_planning_state.currentBasePlanId`), считает цепочку через `PlanCalculationService` (`calculatePlans`/`calculatePlansAfter`). Опционально — in-memory кэш по `payload_hash` (если делаешь — инвалидируй при смене снапшота/базы; при 150 КА можно и без кэша). +- Переписать `IssueDuePlanWorker` на on-demand: идём по активным КА (`spacecraft_snapshot.active = true` / `spacecraft_planning_state`), для каждого через `PlanProjectionService.projectHead` берём ближайший план; если у КА нет активной попытки (`activeAttemptId == null`) и (`issue_not_before(head) ≤ now` **или** взведён `urgent_reissue_from`) — материализуем голову в `planned_plan` со статусом `ISSUING` и стартуем выдачу. Сохраняем claim-семантику с lock на КА. +- `RebuildPlanScheduleUseCase`: убрать материализацию (`upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans`). Логику расчёта забирает `PlanProjectionService`; сам use case либо удаляется, либо превращается в тонкую обёртку над проекцией (если на него ссылается `TestTguController` — переориентировать/упростить). +- **Снять машинерию пересчёта**: удалить `PlanRecalculationWorker`, `PlanRecalculationJobService`, `PlanRecalculationJobEntity`, `PlanRecalculationJobRepository`; убрать `enqueue(...)` из `SpacecraftVisibilitySnapshotService` (hash-проверку оставить — она просто перестаёт что-либо ставить). Миграция `V8__drop_plan_recalculation_job.sql`: `DROP TABLE IF EXISTS plan_recalculation_job;` +- `PlannedPlanRepository`: удалить ставшие мёртвыми `upsertPlannedByBusinessKey`, `findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime`, `findAllBySpacecraftIdAndStatusAndStartTimeLessThanEqualOrderByStartTime`, `findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime`. Оставить/добавить: `lockByPlanId`, выборка истории по КА и по всем для read, защита от дублей выдачи. + +### Фаза 2. Read-проекция для UI +- `PlanQueryService.getAllPlans/getPlansBySpacecraftId`: вернуть `persisted ∪ projection`. Persisted — все строки `planned_plan` (история вечна; `historyDays` оставить как фильтр **отображения** левого края, БД не режем). Projection — `PlanProjectionService.project(...)` по активным КА, виртуальный статус `PLANNED`, дедуп: проекционный план не добавляем, если его бизнес-ключ уже есть среди persisted. +- `PlanResponse`: научить представлять виртуальные планы (детерминированный `planId` из бизнес-ключа, либо nullable — на усмотрение, но контракт JSON для фронта не ломать; `status = PLANNED`). Добавить фабрику `from(CalculatedPlan, status)`. + +### Фаза 3. Статусы и закладочный флоу +- `PlannedPlanStatus`: добавить `AWAITING_LAYIN`, `LAID_IN`, `LAYIN_FAILED`, `NOT_ACCEPTED`. `PLANNED` оставить только как виртуальный для проекции (в БД не писать). +- `HandlePlanDecisionUseCase` (топик `satellite-plan-decision`): + - `ACCEPTED` → план `ACCEPTED` затем `AWAITING_LAYIN`, `currentBasePlanId = plan`, проекция продолжается; `acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)` (как есть). + - `REJECTED` → план терминально `REJECTED` → общий `reissueOnNearestWindow(...)`. + - Удалить `promoteNextPlanOrEnqueueRebuild` (он завязан на хранимые `PLANNED`). +- Новые события (раздельные консьюмеры, общее поведение): + - `TopicsProperties`: добавить `layinFailed` и `planNotAccepted`. + - DTO `PlanExecutionFeedbackMessage(eventId, spacecraftId, planId, occurredAt, reason)` — пока общий на оба события. + - Консьюмеры `LayinFailedConsumer`, `PlanNotAcceptedConsumer` (groupId `${spring.kafka.consumer.group-id}`). + - В use case — два метода `handleLayinFailed(msg)` / `handleNotAccepted(msg)`, **оба идемпотентны** по `eventId` через `processed_plan_decision_event`, оба зовут общий `reissueOnNearestWindow(...)`; статус помечаемого плана соответственно `LAYIN_FAILED` / `NOT_ACCEPTED`. +- `reissueOnNearestWindow(spacecraftId, failedPlanId, now)` (общий для REJECTED-после-cutoff / LAYIN_FAILED / NOT_ACCEPTED): + - под lock на КА: помечает план терминальным статусом (история — навечно), снимает его с роли базы (`currentBasePlanId` ← предыдущая валидная база или `null`), чистит `activePlanId/activeAttemptId`; + - ставит `spacecraft_planning_state.urgent_reissue_from = now + reissue-prep-minutes`. +- `IssueDuePlanWorker` urgent-path: если у КА `urgent_reissue_from != null` — голову проекции берём как ближайшую ЗРВ со `startTime ≥ urgent_reissue_from`, материализуем и выдаём немедленно (не ждём `issue_not_before`), после успешного старта `urgent_reissue_from = null`. +- Миграция `V9__planning_state_urgent_reissue.sql`: `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` + +### Фаза 3b. Событие «изменились параметры станций» +- `TopicsProperties` + конфиги: `topics.station-params-changed: pcp.tgu.station-params-changed.v1`. +- DTO `StationParamsChangedMessage(eventId, stationId?, occurredAt, reason?)`. +- Консьюмер `StationParamsChangedConsumer` (groupId `${spring.kafka.consumer.group-id}`). +- Use case `handleStationParamsChanged(msg)`: **идемпотентен** по `eventId` (общий ledger `processed_plan_decision_event`); ставит `visibility_refresh_job` (`VisibilityRefreshJobService.enqueue`, reason `STATION_PARAMS_CHANGED`) по **всем активным КА** (`spacecraft_snapshot.active = true`). Дальше [VisibilityRefreshWorker] перечитывает ЗРВ у баллистики → новый снапшот → on-demand проекция пересобирает цепочку автоматически. +- `stationId` в событии пока **не используется для сужения** (карты «станция → КА» в tgu нет; баллистика считает ЗРВ по-КА). Поле оставляем на будущее для точечного refresh; сейчас поведение — blanket refresh всех активных КА (при 150 КА дёшево). +- Уже выданные/принятые планы (`ISSUING`/`AWAITING_LAYIN`) ретроспективно не меняются — пересобирается только будущая часть цепочки (после базы). Если станция текущего плана отказала по сути — это придёт отдельным негативным событием (`layin-failed`/`not-accepted`). + +### Фаза 4. Конфиг +- `PlanningProperties` + `application.yml` + `config-repo/pcp-tgu-service.yaml`: + - `external-points-lookahead-days: 3 → 10`; + - новый `reissue-prep-minutes: 30`; + - `min-reissue-lead-minutes` — оставить (REJECTED-до-cutoff). +- `TopicsProperties` + конфиги: `topics.layin-failed: pcp.tgu.layin-failed.v1`, `topics.plan-not-accepted: pcp.tgu.plan-not-accepted.v1`, `topics.station-params-changed: pcp.tgu.station-params-changed.v1` (с возможностью переопределить). + +### Фаза 5. Тесты +- Сохранить `PlanCalculationServiceTest` (ядро не тронуто). +- Новые: `PlanProjectionServiceTest`, `IssueDuePlanWorker` (on-demand голова + urgent-path), `HandlePlanDecisionUseCase` (`ACCEPTED→AWAITING_LAYIN`; `REJECTED→reissue`), консьюмеры `layin-failed`/`not-accepted` (идемпотентность, раздельность, общий reissue), консьюмер `station-params-changed` (идемпотентность + постановка refresh по всем активным КА), read-проекция (`persisted ∪ projection` + дедуп). +- Удалить тесты снятой машинерии (recalc worker, supersede/expire); поправить `RebuildPlanScheduleUseCaseTest`. + +--- + +## 6. Модель данных и миграции + +| Миграция | Действие | +|---|---| +| `V7__drop_tracked_plan.sql` | `DROP TABLE IF EXISTS tracked_plan;` | +| `V8__drop_plan_recalculation_job.sql` | `DROP TABLE IF EXISTS plan_recalculation_job;` | +| `V9__planning_state_urgent_reissue.sql` | `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` | + +`planned_plan` — схему не меняем (новые значения статуса — это `VARCHAR`, миграция не нужна). Индексы оставляем. `ddl-auto: validate` — значит сущности должны точно соответствовать таблицам после миграций. + +--- + +## 7. Конфиг (итоговые значения) + +```yaml +planning: + notification-before-start-minutes: 263 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 + external-update-enabled: true + external-points-lookahead-days: 10 # было 3 + in-memory-history-days: 14 + external-update-fixed-delay-ms: 600000 + notification-check-fixed-delay-ms: 30000 + min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 # новый + worker-lock-ttl-seconds: 300 + +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 # новый + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 # новый + station-params-changed: pcp.tgu.station-params-changed.v1 # новый +``` + +--- + +## 8. Контракты Kafka (входящие, мы — консьюмер) + +Существующий `satellite-plan-decision` (`PlanDecisionMessage`) — без изменений. + +Новые топики, общий DTO `PlanExecutionFeedbackMessage`: + +```json +{ + "eventId": "uuid", // dedup-ключ (идемпотентность) + "spacecraftId": "string", + "planId": "uuid", // план, по которому пришёл негатив (текущая база) + "occurredAt": "2026-06-02T10:00:00", + "reason": "string|null" +} +``` + +- `pcp.tgu.layin-failed.v1` — «не заложен». +- `pcp.tgu.plan-not-accepted.v1` — «не принят». +- Поведение пока идентично; обработчики раздельные. + +Новый топик `pcp.tgu.station-params-changed.v1`, DTO `StationParamsChangedMessage`: + +```json +{ + "eventId": "uuid", // dedup-ключ (идемпотентность) + "stationId": "string|null", // какая станция изменилась; пока не используется для сужения + "occurredAt": "2026-06-02T10:00:00", + "reason": "string|null" +} +``` + +- При получении — поставить `visibility_refresh_job` по всем активным КА (перезапрос ЗРВ у баллистики), цепочка пересоберётся через on-demand проекцию. + +--- + +## 9. Допущения и риски + +- **Источник «ближайшей ЗРВ»** для срочной перевыдачи — тот же последний снапшот ЗРВ; если в нём нет окна со `startTime ≥ urgent_reissue_from`, голова пуста — КА ждёт следующего refresh окон (это корректное поведение, не ошибка). +- **Идемпотентность** новых событий — обязательна (at-least-once Kafka), через общий `processed_plan_decision_event`. +- **`ddl-auto: validate`**: после правок прогнать миграции на чистой БД и убедиться, что Hibernate-валидация проходит. +- **Совместимость UI**: JSON-форма `/api/plans` не меняется; виртуальные планы отдаются с тем же набором полей и `status: "PLANNED"`. +- **Сборка ресурсов**: при правке `application.yml` помнить про `./gradlew :services:pcp-tgu-service:processResources` (IntelliJ-рестарт не всегда пересобирает ресурсы). + +--- + +## 10. Критерии приёмки (по ним проверяем) + +### 10.1. Хранение «только выданное» +- [ ] При штатной работе (КА с окнами ЗРВ, ни одного дозревшего плана) в `planned_plan` **нет ни одной строки** для этого КА. Будущее видно только через `/api/plans` как `PLANNED`. +- [ ] Строка в `planned_plan` появляется **ровно в момент выдачи** и сразу в статусе `ISSUING` (не `PLANNED`). +- [ ] Статус `PLANNED` **никогда не пишется** в БД (проверяемо: `SELECT count(*) FROM planned_plan WHERE status='PLANNED'` = 0 в любой момент). +- [ ] Выданные планы и их история **не удаляются** (ретеншн-джоба нет; нет `DELETE`/`SUPERSEDED`-массовой чистки будущего). +- [ ] В коде нет материализации цепочки: `upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans` удалены. + +### 10.2. Проекция будущего +- [ ] `GET /api/plans` и `GET /api/plans/{spacecraftId}` возвращают persisted-планы **плюс** вычисленную проекцию будущего; JSON-контракт прежний, фронт работает без изменений. +- [ ] Проекция совпадает по содержанию с тем, что раньше материализовалось (та же монотонная цепочка из `PlanCalculationService`): для одних и тех же окон ЗРВ набор `(start,end,kpp)` будущих планов идентичен прежнему поведению. +- [ ] Нет дублей: если бизнес-ключ есть в persisted, проекция его не добавляет повторно. +- [ ] Левый край истории по-прежнему регулируется `historyDays`. + +### 10.3. Снятие машинерии и мёртвого кода +- [ ] Таблиц `tracked_plan` и `plan_recalculation_job` нет (миграции `DROP` применены); связанные сущности/репозитории/воркеры/сервисы удалены. +- [ ] `SpacecraftVisibilitySnapshotService` больше не ставит recalc-job; сохранение снапшота и hash-проверка работают. +- [ ] Проект компилируется без ссылок на удалённые классы; неиспользуемые методы репозитория убраны. + +### 10.4. Закладочный флоу (оптимизм + реакция на негатив) +- [ ] `ACCEPTED` → план переходит в `AWAITING_LAYIN`, становится базой; цепочка продолжается; `updateAcceptedPlanSlots`-job ставится. Никакого ожидания подтверждения закладки в виде доп. строк. +- [ ] Отсутствие любых событий после `ACCEPTED` = «заложен» (план остаётся базой, ничего не делаем). +- [ ] Событие `pcp.tgu.layin-failed.v1` по базовому плану → план в `LAYIN_FAILED`, взводится `urgent_reissue_from = now + reissue-prep-minutes`, и ближайшая ЗРВ ≥ этого времени **выдаётся немедленно** (не дожидаясь `issue_not_before`). +- [ ] Событие `pcp.tgu.plan-not-accepted.v1` → то же поведение, но статус `NOT_ACCEPTED`; обработчик **отдельный** (разные методы/консьюмеры), общее ядро `reissueOnNearestWindow`. +- [ ] Оба события **идемпотентны**: повтор того же `eventId` — no-op (повторной перевыдачи нет). +- [ ] `reissue-prep-minutes` берётся из конфига (по умолчанию 30); изменение значения меняет лид срочной выдачи. +- [ ] Если в снапшоте нет ЗРВ ≥ `urgent_reissue_from` — выдачи нет, флаг сохраняется, повторная попытка произойдёт после обновления окон (без ошибок/исключений в логе). + +### 10.4b. Реакция на изменение параметров станций +- [ ] Событие `pcp.tgu.station-params-changed.v1` ставит `visibility_refresh_job` по всем активным КА (reason `STATION_PARAMS_CHANGED`). +- [ ] После перезапроса ЗРВ снапшоты обновляются, и `/api/plans` отдаёт цепочку, пересобранную по новым окнам (без перезапуска сервиса и без ручного rebuild). +- [ ] Событие идемпотентно по `eventId` (повтор — без повторной постановки refresh). +- [ ] Уже выданные/принятые планы ретроспективно не меняются; пересобирается только будущее. + +### 10.5. Масштабирование / корректность под несколькими подами +- [ ] Claim во всех воркерах — `FOR UPDATE SKIP LOCKED`; выдача плана и обработка решения — под lock на КА (один план не выдаётся дважды при 2–3 подах). +- [ ] У одного КА одновременно не более одной активной попытки выдачи (`activeAttemptId`). +- [ ] Сервис стартует и работает с `external-points-lookahead-days: 10`. + +### 10.6. Конфиг и миграции +- [ ] `application.yml` и `config-repo/pcp-tgu-service.yaml` содержат итоговые значения раздела 7 (lookahead 10, `reissue-prep-minutes: 30`, два новых топика). +- [ ] `ddl-auto: validate` проходит на чистой БД после применения всех миграций (V7–V9). + +### 10.7. Тесты и сборка +- [ ] `./gradlew :services:pcp-tgu-service:test` — зелёный. +- [ ] Есть тесты на: проекцию, on-demand выдачу, urgent-path, `ACCEPTED→AWAITING_LAYIN`, оба новых консьюмера (идемпотентность + раздельность + общий reissue), read-объединение с дедупом. +- [ ] Старые тесты снятой машинерии удалены, оставшиеся — зелёные. + +--- + +## 11. Команды проверки + +```bash +# из корня монорепо +./gradlew :services:pcp-tgu-service:test +./gradlew :services:pcp-tgu-service:processResources # после правок application.yml + +# ручная проверка флоу (нужен tgu.test-controller.enabled=true локально): +# - запустить сервис на :7011, дождаться окон ЗРВ +# - GET http://localhost:7011/api/plans/{spacecraftId} -> в ответе PLANNED-проекция, в БД строк нет +# - сэмулировать ACCEPTED -> в БД ACCEPTED/AWAITING_LAYIN, проекция продолжается +# - отправить layin-failed/not-accepted -> терминальный статус + срочная выдача ближайшей ЗРВ +``` diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index 45a22d7..bd8241f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -35,6 +35,9 @@ data class PlanningProperties( /** Минимальный запас времени для повторной выдачи после REJECTED. */ val minReissueLeadMinutes: Long, + /** Минимальное время на подготовку срочно перевыдаваемого плана (после негатива), в минутах. */ + val reissuePrepMinutes: Long, + /** TTL lease'а worker job claim в секундах. */ val workerLockTtlSeconds: Long ) 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 f6d38db..d96d6bf 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 @@ -9,5 +9,14 @@ data class TopicsProperties( val visibilityWindowsChanged: String, /** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */ - val satellitePlanDecision: String + val satellitePlanDecision: String, + + /** Topic-событие «не заложен» по принятому плану. */ + val layinFailed: String, + + /** Topic-событие «не принят» по принятому плану. */ + val planNotAccepted: String, + + /** Topic-событие об изменении параметров станций (перезапрос ЗРВ). */ + val stationParamsChanged: String ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index e199783..96da5e6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -17,20 +17,20 @@ class PlanController( private val planQueryService: PlanQueryService ) { - /** Возвращает планы по всем КА с указанным окном подтверждённой истории. */ + /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @GetMapping fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono> = Mono.fromCallable { - planQueryService.getAllPlans(historyDays).map(PlanResponse::from) + planQueryService.getAllPlans(historyDays) }.subscribeOn(Schedulers.boundedElastic()) - /** Возвращает цепочку планов для одного КА. */ + /** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */ @GetMapping("/{spacecraftId}") fun getPlansBySpacecraftId( @PathVariable spacecraftId: String, @RequestParam(defaultValue = "7") historyDays: Long ): Mono> = Mono.fromCallable { - planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from) + planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) }.subscribeOn(Schedulers.boundedElastic()) } 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 03d105c..7f63d1f 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 @@ -18,10 +18,9 @@ import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker +import space.nstart.pcp_tgu_service.service.PlanProjectionService import space.nstart.pcp_tgu_service.service.PlanQueryService -import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService import space.nstart.pcp_tgu_service.service.PlatformService -import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase import space.nstart.pcp_tgu_service.service.TestPlanDecisionService import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService import java.nio.file.Files @@ -37,8 +36,7 @@ import java.util.UUID @RequestMapping("/test/tgu") class TestTguController( private val visibilityRefreshJobService: VisibilityRefreshJobService, - private val planRecalculationJobService: PlanRecalculationJobService, - private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, + private val planProjectionService: PlanProjectionService, private val issueDuePlanWorker: IssueDuePlanWorker, private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase, private val testPlanDecisionService: TestPlanDecisionService, @@ -56,12 +54,11 @@ class TestTguController( visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") }.subscribeOn(Schedulers.boundedElastic()) - /** Ставит и сразу выполняет rebuild persisted schedule для КА. */ - @PostMapping("/spacecraft/{spacecraftId}/rebuild-plans") - fun rebuildPlans(@PathVariable spacecraftId: String): Mono = - Mono.fromRunnable { - planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER") - rebuildPlanScheduleUseCase.rebuild(spacecraftId) + /** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */ + @GetMapping("/spacecraft/{spacecraftId}/projection") + fun getProjection(@PathVariable spacecraftId: String): Mono> = + Mono.fromCallable { + planProjectionService.project(spacecraftId).map(PlanResponse::projected) }.subscribeOn(Schedulers.boundedElastic()) /** Принудительно выдаёт конкретный persisted plan через новую attempt. */ diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt new file mode 100644 index 0000000..0ced40f --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие негативной обратной связи по выданному (принятому) плану. + * + * Используется двумя топиками — «не заложен» и «не принят». Сейчас форма общая; + * при будущем расхождении поведения её можно разделить. + */ +data class PlanExecutionFeedbackMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Идентификатор КА внутри TGU. */ + val spacecraftId: String, + + /** Stable persisted planId плана, по которому пришёл негатив (текущий базовый план). */ + val planId: UUID, + + /** Время события во внешней системе. */ + val occurredAt: LocalDateTime, + + /** Причина/комментарий. */ + val reason: String? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index e705e51..9c4bacc 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -1,7 +1,9 @@ package space.nstart.pcp_tgu_service.dto +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.nio.charset.StandardCharsets import java.time.LocalDateTime import java.util.UUID @@ -43,5 +45,26 @@ data class PlanResponse( kppId = plan.kppId, status = plan.status ) + + /** + * Виртуальный (не сохранённый) план будущей проекции со статусом PLANNED. + * + * planId детерминированно выводится из бизнес-ключа, чтобы один и тот же расчётный план + * стабильно опознавался фронтом между запросами. + */ + fun projected(plan: CalculatedPlan): PlanResponse = + PlanResponse( + planId = deterministicPlanId(plan), + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + status = PlannedPlanStatus.PLANNED + ) + + private fun deterministicPlanId(plan: CalculatedPlan): UUID { + val businessKey = "${plan.spacecraftId}|${plan.startTime}|${plan.endTime}|${plan.kppId}" + return UUID.nameUUIDFromBytes(businessKey.toByteArray(StandardCharsets.UTF_8)) + } } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt new file mode 100644 index 0000000..55f48f2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие об изменении параметров станций (например, станция вышла из строя). + * + * По нему перезапрашиваются ЗРВ у баллистики по всем активным КА, и цепочка пересобирается + * через on-demand проекцию. + */ +data class StationParamsChangedMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Какая станция изменилась; пока не используется для сужения набора КА. */ + val stationId: String?, + + /** Время события во внешней системе. */ + val occurredAt: LocalDateTime, + + /** Причина/комментарий. */ + val reason: String? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt deleted file mode 100644 index 49b2918..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt +++ /dev/null @@ -1,48 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime - -/** - * Job на перестроение persisted расписания планов одного КА. - */ -@Entity -@Table(name = "plan_recalculation_job") -class PlanRecalculationJobEntity( - @Id - @Column(name = "spacecraft_id", nullable = false, updatable = false) - var spacecraftId: String, - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: JobStatus = JobStatus.PENDING, - - @Column(name = "reason") - var reason: String? = null, - - @Column(name = "locked_by") - var lockedBy: String? = null, - - @Column(name = "locked_until") - var lockedUntil: LocalDateTime? = null, - - @Column(name = "attempts", nullable = false) - var attempts: Int = 0, - - @Column(name = "rerun_requested", nullable = false) - var rerunRequested: Boolean = false, - - @Column(name = "requested_at", nullable = false) - var requestedAt: LocalDateTime = LocalDateTime.now(), - - @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() -) { - /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ - constructor() : this(spacecraftId = "") -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt index 43259aa..e96a5e6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -47,9 +47,6 @@ class PlannedPlanEntity( @Column(name = "accepted_at") var acceptedAt: LocalDateTime? = null, - @Column(name = "superseded_by_plan_id") - var supersededByPlanId: UUID? = null, - @Column(name = "created_at", nullable = false) var createdAt: LocalDateTime = LocalDateTime.now(), 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 4c2b784..a974961 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 @@ -4,12 +4,24 @@ package space.nstart.pcp_tgu_service.entity * Жизненный цикл persisted business-plan в расписании TGU. */ enum class PlannedPlanStatus { + /** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */ PLANNED, ISSUING, WAITING_DECISION, ACCEPTED, + + /** Принят и оптимистично считается заложенным; текущий базовый план цепочки. */ + AWAITING_LAYIN, + + /** Подтверждённая закладка (терминальный успех). */ + LAID_IN, + + /** Пришло событие «не заложен» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ + LAYIN_FAILED, + + /** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ + NOT_ACCEPTED, + REJECTED, - SUPERSEDED, - EXPIRED, START_AMBIGUOUS } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt index ced8a80..b90bc82 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -27,6 +27,13 @@ class SpacecraftPlanningStateEntity( @Column(name = "chain_version", nullable = false) var chainVersion: Long = 0, + /** + * Если задано — ближайшую ЗРВ со стартом не раньше этого времени нужно выдать срочно + * (после REJECTED / «не заложен» / «не принят»), не дожидаясь обычного issue_not_before. + */ + @Column(name = "urgent_reissue_from") + var urgentReissueFrom: LocalDateTime? = null, + @Column(name = "updated_at", nullable = false) var updatedAt: LocalDateTime = LocalDateTime.now() ) { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt deleted file mode 100644 index b9615e9..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt +++ /dev/null @@ -1,63 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** JPA-сущность, хранящая план вместе с его процессным и подтверждающим жизненным циклом. */ -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime -import java.util.UUID - -@Entity -@Table(name = "tracked_plan") -class TrackedPlanEntity( - @Id - @Column(name = "plan_id", nullable = false, updatable = false) - var planId: UUID, - - @Column(name = "spacecraft_id", nullable = false) - var spacecraftId: String, - - @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime, - - @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime, - - @Column(name = "kpp_id", nullable = false) - var kppId: String, - - @Column(name = "process_instance_key") - var processInstanceKey: Long? = null, - - @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), - - @Column(name = "confirmed_at") - var confirmedAt: LocalDateTime? = null, - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: TrackedPlanStatus = TrackedPlanStatus.WAITING_CONFIRMATION, - - @Enumerated(EnumType.STRING) - @Column(name = "plan_source", nullable = false) - var planSource: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN, - - @Column(name = "base_plan_id") - var basePlanId: UUID? = null, - - @Column(name = "superseded_by_plan_id") - var supersededByPlanId: UUID? = null -) { - /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ - constructor() : this( - planId = UUID.randomUUID(), - spacecraftId = "", - startTime = LocalDateTime.MIN, - endTime = LocalDateTime.MIN, - kppId = "" - ) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt deleted file mode 100644 index beab660..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** Описывает способ создания отслеживаемого плана. */ -enum class TrackedPlanSource { - AUTO_CHAIN, - MANUAL -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt deleted file mode 100644 index 57d2131..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt +++ /dev/null @@ -1,9 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** Описывает состояние жизненного цикла отслеживаемого плана. */ -enum class TrackedPlanStatus { - NEW, - WAITING_CONFIRMATION, - CONFIRMED, - SUPERSEDED -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt index 1a46d9c..5745153 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt @@ -10,6 +10,7 @@ import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit /** * Парсит raw payload баллистики в доменную модель расчёта без доступа к БД. @@ -42,11 +43,18 @@ class VisibilityPayloadParser( return SpacecraftPoints(spacecraftId = spacecraftId, points = points) } - /** Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. */ + /** + * Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. + * + * Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time` + * хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется. + * Без обрезки проекция (наносекунды) и persisted-планы (микросекунды) перестают совпадать по + * бизнес-ключу — ломается дедуп в read-пути и матчинг базы в [calculatePlansAfter]. + */ private fun toLocalDateTime(value: String): LocalDateTime = try { Instant.parse(value).atZone(zoneId).toLocalDateTime() } catch (_: DateTimeParseException) { LocalDateTime.parse(value) - } + }.truncatedTo(ChronoUnit.MICROS) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt new file mode 100644 index 0000000..a8a11f8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt @@ -0,0 +1,24 @@ +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 события «не заложен»; делегирует срочную перевыдачу в use case. + */ +@Component +class LayinFailedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.layinFailed}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleLayinFailed(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt new file mode 100644 index 0000000..35802fe --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt @@ -0,0 +1,24 @@ +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 события «не принят»; пока поведение идентично «не заложен», обработчик раздельный. + */ +@Component +class PlanNotAcceptedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.planNotAccepted}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleNotAccepted(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt new file mode 100644 index 0000000..dc8c5e6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt @@ -0,0 +1,24 @@ +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.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.service.HandleStationParamsChangedUseCase + +/** + * Kafka consumer события об изменении параметров станций; запускает перезапрос ЗРВ. + */ +@Component +class StationParamsChangedConsumer( + val topicsProperties: TopicsProperties, + private val handleStationParamsChangedUseCase: HandleStationParamsChangedUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.stationParamsChanged}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: StationParamsChangedMessage) { + handleStationParamsChangedUseCase.handle(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt deleted file mode 100644 index 8f237e6..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt +++ /dev/null @@ -1,85 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import java.time.LocalDateTime - -/** Доступ к jobs перестроения расписания. */ -interface PlanRecalculationJobRepository : JpaRepository { - /** Атомарно ставит или переоткрывает coalesced recalculation job по spacecraftId. */ - @Modifying - @Query( - value = """ - INSERT INTO plan_recalculation_job( - spacecraft_id, - status, - reason, - locked_by, - locked_until, - rerun_requested, - requested_at, - updated_at - ) - VALUES ( - :spacecraftId, - 'PENDING', - :reason, - NULL, - NULL, - FALSE, - :now, - :now - ) - ON CONFLICT (spacecraft_id) DO UPDATE SET - status = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status - ELSE 'PENDING' - END, - reason = EXCLUDED.reason, - locked_by = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by - ELSE NULL - END, - locked_until = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until - ELSE NULL - END, - rerun_requested = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE - ELSE FALSE - END, - requested_at = EXCLUDED.requested_at, - updated_at = EXCLUDED.updated_at - """, - nativeQuery = true - ) - fun upsertPending( - @Param("spacecraftId") spacecraftId: String, - @Param("reason") reason: String, - @Param("now") now: LocalDateTime - ): Int - - /** Claim pending или просроченные running jobs через row locks для горизонтального scale-out. */ - @Query( - value = """ - SELECT * - FROM plan_recalculation_job - WHERE status = 'PENDING' - OR ( - status = 'RUNNING' - AND locked_until < :now - ) - ORDER BY requested_at - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - fun findPendingForUpdate( - @Param("now") now: LocalDateTime, - @Param("limit") limit: Int - ): List -} 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 c93c6df..62b594a 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 @@ -4,99 +4,56 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity -import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.time.LocalDateTime import java.util.UUID -/** Доступ к persisted расписанию business-plan. */ +/** Доступ к persisted планам (только выданные и их история; будущее не хранится). */ interface PlannedPlanRepository : JpaRepository { - /** Ищет persisted plan по стабильному business key. */ - fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String - ): PlannedPlanEntity? - - /** Возвращает планы КА в порядке старта. */ + /** Возвращает планы КА в порядке старта (для read-проекции и истории). */ fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List - /** Возвращает все планы в порядке старта. */ + /** Возвращает все планы в порядке старта (для read-проекции и истории). */ fun findAllByOrderByStartTime(): List - /** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */ - fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( - spacecraftId: String, - status: PlannedPlanStatus, - endTime: LocalDateTime - ): List - - /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ - fun findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId: String, - status: PlannedPlanStatus, - startTime: LocalDateTime - ): PlannedPlanEntity? - - /** Атомарно создаёт или обновляет PLANNED plan по business key, сохраняя stable planId. */ + /** + * Создаёт новую строку выдаваемого плана сразу в статусе ISSUING. + * + * В on-demand-модели каждая выдача — это INSERT с новым plan_id; апсерта по бизнес-ключу нет, + * повторная выдача того же окна после негатива создаёт отдельную строку (история хранится вечно). + */ @Query( value = """ - WITH upsert AS ( - INSERT INTO planned_plan( - plan_id, - spacecraft_id, - start_time, - end_time, - kpp_id, - status, - issue_not_before, - base_plan_id, - chain_version, - created_at, - updated_at - ) - VALUES ( - :planId, - :spacecraftId, - :startTime, - :endTime, - :kppId, - 'PLANNED', - :issueNotBefore, - :basePlanId, - :chainVersion, - :now, - :now - ) - ON CONFLICT (spacecraft_id, start_time, end_time, kpp_id) DO UPDATE SET - status = CASE - WHEN planned_plan.status IN ('SUPERSEDED', 'EXPIRED') THEN 'PLANNED' - ELSE planned_plan.status - END, - issue_not_before = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.issue_not_before - ELSE planned_plan.issue_not_before - END, - base_plan_id = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.base_plan_id - ELSE planned_plan.base_plan_id - END, - chain_version = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.chain_version - ELSE planned_plan.chain_version - END, - updated_at = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.updated_at - ELSE planned_plan.updated_at - END - RETURNING * + INSERT INTO planned_plan( + plan_id, + spacecraft_id, + start_time, + end_time, + kpp_id, + status, + issue_not_before, + base_plan_id, + chain_version, + created_at, + updated_at ) - SELECT * - FROM upsert + VALUES ( + :planId, + :spacecraftId, + :startTime, + :endTime, + :kppId, + 'ISSUING', + :issueNotBefore, + :basePlanId, + :chainVersion, + :now, + :now + ) + RETURNING * """, nativeQuery = true ) - fun upsertPlannedByBusinessKey( + fun insertIssuingPlan( @Param("planId") planId: UUID, @Param("spacecraftId") spacecraftId: String, @Param("startTime") startTime: LocalDateTime, @@ -108,7 +65,7 @@ interface PlannedPlanRepository : JpaRepository { @Param("now") now: LocalDateTime ): PlannedPlanEntity - /** Блокирует plan для обработки decision event или issue state transition. */ + /** Блокирует plan для обработки decision/feedback event или issue state transition. */ @Query( value = """ SELECT * @@ -119,25 +76,4 @@ interface PlannedPlanRepository : JpaRepository { nativeQuery = true ) fun lockByPlanId(@Param("planId") planId: UUID): PlannedPlanEntity? - - /** Claim due plans через PostgreSQL row locks, чтобы несколько pod не выдали один plan дважды. */ - @Query( - value = """ - SELECT * - FROM planned_plan - WHERE status = 'PLANNED' - AND (issue_not_before IS NULL OR issue_not_before <= :now) - AND start_time <= :issueBefore - AND start_time > :now - ORDER BY start_time - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - fun findDueForUpdate( - @Param("now") now: LocalDateTime, - @Param("issueBefore") issueBefore: LocalDateTime, - @Param("limit") limit: Int - ): List } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt deleted file mode 100644 index 21a5d4b..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt +++ /dev/null @@ -1,46 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -/** Предоставляет методы доступа к БД для отслеживаемых планов и их состояний. */ -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import org.springframework.data.jpa.repository.JpaRepository -import java.time.LocalDateTime -import java.util.UUID - -interface TrackedPlanJpaRepository : JpaRepository { - /** Возвращает отслеживаемые планы КА в указанном статусе. */ - fun findAllBySpacecraftIdAndStatus(spacecraftId: String, status: TrackedPlanStatus): List - - /** Возвращает все tracked-планы КА, упорядоченные по времени старта. */ - fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List - - /** Возвращает планы, подтверждённые с указанного момента, упорядоченные по времени старта. */ - fun findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime( - spacecraftId: String, - status: TrackedPlanStatus, - confirmedAt: LocalDateTime - ): List - - /** Возвращает отслеживаемый план по его бизнес-полям и статусу. */ - fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String, - status: TrackedPlanStatus - ): TrackedPlanEntity? - - /** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */ - fun findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String - ): TrackedPlanEntity? - - /** Возвращает последний подтверждённый отслеживаемый план для КА. */ - fun findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc( - spacecraftId: String, - status: TrackedPlanStatus - ): TrackedPlanEntity? -} 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 b61cea8..18d5a6f 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 @@ -1,11 +1,14 @@ package space.nstart.pcp_tgu_service.service +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties 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.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity @@ -18,7 +21,11 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaReposit import java.time.LocalDateTime /** - * Обрабатывает decision events идемпотентно по eventId и строго по attemptId. + * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId. + * + * - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается. + * - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ + * запускается срочная перевыдача ([reissueOnNearestWindow]). */ @Service class HandlePlanDecisionUseCase( @@ -26,15 +33,15 @@ class HandlePlanDecisionUseCase( private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val plannedPlanRepository: PlannedPlanRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, - private val planRecalculationJobService: PlanRecalculationJobService, private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService, - private val planningProperties: space.nstart.pcp_tgu_service.config.PlanningProperties + private val planningProperties: PlanningProperties ) { - /** Применяет решение; повтор того же eventId является успешным no-op. */ + private val log = LoggerFactory.getLogger(javaClass) + + /** Применяет решение по attempt; повтор того же eventId — успешный no-op. */ @Transactional fun handle(message: PlanDecisionMessage) { - val inserted = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) - if (inserted == 0) { + if (!claimEvent(message.eventId)) { return } @@ -72,6 +79,50 @@ class HandlePlanDecisionUseCase( } } + /** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */ + @Transactional + fun handleLayinFailed(message: PlanExecutionFeedbackMessage) { + applyNegativeFeedback(message, PlannedPlanStatus.LAYIN_FAILED) + } + + /** Событие «не принят» по базовому плану — пока поведение идентично «не заложен». */ + @Transactional + fun handleNotAccepted(message: PlanExecutionFeedbackMessage) { + applyNegativeFeedback(message, PlannedPlanStatus.NOT_ACCEPTED) + } + + private fun applyNegativeFeedback(message: PlanExecutionFeedbackMessage, terminalStatus: PlannedPlanStatus) { + 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, + "Feedback spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}" + ) + } + + val state = getOrCreateLockedState(message.spacecraftId) + if (state.currentBasePlanId != plan.planId || plan.status != PlannedPlanStatus.AWAITING_LAYIN) { + // Событие пришло не по текущему базовому плану (устарело/дубль логики) — ничего не делаем. + log.info( + "Negative feedback ignored: plan {} is not the current AWAITING_LAYIN base (status={}, base={}).", + plan.planId, plan.status, state.currentBasePlanId + ) + return + } + + val now = LocalDateTime.now() + plan.status = terminalStatus + plan.updatedAt = now + plannedPlanRepository.save(plan) + + reissueOnNearestWindow(state, plan, now) + } + private fun recordDecisionOnAttempt( message: PlanDecisionMessage, attempt: PlanIssueAttemptEntity @@ -96,20 +147,20 @@ class HandlePlanDecisionUseCase( val now = LocalDateTime.now() recordDecisionOnAttempt(message, attempt) - plan.status = PlannedPlanStatus.ACCEPTED + plan.status = PlannedPlanStatus.AWAITING_LAYIN plan.acceptedAt = message.decisionTime plan.updatedAt = now state.currentBasePlanId = plan.planId state.activePlanId = null state.activeAttemptId = null + state.urgentReissueFrom = null state.chainVersion += 1 state.updatedAt = now planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) spacecraftPlanningStateJpaRepository.save(state) - planRecalculationJobService.enqueue(message.spacecraftId, "PLAN_ACCEPTED") acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) } @@ -122,44 +173,38 @@ class HandlePlanDecisionUseCase( val now = LocalDateTime.now() recordDecisionOnAttempt(message, attempt) - state.activePlanId = null - state.activeAttemptId = null - state.updatedAt = now - - if (now < plan.startTime.minusMinutes(planningProperties.minReissueLeadMinutes)) { - plan.status = PlannedPlanStatus.PLANNED - plan.issueNotBefore = now - plan.updatedAt = now - } else { - plan.status = PlannedPlanStatus.EXPIRED - plan.updatedAt = now - promoteNextPlanOrEnqueueRebuild(plan.spacecraftId, now) - } + plan.status = PlannedPlanStatus.REJECTED + plan.updatedAt = now planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) - spacecraftPlanningStateJpaRepository.save(state) + + reissueOnNearestWindow(state, plan, now) } /** - * Decision use case только меняет state. Новую попытку позже создаст IssueDuePlanWorker. + * Общий маршрут после негатива (REJECTED/«не заложен»/«не принят»): снимает активную выдачу + * и взводит срочную перевыдачу на ближайшую ЗРВ. Базовый указатель не трогаем — проекция + * сама подберёт ближайшее окно (для принятого-и-провалившегося плана он же служит якорем цепочки). */ - private fun promoteNextPlanOrEnqueueRebuild(spacecraftId: String, now: LocalDateTime) { - val nextPlan = plannedPlanRepository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId = spacecraftId, - status = PlannedPlanStatus.PLANNED, - startTime = now + private fun reissueOnNearestWindow( + state: SpacecraftPlanningStateEntity, + failedPlan: PlannedPlanEntity, + now: LocalDateTime + ) { + state.activePlanId = null + state.activeAttemptId = null + state.urgentReissueFrom = maxOf( + now.plusMinutes(planningProperties.reissuePrepMinutes), + failedPlan.startTime ) - - if (nextPlan == null) { - planRecalculationJobService.enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") - } else { - nextPlan.issueNotBefore = now - nextPlan.updatedAt = now - plannedPlanRepository.save(nextPlan) - } + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) } + private fun claimEvent(eventId: java.util.UUID): Boolean = + processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0 + private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt new file mode 100644 index 0000000..7583db4 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime + +/** + * На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА. + * + * Дальше [VisibilityRefreshWorker] обновит снапшоты, а будущая цепочка пересоберётся + * через on-demand [PlanProjectionService]. + */ +@Service +class HandleStationParamsChangedUseCase( + private val processedPlanDecisionEventRepository: ProcessedPlanDecisionEventRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val visibilityRefreshJobService: VisibilityRefreshJobService +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Идемпотентно по eventId; ставит refresh job по всем активным КА. */ + @Transactional + fun handle(message: StationParamsChangedMessage) { + val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0 + if (!claimed) { + return + } + + val active = spacecraftSnapshotRepository.findAllByActiveTrue() + active.forEach { snapshot -> + visibilityRefreshJobService.enqueue(snapshot.spacecraftId, snapshot.noradId, REASON) + } + log.info( + "Station params changed (stationId={}): enqueued visibility refresh for {} active spacecraft.", + message.stationId, active.size + ) + } + + private companion object { + const val REASON = "STATION_PARAMS_CHANGED" + } +} 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 8f89117..79d8968 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 @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus @@ -16,28 +17,34 @@ 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 space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.UUID /** - * Выдаёт due plans из БД через попытки и короткий BPMN process per attempt. + * Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ + * ([PlanProjectionService]) и материализуется в planned_plan ровно в момент выдачи (статус ISSUING). */ @Service class IssueDuePlanWorker( private val planningProperties: PlanningProperties, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, private val plannedPlanRepository: PlannedPlanRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val planProjectionService: PlanProjectionService, private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter, private val transactionTemplate: TransactionTemplate ) { private val log = LoggerFactory.getLogger(javaClass) - /** Периодически claim'ит due plans и стартует BPMN вне DB transaction. */ + /** Периодически по активным КА считает голову проекции и выдаёт дозревший план. */ @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") fun issueDuePlans() { - val prepared = transactionTemplate.execute { prepareDuePlans(limit = 10) }.orEmpty() - prepared.forEach { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + spacecraftSnapshotRepository.findAllByActiveTrue().forEach { snapshot -> + val prepared = transactionTemplate.execute { prepareDueForSpacecraft(snapshot.spacecraftId) } + prepared?.let { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + } } /** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */ @@ -47,50 +54,103 @@ class IssueDuePlanWorker( return startPreparedAttempt(prepared) } - private fun prepareDuePlans(limit: Int): List { + /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ + private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { val now = LocalDateTime.now() - val issueBefore = now.plusMinutes(planningProperties.notificationBeforeStartMinutes) - return plannedPlanRepository.findDueForUpdate(now, issueBefore, limit) - .mapNotNull { plan -> preparePlan(plan, now) } + val state = getOrCreateLockedState(spacecraftId) + if (state.activeAttemptId != null) { + return null + } + + val projection = planProjectionService.project(spacecraftId) + if (projection.isEmpty()) { + return null + } + + val urgentFrom = state.urgentReissueFrom + val head = if (urgentFrom != null) { + projection.firstOrNull { plan -> !plan.startTime.isBefore(urgentFrom) } + } else { + projection.first() + } ?: return null + + if (!head.startTime.isAfter(now)) { + return null + } + + val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes) + val due = urgentFrom != null || !now.isBefore(issueNotBefore) + if (!due) { + return null + } + + val plan = materializeIssuingPlan(head, state, issueNotBefore, now) + val attempt = createStartingAttempt(plan.planId, now) + + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) } private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { val now = LocalDateTime.now() val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null - if (plan.status !in setOf(PlannedPlanStatus.PLANNED, PlannedPlanStatus.REJECTED)) { - return null - } - return preparePlan(plan, now) - } - - private fun preparePlan(plan: PlannedPlanEntity, now: LocalDateTime): PreparedAttempt? { val state = getOrCreateLockedState(plan.spacecraftId) if (state.activeAttemptId != null) { return null } + if (plan.status !in REISSUABLE_STATUSES) { + return null + } - val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)?.attemptNo ?: 0) + 1 - val attempt = planIssueAttemptRepository.save( + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + val attempt = createStartingAttempt(plan.planId, now) + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + private fun materializeIssuingPlan( + head: CalculatedPlan, + state: SpacecraftPlanningStateEntity, + issueNotBefore: LocalDateTime, + now: LocalDateTime + ): PlannedPlanEntity = + plannedPlanRepository.insertIssuingPlan( + planId = UUID.randomUUID(), + spacecraftId = head.spacecraftId, + startTime = head.startTime, + endTime = head.endTime, + kppId = head.kppId, + issueNotBefore = issueNotBefore, + basePlanId = state.currentBasePlanId, + chainVersion = state.chainVersion, + now = now + ) + + private fun createStartingAttempt(planId: UUID, now: LocalDateTime): PlanIssueAttemptEntity { + val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)?.attemptNo ?: 0) + 1 + return planIssueAttemptRepository.save( PlanIssueAttemptEntity( attemptId = UUID.randomUUID(), - planId = plan.planId, + planId = planId, attemptNo = attemptNo, status = PlanIssueAttemptStatus.STARTING, createdAt = now, updatedAt = now ) ) - - plan.status = PlannedPlanStatus.ISSUING - plan.updatedAt = now - plannedPlanRepository.save(plan) - - state.activePlanId = plan.planId - state.activeAttemptId = attempt.attemptId - state.updatedAt = now - spacecraftPlanningStateJpaRepository.save(state) - - return PreparedAttempt(plan.planId, attempt.attemptId) } private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse { @@ -159,4 +219,15 @@ class IssueDuePlanWorker( val planId: UUID, val attemptId: UUID ) + + private companion object { + /** Статусы persisted-плана, который допустимо выдать вручную через issuePlanNow (test-only). */ + val REISSUABLE_STATUSES = setOf( + PlannedPlanStatus.PLANNED, + PlannedPlanStatus.REJECTED, + PlannedPlanStatus.LAYIN_FAILED, + PlannedPlanStatus.NOT_ACCEPTED, + PlannedPlanStatus.START_AMBIGUOUS + ) + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index 93f593d..0f82246 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -48,6 +48,23 @@ class PlanCalculationService( fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) + + if (candidates.isEmpty()) { + return emptyList() + } + + // Базовый (принятый) план полностью в прошлом относительно доступных окон — он + // больше не ограничивает будущее (нахлёст с ним невозможен и не нужен). Строим + // свежую цепочку с самого раннего окна. Иначе проекция схлопнулась бы в пустоту. + if (previousPlan.endTime <= candidates.first().startTime) { + return buildChain( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + fromStartIndex = 0, + initialLowerEndBound = null + ) + } + /** Индекс кандидата, с которого начинался предыдущий план. */ val previousStartIndex: Int = candidates.indexOfFirst { candidate -> candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime @@ -85,7 +102,14 @@ class PlanCalculationService( } if (fromStartIndex !in candidates.indices) { - return emptyList() + // База перекрывается с окнами, но якорь хвоста подобрать не удалось — не + // схлопываемся в пустоту, а строим свежую цепочку с самого раннего окна. + return buildChain( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + fromStartIndex = 0, + initialLowerEndBound = null + ) } return buildChain( 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 new file mode 100644 index 0000000..64b7d3b --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt @@ -0,0 +1,51 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository + +/** + * Считает будущую цепочку планов КА на лету из последнего снапшота ЗРВ и текущей базы. + * + * Проекция нигде не сохраняется: это детерминированная функция от (снапшот + база + конфиг). + * Её используют read-путь (UI) и воркер выдачи, чтобы понять, какой план выдавать следующим. + */ +@Service +class PlanProjectionService( + private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val plannedPlanRepository: PlannedPlanRepository, + private val visibilityPayloadParser: VisibilityPayloadParser, + private val planCalculationService: PlanCalculationService +) { + /** Полная проекция будущей цепочки для КА; пусто, если нет снапшота или окон. */ + @Transactional(readOnly = true) + fun project(spacecraftId: String): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + + val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) + val basePlan = state?.currentBasePlanId + ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + + return if (basePlan == null) { + planCalculationService.calculatePlans(points) + } else { + planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) + } + } + + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = + CalculatedPlan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index 8ae5027..c3980d7 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -4,32 +4,51 @@ import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.UUID /** - * Read-only query service для persisted planned_plan. + * Read-сервис планов: объединяет persisted (выданные, история — вечно) с вычисленной + * на лету проекцией будущего ([PlanProjectionService]). Проекция в БД не пишется. */ @Service class PlanQueryService( - private val plannedPlanRepository: PlannedPlanRepository + private val plannedPlanRepository: PlannedPlanRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val planProjectionService: PlanProjectionService ) { - /** Возвращает все persisted планы с отсечением глубокой истории. */ + /** Все планы (persisted ∪ проекция) с отсечением глубокой истории по `historyDays`. */ @Transactional(readOnly = true) - fun getAllPlans(historyDays: Long): List { - val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) - return plannedPlanRepository.findAllByOrderByStartTime() + fun getAllPlans(historyDays: Long): List { + val historySince = historySince(historyDays) + val persisted = plannedPlanRepository.findAllByOrderByStartTime() .filter { plan -> plan.endTime >= historySince } + val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } + + val projected = spacecraftSnapshotRepository.findAllByActiveTrue() + .flatMap { snapshot -> planProjectionService.project(snapshot.spacecraftId) } + .filter { plan -> plan.businessKey() !in persistedKeys } + + return combine(persisted, projected) } - /** Возвращает persisted планы одного КА с отсечением глубокой истории. */ + /** Планы одного КА (persisted ∪ проекция) с отсечением глубокой истории. */ @Transactional(readOnly = true) - fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { - val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) - return plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) + fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { + val historySince = historySince(historyDays) + val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) .filter { plan -> plan.endTime >= historySince } + val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } + + val projected = planProjectionService.project(spacecraftId) + .filter { plan -> plan.businessKey() !in persistedKeys } + + return combine(persisted, projected) } /** Возвращает persisted plan по ID для test-only/debug API. */ @@ -37,4 +56,17 @@ class PlanQueryService( fun getByPlanId(planId: UUID): PlannedPlanEntity = plannedPlanRepository.findById(planId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") } + + private fun combine(persisted: List, projected: List): List = + (persisted.map(PlanResponse::from) + projected.map(PlanResponse::projected)) + .sortedBy { response -> response.startTime } + + private fun historySince(historyDays: Long): LocalDateTime = + LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + + private fun PlannedPlanEntity.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" + + private fun CalculatedPlan.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt deleted file mode 100644 index 1a59b39..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt +++ /dev/null @@ -1,20 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import java.time.LocalDateTime - -/** - * Coalesce-сервис для plan_recalculation_job по spacecraftId. - */ -@Service -class PlanRecalculationJobService( - private val planRecalculationJobRepository: PlanRecalculationJobRepository -) { - /** Ставит или переоткрывает job перестроения расписания без дублей. */ - @Transactional - fun enqueue(spacecraftId: String, reason: String) { - planRecalculationJobRepository.upsertPending(spacecraftId, reason, LocalDateTime.now()) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt deleted file mode 100644 index a5879e2..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt +++ /dev/null @@ -1,85 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Service -import org.springframework.transaction.support.TransactionTemplate -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.entity.JobStatus -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import java.net.InetAddress -import java.time.LocalDateTime - -/** - * Claim'ит jobs перестроения расписания и вызывает RebuildPlanScheduleUseCase. - */ -@Service -class PlanRecalculationWorker( - private val planningProperties: PlanningProperties, - private val planRecalculationJobRepository: PlanRecalculationJobRepository, - private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, - private val transactionTemplate: TransactionTemplate -) { - private val log = LoggerFactory.getLogger(javaClass) - private val workerId = "${InetAddress.getLocalHost().hostName}-${java.util.UUID.randomUUID()}" - - /** Периодически обрабатывает pending recalculation jobs. */ - @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") - fun processPendingJobs() { - val jobs = claimJobs(limit = 10) - jobs.forEach { job -> processJob(job.spacecraftId) } - } - - private fun claimJobs(limit: Int): List = - transactionTemplate.execute { - val now = LocalDateTime.now() - val jobs = planRecalculationJobRepository.findPendingForUpdate(now, limit) - jobs.forEach { job -> - job.status = JobStatus.RUNNING - job.lockedBy = workerId - job.lockedUntil = now.plusSeconds(planningProperties.workerLockTtlSeconds) - job.attempts += 1 - job.updatedAt = now - } - planRecalculationJobRepository.saveAll(jobs) - jobs - }.orEmpty() - - private fun processJob(spacecraftId: String) { - try { - rebuildPlanScheduleUseCase.rebuild(spacecraftId) - markDone(spacecraftId) - } catch (ex: Exception) { - log.warn("Plan recalculation job failed. spacecraftId={}", spacecraftId, ex) - releaseOrFail(spacecraftId) - } - } - - private fun markDone(spacecraftId: String) { - transactionTemplate.executeWithoutResult { - val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult - job.status = if (job.rerunRequested) JobStatus.PENDING else JobStatus.DONE - job.rerunRequested = false - job.lockedBy = null - job.lockedUntil = null - job.updatedAt = LocalDateTime.now() - planRecalculationJobRepository.save(job) - } - } - - private fun releaseOrFail(spacecraftId: String) { - transactionTemplate.executeWithoutResult { - val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult - job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING - job.lockedBy = null - job.lockedUntil = null - job.updatedAt = LocalDateTime.now() - planRecalculationJobRepository.save(job) - } - } - - private companion object { - const val MAX_ATTEMPTS = 3 - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt deleted file mode 100644 index b63953e..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt +++ /dev/null @@ -1,113 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.CalculatedPlan -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.integration.api.VisibilityPayloadParser -import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository -import java.time.LocalDateTime -import java.util.UUID - -/** - * Перестраивает persisted расписание планов из последнего raw visibility snapshot. - */ -@Service -class RebuildPlanScheduleUseCase( - private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, - private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, - private val plannedPlanRepository: PlannedPlanRepository, - private val visibilityPayloadParser: VisibilityPayloadParser, - private val planCalculationService: PlanCalculationService, - private val planningProperties: PlanningProperties -) { - /** Rebuild по одному КА: upsert сохраняет стабильный planId для business key. */ - @Transactional - fun rebuild(spacecraftId: String) { - val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId) - .orElseThrow { IllegalStateException("Visibility snapshot for spacecraftId=$spacecraftId was not found") } - val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) - val state = getOrCreateState(spacecraftId) - val basePlan = state.currentBasePlanId?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } - - val calculatedPlans = if (basePlan == null) { - planCalculationService.calculatePlans(points) - } else { - planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) - } - - val now = LocalDateTime.now() - val chainVersion = state.chainVersion + 1 - val savedPlans = calculatedPlans.map { calculatedPlan -> - upsertPlan(calculatedPlan, state.currentBasePlanId, chainVersion, now) - } - - supersedeObsoleteFuturePlans( - spacecraftId = spacecraftId, - calculatedBusinessKeys = savedPlans.map { plan -> plan.businessKey() }.toSet(), - now = now - ) - state.chainVersion = chainVersion - state.updatedAt = now - spacecraftPlanningStateJpaRepository.save(state) - } - - private fun getOrCreateState(spacecraftId: String): SpacecraftPlanningStateEntity { - spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) - ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) - return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId) - ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create") - } - - private fun upsertPlan( - calculatedPlan: CalculatedPlan, - basePlanId: UUID?, - chainVersion: Long, - now: LocalDateTime - ): PlannedPlanEntity = - plannedPlanRepository.upsertPlannedByBusinessKey( - planId = UUID.randomUUID(), - spacecraftId = calculatedPlan.spacecraftId, - startTime = calculatedPlan.startTime, - endTime = calculatedPlan.endTime, - kppId = calculatedPlan.kppId, - issueNotBefore = calculatedPlan.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes), - basePlanId = basePlanId, - chainVersion = chainVersion, - now = now - ) - - private fun supersedeObsoleteFuturePlans( - spacecraftId: String, - calculatedBusinessKeys: Set, - now: LocalDateTime - ) { - plannedPlanRepository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( - spacecraftId = spacecraftId, - status = PlannedPlanStatus.PLANNED, - endTime = now - ) - .filter { plan -> plan.businessKey() !in calculatedBusinessKeys } - .forEach { plan -> - plan.status = PlannedPlanStatus.SUPERSEDED - plan.updatedAt = now - plannedPlanRepository.save(plan) - } - } - - private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = - CalculatedPlan( - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId - ) - - private fun PlannedPlanEntity.businessKey(): String = - "$spacecraftId|$startTime|$endTime|$kppId" -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt index 2836a6e..7dde8c8 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt @@ -9,16 +9,16 @@ import java.security.MessageDigest import java.time.LocalDateTime /** - * Сохраняет raw visibility payload и ставит recalculation только при изменении hash. + * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота + * ([PlanProjectionService]), поэтому отдельный triggers пересчёта здесь не нужен. */ @Service class SpacecraftVisibilitySnapshotService( private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, - private val visibilityPayloadParser: VisibilityPayloadParser, - private val planRecalculationJobService: PlanRecalculationJobService + private val visibilityPayloadParser: VisibilityPayloadParser ) { /** - * Сохраняет snapshot. Recalculation job создаётся только для нового или изменившегося payload. + * Сохраняет snapshot. Hash используется только для метаданных и диагностики изменений окон. */ @Transactional fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) { @@ -42,7 +42,6 @@ class SpacecraftVisibilitySnapshotService( updatedAt = now ) ) - planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") return } @@ -60,10 +59,6 @@ class SpacecraftVisibilitySnapshotService( } spacecraftVisibilitySnapshotRepository.save(existing) - - if (changed) { - planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") - } } private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata { diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 3b44568..14e21ef 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -14,19 +14,23 @@ spring: planning: notification-before-start-minutes: 263 - target-plan-duration-minutes: 180 - target-plan-duration-delta-minutes: 60 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 external-update-enabled: true - external-points-lookahead-days: 3 + external-points-lookahead-days: 10 in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 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 + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 + station-params-changed: pcp.tgu.station-params-changed.v1 tgu: test-controller: diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql new file mode 100644 index 0000000..03cc7d6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql @@ -0,0 +1,5 @@ +-- В on-demand-модели планы не апсертятся по бизнес-ключу: при перевыдаче того же окна +-- (после REJECTED/негатива) создаётся новая строка с новым plan_id, а старая остаётся в истории. +-- Поэтому уникальность по (spacecraft_id, start_time, end_time, kpp_id) больше не нужна. +ALTER TABLE planned_plan + DROP CONSTRAINT IF EXISTS uq_planned_plan_business_key; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql new file mode 100644 index 0000000..f0c51f6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql @@ -0,0 +1,11 @@ +-- Чистка легаси: до редизайна старый код материализовал всю будущую цепочку в planned_plan +-- со статусом PLANNED. Новая модель «храним только выданное» проекцию в БД не пишет — будущее +-- считается на лету. Persisted-строк в статусе PLANNED как класса больше не существует. +-- +-- Эти строки и порождали видимые «дубликаты» в UI: persisted-PLANNED (микросекунды) показывался +-- рядом со своим проекционным двойником (наносекунды), не схлопываясь по бизнес-ключу. +-- +-- Удаляем только PLANNED. Выданная история (ISSUING/WAITING_DECISION/ACCEPTED/AWAITING_LAYIN/ +-- LAID_IN/REJECTED/LAYIN_FAILED/NOT_ACCEPTED/SUPERSEDED/EXPIRED и пр.) не затрагивается. +DELETE FROM planned_plan +WHERE status = 'PLANNED'; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql new file mode 100644 index 0000000..85ee844 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql @@ -0,0 +1,19 @@ +-- Чистка легаси статусов SUPERSEDED/EXPIRED. +-- +-- В старой модели вся будущая цепочка материализовалась в planned_plan как PLANNED-строки. +-- SUPERSEDED гасил будущие планы, выпавшие из пересчитанной цепочки (это делал ныне удалённый +-- RebuildPlanScheduleUseCase); EXPIRED помечал неизданный план, чьё окно прошло. +-- +-- Новая модель «храним только выданное, будущее считаем на лету» проекцию в БД не пишет: +-- перекрывать (supersede) нечего, а неизданный просроченный план строкой никогда не был. +-- Оба статуса в коде больше не присваиваются (убраны из PlannedPlanStatus). +-- +-- Эти строки — не выданная история, а легаси-бухгалтерия будущего, поэтому удаляем безвозвратно. +-- Выданная история (ISSUING/WAITING_DECISION/ACCEPTED/AWAITING_LAYIN/LAID_IN/REJECTED/ +-- LAYIN_FAILED/NOT_ACCEPTED/START_AMBIGUOUS) не затрагивается. +DELETE FROM planned_plan +WHERE status IN ('SUPERSEDED', 'EXPIRED'); + +-- Столбец под supersede-флоу больше не используется (поле supersededByPlanId удалено из сущности). +ALTER TABLE planned_plan + DROP COLUMN IF EXISTS superseded_by_plan_id; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql new file mode 100644 index 0000000..1ce8ff1 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql @@ -0,0 +1,2 @@ +-- Legacy-таблица из V1/V2: заменена на planned_plan (V4), в коде не используется. +DROP TABLE IF EXISTS tracked_plan; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql new file mode 100644 index 0000000..e21fd14 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql @@ -0,0 +1,2 @@ +-- Расписание считается on-demand из снапшота ЗРВ; отдельная очередь пересчёта больше не нужна. +DROP TABLE IF EXISTS plan_recalculation_job; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql new file mode 100644 index 0000000..b8591a2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql @@ -0,0 +1,4 @@ +-- Время, начиная с которого нужно срочно выдать следующий план после негативного события +-- (REJECTED / «не заложен» / «не принят»). NULL — срочной перевыдачи не требуется. +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL; diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index df21568..d90e0a4 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -114,6 +114,7 @@ class ExternalPointsClientTest { externalUpdateFixedDelayMs = 0, notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, + reissuePrepMinutes = 30, workerLockTtlSeconds = 300 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt new file mode 100644 index 0000000..87be7b4 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp_tgu_service.integration.api + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class VisibilityPayloadParserTest { + + private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) + + @Test + fun `truncates window times to microseconds to match persisted timestamp precision`() { + // Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан + // обрезать хвост, иначе проекция и persisted-планы не совпадут по бизнес-ключу. + val payload = """ + [{ + "noradId": 20, + "stationId": 102, + "onStart": {"time": "2026-06-02T14:14:08.548125028Z", "elevation": 5.0}, + "onStop": {"time": "2026-06-03T14:01:27.615390539Z", "elevation": 5.0}, + "onMaximum": {"time": "2026-06-02T20:00:00Z", "elevation": 45.0} + }] + """.trimIndent() + + val points = parser.parse("20", payload) + val window = points.points.single().observationWindows.single() + + assertEquals(0, window.startTime.nano % 1_000, "наносекундный остаток должен быть обрезан") + assertEquals(0, window.endTime.nano % 1_000, "наносекундный остаток должен быть обрезан") + // Микросекундная часть сохраняется как есть. + assertEquals(548_125_000, window.startTime.nano) + assertEquals(615_390_000, window.endTime.nano) + assertEquals(LocalDateTime::class.java, window.startTime.javaClass) + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt index 85bcc69..52f6246 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest { starter: AcceptedPlanSlotsUpdateProcessStarter ): AcceptedPlanSlotsUpdateWorker = AcceptedPlanSlotsUpdateWorker( - planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300), + planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300), acceptedPlanSlotsUpdateJobRepository = repository, acceptedPlanSlotsUpdateProcessStarter = starter, transactionTemplate = transactionTemplate() diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt new file mode 100644 index 0000000..6c4d131 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt @@ -0,0 +1,52 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +class HandleStationParamsChangedUseCaseTest { + + @Test + fun `enqueues visibility refresh for every active spacecraft once per event`() { + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf( + SpacecraftSnapshotEntity(spacecraftId = "1", noradId = 100L), + SpacecraftSnapshotEntity(spacecraftId = "2", noradId = 200L) + ) + ) + val refreshJobService = mock(VisibilityRefreshJobService::class.java) + val useCase = HandleStationParamsChangedUseCase(processedRepository, snapshotRepository, refreshJobService) + val message = message() + + useCase.handle(message) + useCase.handle(message) + + verify(refreshJobService, times(1)).enqueue("1", 100L, "STATION_PARAMS_CHANGED") + verify(refreshJobService, times(1)).enqueue("2", 200L, "STATION_PARAMS_CHANGED") + } + + private fun message(): StationParamsChangedMessage = + StationParamsChangedMessage( + eventId = UUID.randomUUID(), + stationId = "ST-7", + occurredAt = LocalDateTime.now(), + reason = "OUT_OF_SERVICE" + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} 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 new file mode 100644 index 0000000..c2c381c --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -0,0 +1,220 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +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.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +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.entity.SpacecraftSnapshotEntity +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 space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class IssueDuePlanWorkerOnDemandTest { + + private val spacecraftId = "25544" + + @Test + fun `materializes due projection head as ISSUING and starts one attempt`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + // issueNotBefore = start - 30m; start=now+10m → issueNotBefore in the past → due now. + val head = plan(now().plusMinutes(10), now().plusHours(3)) + val worker = worker(planStore, attemptStore, state, projection = listOf(head)) + + worker.issueDuePlans() + + assertEquals(1, planStore.size) + val materialized = planStore.values.single() + assertEquals(head.startTime, materialized.startTime) + // markStarted перевёл выданный план в WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, materialized.status) + assertNotNull(state.activeAttemptId) + assertEquals(1, attemptStore.size) + } + + @Test + fun `does not issue when projection head is not due yet`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + // start far in the future → issueNotBefore well after now → not due. + val head = plan(now().plusHours(10), now().plusHours(13)) + val worker = worker(planStore, attemptStore, state, projection = listOf(head)) + + worker.issueDuePlans() + + assertTrue(planStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `urgent reissue issues nearest window past the floor immediately and clears the flag`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val floor = now().plusMinutes(30) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, urgentReissueFrom = floor) + val beforeFloor = plan(now().plusMinutes(10), now().plusHours(3)) + val afterFloor = plan(now().plusMinutes(40), now().plusHours(4)) + val worker = worker(planStore, attemptStore, state, projection = listOf(beforeFloor, afterFloor)) + + worker.issueDuePlans() + + val materialized = planStore.values.single() + assertEquals(afterFloor.startTime, materialized.startTime) + assertNull(state.urgentReissueFrom) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `skips spacecraft that already has an active attempt`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activeAttemptId = UUID.randomUUID()) + val head = plan(now().plusMinutes(10), now().plusHours(3)) + val projection = mock(PlanProjectionService::class.java) + `when`(projection.project(spacecraftId)).thenReturn(listOf(head)) + val worker = worker(planStore, attemptStore, state, projectionService = projection) + + worker.issueDuePlans() + + assertTrue(planStore.isEmpty()) + verify(projection, never()).project(spacecraftId) + } + + private fun worker( + planStore: MutableMap, + attemptStore: MutableMap, + state: SpacecraftPlanningStateEntity, + projection: List = emptyList(), + projectionService: PlanProjectionService = projectionReturning(projection) + ): IssueDuePlanWorker { + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L)) + ) + val starter = mock(BpmnPlanIssueProcessStarter::class.java) + `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val p = invocation.arguments[0] as PlannedPlanEntity + val a = invocation.arguments[1] as PlanIssueAttemptEntity + ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) + } + return IssueDuePlanWorker( + planningProperties = planningProperties(), + spacecraftSnapshotRepository = snapshotRepository, + plannedPlanRepository = plannedRepository(planStore), + planIssueAttemptRepository = attemptRepository(attemptStore), + spacecraftPlanningStateJpaRepository = stateRepository(state), + planProjectionService = projectionService, + bpmnPlanIssueProcessStarter = starter, + transactionTemplate = transactionTemplate() + ) + } + + private fun projectionReturning(projection: List): PlanProjectionService { + val service = mock(PlanProjectionService::class.java) + `when`(service.project(spacecraftId)).thenReturn(projection) + return service + } + + private fun plannedRepository(store: MutableMap): PlannedPlanRepository { + val repository = mock(PlannedPlanRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val plan = invocation.arguments[0] as PlannedPlanEntity + store[plan.planId] = plan + plan + } + `when`( + repository.insertIssuingPlan( + anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), + anyNonNull(), anyOrNull(), org.mockito.ArgumentMatchers.anyLong(), anyNonNull() + ) + ).thenAnswer { invocation -> + val entity = PlannedPlanEntity( + planId = invocation.arguments[0] as UUID, + spacecraftId = invocation.arguments[1] as String, + startTime = invocation.arguments[2] as LocalDateTime, + endTime = invocation.arguments[3] as LocalDateTime, + kppId = invocation.arguments[4] as String, + status = PlannedPlanStatus.ISSUING, + issueNotBefore = invocation.arguments[5] as LocalDateTime + ) + store[entity.planId] = entity + entity + } + return repository + } + + private fun attemptRepository(store: MutableMap): PlanIssueAttemptRepository { + val repository = mock(PlanIssueAttemptRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.findTopByPlanIdOrderByAttemptNoDesc(anyNonNull())).thenAnswer { invocation -> + store.values.filter { attempt -> attempt.planId == invocation.arguments[0] }.maxByOrNull { attempt -> attempt.attemptNo } + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val attempt = invocation.arguments[0] as PlanIssueAttemptEntity + store[attempt.attemptId] = attempt + attempt + } + return repository + } + + private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { + val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) + `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + return repository + } + + private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun now(): LocalDateTime = LocalDateTime.now() + + private fun planningProperties(): PlanningProperties = + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300) + + private fun transactionTemplate(): TransactionTemplate = + TransactionTemplate( + object : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + override fun commit(status: TransactionStatus) = Unit + override fun rollback(status: TransactionStatus) = Unit + } + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + @Suppress("UNCHECKED_CAST") + private fun anyOrNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt deleted file mode 100644 index 506b63e..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import org.springframework.data.jpa.repository.Query -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.TransactionStatus -import org.springframework.transaction.support.SimpleTransactionStatus -import org.springframework.transaction.support.TransactionTemplate -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.entity.JobStatus -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import space.nstart.pcp_tgu_service.entity.VisibilityRefreshJobEntity -import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository -import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository -import java.time.LocalDateTime -import java.util.Optional - -class JobRerunFlowTest { - - @Test - fun `enqueue does not reset running visibility job lock`() { - val query = visibilityUpsertQuery() - - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.status")) - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_by")) - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_until")) - } - - @Test - fun `enqueue does not reset running recalculation job lock`() { - val query = recalculationUpsertQuery() - - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status")) - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by")) - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until")) - } - - @Test - fun `enqueue running job sets rerunRequested to true`() { - assertTrue( - visibilityUpsertQuery() - .contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN TRUE") - ) - assertTrue( - recalculationUpsertQuery() - .contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE") - ) - } - - @Test - fun `visibility markDone with rerunRequested moves job to pending`() { - val spacecraftId = "SC-1" - val jobStore = linkedMapOf( - spacecraftId to VisibilityRefreshJobEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - status = JobStatus.PENDING, - rerunRequested = true - ) - ) - val repository = visibilityJobRepository(jobStore) - val externalPointsClient = mock(ExternalPointsClient::class.java) - val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) - `when`( - externalPointsClient.fetchRawVisibilityPayload( - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull(), - anyNonNull() - ) - ).thenReturn("[]") - val worker = VisibilityRefreshWorker( - planningProperties = planningProperties(), - visibilityRefreshJobRepository = repository, - spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), - externalPointsClient = externalPointsClient, - spacecraftVisibilitySnapshotService = snapshotService, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.PENDING, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `visibility markDone without rerunRequested moves job to done`() { - val spacecraftId = "SC-2" - val jobStore = linkedMapOf( - spacecraftId to VisibilityRefreshJobEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - status = JobStatus.PENDING - ) - ) - val repository = visibilityJobRepository(jobStore) - val externalPointsClient = mock(ExternalPointsClient::class.java) - val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) - `when`( - externalPointsClient.fetchRawVisibilityPayload( - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull(), - anyNonNull() - ) - ).thenReturn("[]") - val worker = VisibilityRefreshWorker( - planningProperties = planningProperties(), - visibilityRefreshJobRepository = repository, - spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), - externalPointsClient = externalPointsClient, - spacecraftVisibilitySnapshotService = snapshotService, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.DONE, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `recalculation markDone with rerunRequested moves job to pending`() { - val spacecraftId = "SC-3" - val jobStore = linkedMapOf( - spacecraftId to PlanRecalculationJobEntity( - spacecraftId = spacecraftId, - status = JobStatus.PENDING, - rerunRequested = true - ) - ) - val repository = recalculationJobRepository(jobStore) - val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) - val worker = PlanRecalculationWorker( - planningProperties = planningProperties(), - planRecalculationJobRepository = repository, - rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.PENDING, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `recalculation markDone without rerunRequested moves job to done`() { - val spacecraftId = "SC-4" - val jobStore = linkedMapOf( - spacecraftId to PlanRecalculationJobEntity( - spacecraftId = spacecraftId, - status = JobStatus.PENDING - ) - ) - val repository = recalculationJobRepository(jobStore) - val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) - val worker = PlanRecalculationWorker( - planningProperties = planningProperties(), - planRecalculationJobRepository = repository, - rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.DONE, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - private fun visibilityUpsertQuery(): String = - VisibilityRefreshJobRepository::class.java - .getMethod( - "upsertPending", - String::class.java, - java.lang.Long::class.java, - String::class.java, - LocalDateTime::class.java - ) - .getAnnotation(Query::class.java) - .value - - private fun recalculationUpsertQuery(): String = - PlanRecalculationJobRepository::class.java - .getMethod( - "upsertPending", - String::class.java, - String::class.java, - LocalDateTime::class.java - ) - .getAnnotation(Query::class.java) - .value - - private fun visibilityJobRepository( - store: MutableMap - ): VisibilityRefreshJobRepository { - val repository = mock(VisibilityRefreshJobRepository::class.java) - `when`( - repository.findPendingForUpdate( - anyNonNull(), - org.mockito.ArgumentMatchers.anyInt() - ) - ).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val limit = invocation.arguments[1] as Int - store.values - .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } - .sortedBy { job -> job.requestedAt } - .take(limit) - } - `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> - Optional.ofNullable(store[invocation.arguments[0]]) - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val job = invocation.arguments[0] as VisibilityRefreshJobEntity - store[job.spacecraftId] = job - job - } - `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> - val jobs = invocation.arguments[0] as Iterable - jobs.forEach { job -> store[job.spacecraftId] = job } - jobs.toList() - } - return repository - } - - private fun recalculationJobRepository( - store: MutableMap - ): PlanRecalculationJobRepository { - val repository = mock(PlanRecalculationJobRepository::class.java) - `when`( - repository.findPendingForUpdate( - anyNonNull(), - org.mockito.ArgumentMatchers.anyInt() - ) - ).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val limit = invocation.arguments[1] as Int - store.values - .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } - .sortedBy { job -> job.requestedAt } - .take(limit) - } - `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> - Optional.ofNullable(store[invocation.arguments[0]]) - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val job = invocation.arguments[0] as PlanRecalculationJobEntity - store[job.spacecraftId] = job - job - } - `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> - val jobs = invocation.arguments[0] as Iterable - jobs.forEach { job -> store[job.spacecraftId] = job } - jobs.toList() - } - return repository - } - - private fun isExpiredRunning(status: JobStatus, lockedUntil: LocalDateTime?, now: LocalDateTime): Boolean = - status == JobStatus.RUNNING && - lockedUntil != null && - lockedUntil.isBefore(now) - - private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) - - private fun transactionTemplate(): TransactionTemplate = - TransactionTemplate( - object : PlatformTransactionManager { - override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() - override fun commit(status: TransactionStatus) = Unit - override fun rollback(status: TransactionStatus) = Unit - } - ) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - org.mockito.ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index 5dde633..b3315df 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -72,6 +72,37 @@ class PlanCalculationServiceTest { assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime }) } + @Test + fun `calculatePlansAfter rebuilds a fresh chain when the base plan is entirely in the past`() { + // Регресс: при обновлении окна тянутся из [now, now+Nд], и принятый базовый план + // со временем уходит в прошлое за левый край окна. Тогда хвост нельзя сопоставить + // с базой — но расписание не должно схлопнуться в пустоту (иначе supersede погасил + // бы всё будущее). Должна строиться свежая цепочка с самого раннего окна. + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val futureStart = LocalDateTime.of(2026, 3, 10, 10, 0) + val spacecraftPoints = spacecraftPoints( + window("KPP-1", futureStart), + window("KPP-2", futureStart.plusHours(8)), + window("KPP-3", futureStart.plusHours(16)), + window("KPP-4", futureStart.plusHours(23).plusMinutes(55), 15), + window("KPP-5", futureStart.plusHours(24).plusMinutes(10)) + ) + // Базовый план целиком раньше любого доступного окна (состарился и выпал из горизонта). + val agedOutBasePlan = CalculatedPlan( + spacecraftId = "25544", + startTime = futureStart.minusDays(2), + endTime = futureStart.minusDays(1), + kppId = "KPP-OLD" + ) + + val plans = service.calculatePlansAfter(spacecraftPoints, agedOutBasePlan) + + assertChainInvariant(plans) + assertTrue(plans.isNotEmpty(), "расписание не должно схлопнуться в пустоту: $plans") + assertEquals(service.calculatePlans(spacecraftPoints), plans, "должна строиться та же свежая цепочка") + assertEquals(futureStart, plans.first().startTime) + } + @Test fun `calculatePlans returns empty list when only one window is available`() { val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L) @@ -307,6 +338,7 @@ class PlanCalculationServiceTest { externalUpdateFixedDelayMs = 0, notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, + reissuePrepMinutes = 30, workerLockTtlSeconds = 300 ) ) 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 32fd2f4..3f1e376 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 @@ -1,8 +1,10 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue 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.assertTrue import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.Mockito.never @@ -16,6 +18,7 @@ import org.springframework.transaction.support.TransactionTemplate import space.nstart.pcp_tgu_service.config.PlanningProperties 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.ProcessStartResponse import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus @@ -26,6 +29,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 space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.Optional import java.util.UUID @@ -33,198 +37,114 @@ import java.util.UUID class PlanIssueAndDecisionFlowTest { @Test - fun `issue worker creates one active attempt and repeated issue of same plan creates new attempt after state is cleared`() { + fun `issuePlanNow creates one active attempt and repeated manual issue creates a new attempt after state cleared`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED) val planStore = linkedMapOf(plan.planId to plan) val attemptStore = linkedMapOf() val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) - val plannedRepository = plannedRepository(planStore) - val attemptRepository = attemptRepository(attemptStore) - val stateRepository = stateRepository(state) - val starter = mock(BpmnPlanIssueProcessStarter::class.java) - `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> - val p = invocation.arguments[0] as PlannedPlanEntity - val a = invocation.arguments[1] as PlanIssueAttemptEntity - ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) - } - val worker = IssueDuePlanWorker( - planningProperties = planningProperties(), - plannedPlanRepository = plannedRepository, - planIssueAttemptRepository = attemptRepository, - spacecraftPlanningStateJpaRepository = stateRepository, - bpmnPlanIssueProcessStarter = starter, - transactionTemplate = transactionTemplate() - ) + val worker = issueWorker(planStore, attemptStore, state) val firstResponse = worker.issuePlanNow(plan.planId) - val blockedResponse = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() + val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() state.activePlanId = null state.activeAttemptId = null - plan.status = PlannedPlanStatus.PLANNED + plan.status = PlannedPlanStatus.REJECTED val secondResponse = worker.issuePlanNow(plan.planId) - assertEquals(1, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.attemptNo) - assertEquals(2, attemptStore.values.first { it.attemptId == secondResponse.attemptId }.attemptNo) + assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo) + assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo) assertNotEquals(firstResponse.attemptId, secondResponse.attemptId) - assertEquals(PlanIssueAttemptStatus.STARTED, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.status) - assertEquals(firstResponse.processInstanceKey, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.processInstanceKey) assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) - assertTrue(blockedResponse?.message?.startsWith("404 NOT_FOUND") == true) + assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true) } @Test - fun `mark started does not roll accepted plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.ACCEPTED - planStore.getValue(response.planId).status = PlannedPlanStatus.ACCEPTED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `mark started does not roll rejected planned plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED - planStore.getValue(response.planId).status = PlannedPlanStatus.PLANNED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.PLANNED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `mark started does not roll rejected expired plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED - planStore.getValue(response.planId).status = PlannedPlanStatus.EXPIRED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.EXPIRED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `accepted decision sets base plan clears active ids and enqueues recalculation once`() { + fun `accepted decision moves plan to AWAITING_LAYIN sets base and enqueues slots update once`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase(plan, attempt, state, processedRepository, recalculationJobService, slotsUpdateJobService) + val useCase = decisionUseCase(plan, attempt, state, processedRepository, slotsUpdateJobService) val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED) useCase.handle(message) useCase.handle(message) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) assertEquals(plan.planId, state.currentBasePlanId) - assertEquals(null, state.activePlanId) - assertEquals(null, state.activeAttemptId) - verify(recalculationJobService).enqueue(spacecraftId, "PLAN_ACCEPTED") + assertNull(state.activePlanId) + assertNull(state.activeAttemptId) + assertNull(state.urgentReissueFrom) verify(slotsUpdateJobService).enqueue(plan.planId) } @Test - fun `rejected before cutoff reopens same plan without direct new attempt`() { + fun `rejected decision marks plan REJECTED and arms urgent reissue`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.PLANNED, plan.status) - assertEquals(null, state.activeAttemptId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertEquals(PlannedPlanStatus.REJECTED, plan.status) + assertNull(state.activeAttemptId) + assertNotNull(state.urgentReissueFrom) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test - fun `rejected after cutoff expires plan and promotes next planned plan`() { + fun `layin failed on current base marks LAYIN_FAILED and arms urgent reissue idempotently`() { + assertNegativeFeedbackArmsReissue(PlannedPlanStatus.LAYIN_FAILED) { useCase, message -> + useCase.handleLayinFailed(message) + } + } + + @Test + fun `plan not accepted on current base marks NOT_ACCEPTED and arms urgent reissue idempotently`() { + assertNegativeFeedbackArmsReissue(PlannedPlanStatus.NOT_ACCEPTED) { useCase, message -> + useCase.handleNotAccepted(message) + } + } + + @Test + fun `negative feedback for a plan that is not the current base is ignored`() { val spacecraftId = "25544" - val currentPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusMinutes(2), LocalDateTime.now().plusMinutes(20), PlannedPlanStatus.WAITING_DECISION) - val nextPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2), PlannedPlanStatus.PLANNED) - val attempt = attempt(currentPlan.planId, status = PlanIssueAttemptStatus.STARTED) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = currentPlan.planId, activeAttemptId = attempt.attemptId) - val planStore = linkedMapOf(currentPlan.planId to currentPlan, nextPlan.planId to nextPlan) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) - val useCase = decisionUseCase( - plan = currentPlan, - attempt = attempt, - state = state, - plannedRepository = plannedRepository(planStore), - recalculationJobService = recalculationJobService + val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN) + val stalePlan = plannedPlan(spacecraftId, time(2), time(3), PlannedPlanStatus.REJECTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository(), + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base, stalePlan.planId to stalePlan)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() ) - useCase.handle(decisionMessage(spacecraftId, currentPlan.planId, attempt.attemptId, PlanDecision.REJECTED)) + useCase.handleLayinFailed(feedbackMessage(spacecraftId, stalePlan.planId)) - assertEquals(PlannedPlanStatus.EXPIRED, currentPlan.status) - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(null, state.activeAttemptId) - assertEquals(true, nextPlan.issueNotBefore != null) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertEquals(PlannedPlanStatus.REJECTED, stalePlan.status) + assertNull(state.urgentReissueFrom) } @Test fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val activeAttemptId = UUID.randomUUID() val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)) @@ -232,59 +152,66 @@ class PlanIssueAndDecisionFlowTest { assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) assertEquals(plan.planId, state.activePlanId) assertEquals(activeAttemptId, state.activeAttemptId) - assertEquals(null, state.currentBasePlanId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_ACCEPTED") + assertNull(state.currentBasePlanId) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test fun `terminal attempt is not reprocessed by new decision event`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.ACCEPTED) - val oldEventId = UUID.randomUUID() - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.ACCEPTED) - attempt.decisionEventId = oldEventId - val state = SpacecraftPlanningStateEntity( - spacecraftId = spacecraftId, - currentBasePlanId = plan.planId, - activePlanId = null, - activeAttemptId = null - ) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.ACCEPTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = plan.planId) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(oldEventId, attempt.decisionEventId) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) assertEquals(plan.planId, state.currentBasePlanId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertNull(state.urgentReissueFrom) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } + private fun assertNegativeFeedbackArmsReissue( + expectedStatus: PlannedPlanStatus, + invoke: (HandlePlanDecisionUseCase, PlanExecutionFeedbackMessage) -> Unit + ) { + val spacecraftId = "25544" + val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId) + 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(linkedMapOf(base.planId to base)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() + ) + val message = feedbackMessage(spacecraftId, base.planId) + + invoke(useCase, message) + invoke(useCase, message) + + assertEquals(expectedStatus, base.status) + assertNotNull(state.urgentReissueFrom) + } + private fun decisionUseCase( plan: PlannedPlanEntity, attempt: PlanIssueAttemptEntity, state: SpacecraftPlanningStateEntity, processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(), - recalculationJobService: PlanRecalculationJobService = mock(PlanRecalculationJobService::class.java), - slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), - plannedRepository: PlannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)) + slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) ): HandlePlanDecisionUseCase = HandlePlanDecisionUseCase( processedPlanDecisionEventRepository = processedRepository, planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)), - plannedPlanRepository = plannedRepository, + plannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)), spacecraftPlanningStateJpaRepository = stateRepository(state), - planRecalculationJobService = recalculationJobService, acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, planningProperties = planningProperties() ) @@ -292,20 +219,21 @@ class PlanIssueAndDecisionFlowTest { private fun issueWorker( planStore: MutableMap, attemptStore: MutableMap, - beforeStartReturns: (ProcessStartResponse) -> Unit + state: SpacecraftPlanningStateEntity ): IssueDuePlanWorker { val starter = mock(BpmnPlanIssueProcessStarter::class.java) `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> val p = invocation.arguments[0] as PlannedPlanEntity val a = invocation.arguments[1] as PlanIssueAttemptEntity ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) - .also(beforeStartReturns) } return IssueDuePlanWorker( planningProperties = planningProperties(), + spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), plannedPlanRepository = plannedRepository(planStore), planIssueAttemptRepository = attemptRepository(attemptStore), - spacecraftPlanningStateJpaRepository = stateRepository(SpacecraftPlanningStateEntity(spacecraftId = planStore.values.first().spacecraftId)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + planProjectionService = mock(PlanProjectionService::class.java), bpmnPlanIssueProcessStarter = starter, transactionTemplate = transactionTemplate() ) @@ -326,26 +254,6 @@ class PlanIssueAndDecisionFlowTest { store[plan.planId] = plan plan } - `when`(repository.findDueForUpdate(anyNonNull(), anyNonNull(), org.mockito.ArgumentMatchers.anyInt())).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val issueBefore = invocation.arguments[1] as LocalDateTime - store.values.filter { plan -> - plan.status == PlannedPlanStatus.PLANNED && - (plan.issueNotBefore == null || !plan.issueNotBefore!!.isAfter(now)) && - !plan.startTime.isAfter(issueBefore) && - plan.startTime.isAfter(now) - }.sortedBy { plan -> plan.startTime } - } - `when`(repository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values - .filter { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.status == invocation.arguments[1] && - plan.startTime > invocation.arguments[2] as LocalDateTime - } - .minByOrNull { plan -> plan.startTime } - } return repository } @@ -376,7 +284,7 @@ class PlanIssueAndDecisionFlowTest { spacecraftId: String, start: LocalDateTime, end: LocalDateTime, - status: PlannedPlanStatus = PlannedPlanStatus.PLANNED + status: PlannedPlanStatus ): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(), @@ -412,10 +320,19 @@ class PlanIssueAndDecisionFlowTest { reason = null ) + private fun feedbackMessage(spacecraftId: String, planId: UUID): PlanExecutionFeedbackMessage = + PlanExecutionFeedbackMessage( + eventId = UUID.randomUUID(), + spacecraftId = spacecraftId, + planId = planId, + occurredAt = LocalDateTime.now(), + reason = null + ) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate( 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 new file mode 100644 index 0000000..4efb71c --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -0,0 +1,115 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +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 space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +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.entity.SpacecraftVisibilitySnapshotEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class PlanProjectionServiceTest { + + private val spacecraftId = "25544" + + @Test + fun `returns empty projection when there is no snapshot`() { + val calculation = mock(PlanCalculationService::class.java) + val service = service(snapshot = null, calculation = calculation) + + val result = service.project(spacecraftId) + + assertTrue(result.isEmpty()) + verify(calculation, never()).calculatePlans(anyNonNull()) + } + + @Test + fun `builds a fresh chain when there is no base plan`() { + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(1), time(4))) + `when`(calculation.calculatePlans(anyNonNull())).thenReturn(expected) + val service = service(state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId), calculation = calculation) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + verify(calculation).calculatePlans(anyNonNull()) + verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull()) + } + + @Test + fun `builds the tail after the current base plan`() { + val base = PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = time(0), + endTime = time(3), + kppId = "KPP-1", + status = PlannedPlanStatus.AWAITING_LAYIN + ) + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(2), time(5))) + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenReturn(expected) + val service = service( + state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId), + basePlan = base, + calculation = calculation + ) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + verify(calculation).calculatePlansAfter(anyNonNull(), anyNonNull()) + verify(calculation, never()).calculatePlans(anyNonNull()) + } + + private fun service( + snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(), + state: SpacecraftPlanningStateEntity? = null, + basePlan: PlannedPlanEntity? = null, + calculation: PlanCalculationService + ): PlanProjectionService { + val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) + `when`(snapshotRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(snapshot)) + val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state)) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + val parser = mock(VisibilityPayloadParser::class.java) + `when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList())) + return PlanProjectionService(snapshotRepository, stateRepository, plannedRepository, parser, calculation) + } + + private fun snapshot(): SpacecraftVisibilitySnapshotEntity = + SpacecraftVisibilitySnapshotEntity( + spacecraftId = spacecraftId, + noradId = 25544L, + payloadJson = "[]", + payloadHash = "hash", + fetchedAt = LocalDateTime.now() + ) + + private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt new file mode 100644 index 0000000..602de33 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -0,0 +1,78 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +class PlanQueryServiceTest { + + private val spacecraftId = "25544" + + @Test + fun `getAllPlans merges persisted with projection and dedupes by business key`() { + val persisted = plannedPlan(time(1), time(4), PlannedPlanStatus.AWAITING_LAYIN) + // Та же бизнес-ключ-тройка, что у persisted — должна быть выкинута из проекции. + val duplicateProjected = calculated(persisted.startTime, persisted.endTime) + val freshProjected = calculated(time(5), time(8)) + + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(persisted)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L)) + ) + val projection = mock(PlanProjectionService::class.java) + `when`(projection.project(spacecraftId)).thenReturn(listOf(duplicateProjected, freshProjected)) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertEquals(2, result.size) + val persistedResponse = result.first { it.startTime == persisted.startTime } + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) + val projectedResponse = result.first { it.startTime == freshProjected.startTime } + assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status) + // Отсортировано по startTime. + assertTrue(result[0].startTime <= result[1].startTime) + } + + @Test + fun `getAllPlans drops persisted plans older than the history window`() { + val old = plannedPlan(time(-240), time(-239), PlannedPlanStatus.ACCEPTED) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(old)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(emptyList()) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertTrue(result.isEmpty()) + } + + private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status + ) + + private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt deleted file mode 100644 index 11ebc40..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.CalculatedPlan -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -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.entity.SpacecraftVisibilitySnapshotEntity -import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser -import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository -import java.time.LocalDateTime -import java.util.Optional -import java.util.UUID - -class RebuildPlanScheduleUseCaseTest { - - @Test - fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { - val spacecraftId = "25544" - val planStore = linkedMapOf() - // Устаревший PLANNED-план, который уже стартовал, но ещё не завершился — - // должен быть погашен по новому условию end_time > now (а не start_time > now). - val stalePlanId = UUID.randomUUID() - planStore[stalePlanId] = PlannedPlanEntity( - planId = stalePlanId, - spacecraftId = spacecraftId, - startTime = LocalDateTime.now().minusHours(1), - endTime = LocalDateTime.now().plusHours(1), - kppId = "KPP-STALE", - status = PlannedPlanStatus.PLANNED - ) - val plannedRepository = plannedRepository(planStore) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) - val stateRepository = stateRepository(state) - val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - `when`(snapshotRepository.findById(spacecraftId)).thenReturn( - Optional.of( - SpacecraftVisibilitySnapshotEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - payloadJson = rawPayload(), - payloadHash = "hash", - fetchedAt = LocalDateTime.now() - ) - ) - ) - val calculationService = mock(PlanCalculationService::class.java) - val baseTime = LocalDateTime.now().plusHours(1) - val firstPlan = CalculatedPlan(spacecraftId, baseTime, baseTime.plusHours(2), "KPP-1") - val secondPlan = CalculatedPlan(spacecraftId, baseTime.plusHours(3), baseTime.plusHours(5), "KPP-2") - `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan, secondPlan)) - val service = RebuildPlanScheduleUseCase( - spacecraftVisibilitySnapshotRepository = snapshotRepository, - spacecraftPlanningStateJpaRepository = stateRepository, - plannedPlanRepository = plannedRepository, - visibilityPayloadParser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()), - planCalculationService = calculationService, - planningProperties = planningProperties() - ) - - service.rebuild(spacecraftId) - val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId - // Устаревший активный план без места в новой цепочке погашен уже на первом rebuild. - assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.getValue(stalePlanId).status) - - `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) - service.rebuild(spacecraftId) - - assertEquals(firstPlanId, planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId) - assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.values.first { plan -> plan.kppId == "KPP-2" }.status) - assertNotEquals(planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId, planStore.values.first { plan -> plan.kppId == "KPP-2" }.planId) - assertEquals(2, state.chainVersion) - } - - private fun plannedRepository(store: MutableMap): PlannedPlanRepository { - val repository = mock(PlannedPlanRepository::class.java) - `when`( - repository.upsertPlannedByBusinessKey( - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull() - ) - ).thenAnswer { invocation -> - val planId = invocation.arguments[0] as UUID - val spacecraftId = invocation.arguments[1] as String - val startTime = invocation.arguments[2] as LocalDateTime - val endTime = invocation.arguments[3] as LocalDateTime - val kppId = invocation.arguments[4] as String - val issueNotBefore = invocation.arguments[5] as LocalDateTime - val basePlanId = invocation.arguments[6] as UUID? - val chainVersion = invocation.arguments[7] as Long - val now = invocation.arguments[8] as LocalDateTime - val existing = store.values.firstOrNull { plan -> - plan.spacecraftId == spacecraftId && - plan.startTime == startTime && - plan.endTime == endTime && - plan.kppId == kppId - } - - if (existing != null) { - if (existing.status == PlannedPlanStatus.SUPERSEDED || existing.status == PlannedPlanStatus.EXPIRED) { - existing.status = PlannedPlanStatus.PLANNED - } - if (existing.status == PlannedPlanStatus.PLANNED) { - existing.issueNotBefore = issueNotBefore - existing.basePlanId = basePlanId - existing.chainVersion = chainVersion - existing.updatedAt = now - } - existing - } else { - PlannedPlanEntity( - planId = planId, - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId, - status = PlannedPlanStatus.PLANNED, - issueNotBefore = issueNotBefore, - basePlanId = basePlanId, - chainVersion = chainVersion, - createdAt = now, - updatedAt = now - ).also { plan -> store[plan.planId] = plan } - } - } - `when`(repository.findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values.firstOrNull { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.startTime == invocation.arguments[1] && - plan.endTime == invocation.arguments[2] && - plan.kppId == invocation.arguments[3] - } - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val plan = invocation.arguments[0] as PlannedPlanEntity - store[plan.planId] = plan - plan - } - `when`(repository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values - .filter { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.status == invocation.arguments[1] && - plan.endTime > invocation.arguments[2] as LocalDateTime - } - .sortedBy { plan -> plan.startTime } - } - return repository - } - - private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { - val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) - `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) - `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - return repository - } - - private fun rawPayload(): String = - """[{"noradId":25544,"stationId":7,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}]""" - - private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - org.mockito.ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt index 7ac41da..576c1b1 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt @@ -35,9 +35,8 @@ class VisibilityFlowTest { } @Test - fun `same visibility payload updates fetchedAt but does not enqueue recalculation`() { + fun `same visibility payload updates fetchedAt and persists snapshot`() { val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val payload = rawPayloadArray(stationId = 7) val existing = SpacecraftVisibilitySnapshotEntity( spacecraftId = "25544", @@ -48,27 +47,25 @@ class VisibilityFlowTest { ) `when`(repository.findById("25544")).thenReturn(Optional.of(existing)) `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val service = SpacecraftVisibilitySnapshotService(repository, parser()) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt) - verify(recalculationJobService, never()).enqueue("25544", "VISIBILITY_CHANGED") + verify(repository, times(1)).save(anyNonNull()) } @Test - fun `changed visibility payload stores raw payload and enqueues recalculation`() { + fun `new visibility payload stores raw snapshot`() { val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) `when`(repository.findById("25544")).thenReturn(Optional.empty()) `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val service = SpacecraftVisibilitySnapshotService(repository, parser()) val payload = rawPayloadArray(stationId = 7) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) verify(repository, times(1)).save(anyNonNull()) - verify(recalculationJobService).enqueue("25544", "VISIBILITY_CHANGED") } private fun parser(): VisibilityPayloadParser =