Редизайн pcp-tgu-service: хранить только выданное, будущее считать на лету

Раньше вся будущая цепочка планов материализовалась в 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.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 14:32:51 +03:00
parent d630a891f6
commit c76a823dbc
52 changed files with 1587 additions and 1377 deletions
+7 -3
View File
@@ -31,19 +31,23 @@ springdoc:
planning: planning:
notification-before-start-minutes: 263 notification-before-start-minutes: 263
target-plan-duration-minutes: 180 target-plan-duration-minutes: 1440
target-plan-duration-delta-minutes: 60 target-plan-duration-delta-minutes: 240
external-update-enabled: true external-update-enabled: true
external-points-lookahead-days: 3 external-points-lookahead-days: 10
in-memory-history-days: 14 in-memory-history-days: 14
external-update-fixed-delay-ms: 600000 external-update-fixed-delay-ms: 600000
notification-check-fixed-delay-ms: 30000 notification-check-fixed-delay-ms: 30000
min-reissue-lead-minutes: 5 min-reissue-lead-minutes: 5
reissue-prep-minutes: 30
worker-lock-ttl-seconds: 300 worker-lock-ttl-seconds: 300
topics: topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1
tgu: tgu:
test-controller: test-controller:
@@ -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`).
Масштаб: **100150 КА**, горизонт планирования **до 10 суток**, деплой **23 реплики** (горизонтальное масштабирование уже обеспечено координацией через БД: `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<CalculatedPlan>` и `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 -> терминальный статус + срочная выдача ближайшей ЗРВ
```
@@ -35,6 +35,9 @@ data class PlanningProperties(
/** Минимальный запас времени для повторной выдачи после REJECTED. */ /** Минимальный запас времени для повторной выдачи после REJECTED. */
val minReissueLeadMinutes: Long, val minReissueLeadMinutes: Long,
/** Минимальное время на подготовку срочно перевыдаваемого плана (после негатива), в минутах. */
val reissuePrepMinutes: Long,
/** TTL lease'а worker job claim в секундах. */ /** TTL lease'а worker job claim в секундах. */
val workerLockTtlSeconds: Long val workerLockTtlSeconds: Long
) )
@@ -9,5 +9,14 @@ data class TopicsProperties(
val visibilityWindowsChanged: String, val visibilityWindowsChanged: String,
/** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */ /** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */
val satellitePlanDecision: String val satellitePlanDecision: String,
/** Topic-событие «не заложен» по принятому плану. */
val layinFailed: String,
/** Topic-событие «не принят» по принятому плану. */
val planNotAccepted: String,
/** Topic-событие об изменении параметров станций (перезапрос ЗРВ). */
val stationParamsChanged: String
) )
@@ -17,20 +17,20 @@ class PlanController(
private val planQueryService: PlanQueryService private val planQueryService: PlanQueryService
) { ) {
/** Возвращает планы по всем КА с указанным окном подтверждённой истории. */ /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
@GetMapping @GetMapping
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono<List<PlanResponse>> = fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono<List<PlanResponse>> =
Mono.fromCallable { Mono.fromCallable {
planQueryService.getAllPlans(historyDays).map(PlanResponse::from) planQueryService.getAllPlans(historyDays)
}.subscribeOn(Schedulers.boundedElastic()) }.subscribeOn(Schedulers.boundedElastic())
/** Возвращает цепочку планов для одного КА. */ /** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
@GetMapping("/{spacecraftId}") @GetMapping("/{spacecraftId}")
fun getPlansBySpacecraftId( fun getPlansBySpacecraftId(
@PathVariable spacecraftId: String, @PathVariable spacecraftId: String,
@RequestParam(defaultValue = "7") historyDays: Long @RequestParam(defaultValue = "7") historyDays: Long
): Mono<List<PlanResponse>> = ): Mono<List<PlanResponse>> =
Mono.fromCallable { Mono.fromCallable {
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from) planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
}.subscribeOn(Schedulers.boundedElastic()) }.subscribeOn(Schedulers.boundedElastic())
} }
@@ -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.integration.api.ExternalPointsClient
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker 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.PlanQueryService
import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService
import space.nstart.pcp_tgu_service.service.PlatformService 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.TestPlanDecisionService
import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService
import java.nio.file.Files import java.nio.file.Files
@@ -37,8 +36,7 @@ import java.util.UUID
@RequestMapping("/test/tgu") @RequestMapping("/test/tgu")
class TestTguController( class TestTguController(
private val visibilityRefreshJobService: VisibilityRefreshJobService, private val visibilityRefreshJobService: VisibilityRefreshJobService,
private val planRecalculationJobService: PlanRecalculationJobService, private val planProjectionService: PlanProjectionService,
private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase,
private val issueDuePlanWorker: IssueDuePlanWorker, private val issueDuePlanWorker: IssueDuePlanWorker,
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase, private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
private val testPlanDecisionService: TestPlanDecisionService, private val testPlanDecisionService: TestPlanDecisionService,
@@ -56,12 +54,11 @@ class TestTguController(
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER")
}.subscribeOn(Schedulers.boundedElastic()) }.subscribeOn(Schedulers.boundedElastic())
/** Ставит и сразу выполняет rebuild persisted schedule для КА. */ /** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */
@PostMapping("/spacecraft/{spacecraftId}/rebuild-plans") @GetMapping("/spacecraft/{spacecraftId}/projection")
fun rebuildPlans(@PathVariable spacecraftId: String): Mono<Void> = fun getProjection(@PathVariable spacecraftId: String): Mono<List<PlanResponse>> =
Mono.fromRunnable<Void> { Mono.fromCallable {
planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER") planProjectionService.project(spacecraftId).map(PlanResponse::projected)
rebuildPlanScheduleUseCase.rebuild(spacecraftId)
}.subscribeOn(Schedulers.boundedElastic()) }.subscribeOn(Schedulers.boundedElastic())
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */ /** Принудительно выдаёт конкретный persisted plan через новую attempt. */
@@ -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?
)
@@ -1,7 +1,9 @@
package space.nstart.pcp_tgu_service.dto 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.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
@@ -43,5 +45,26 @@ data class PlanResponse(
kppId = plan.kppId, kppId = plan.kppId,
status = plan.status 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))
}
} }
} }
@@ -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?
)
@@ -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 = "")
}
@@ -47,9 +47,6 @@ class PlannedPlanEntity(
@Column(name = "accepted_at") @Column(name = "accepted_at")
var acceptedAt: LocalDateTime? = null, var acceptedAt: LocalDateTime? = null,
@Column(name = "superseded_by_plan_id")
var supersededByPlanId: UUID? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(), var createdAt: LocalDateTime = LocalDateTime.now(),
@@ -4,12 +4,24 @@ package space.nstart.pcp_tgu_service.entity
* Жизненный цикл persisted business-plan в расписании TGU. * Жизненный цикл persisted business-plan в расписании TGU.
*/ */
enum class PlannedPlanStatus { enum class PlannedPlanStatus {
/** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */
PLANNED, PLANNED,
ISSUING, ISSUING,
WAITING_DECISION, WAITING_DECISION,
ACCEPTED, ACCEPTED,
/** Принят и оптимистично считается заложенным; текущий базовый план цепочки. */
AWAITING_LAYIN,
/** Подтверждённая закладка (терминальный успех). */
LAID_IN,
/** Пришло событие «не заложен» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
LAYIN_FAILED,
/** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
NOT_ACCEPTED,
REJECTED, REJECTED,
SUPERSEDED,
EXPIRED,
START_AMBIGUOUS START_AMBIGUOUS
} }
@@ -27,6 +27,13 @@ class SpacecraftPlanningStateEntity(
@Column(name = "chain_version", nullable = false) @Column(name = "chain_version", nullable = false)
var chainVersion: Long = 0, var chainVersion: Long = 0,
/**
* Если задано — ближайшую ЗРВ со стартом не раньше этого времени нужно выдать срочно
* (после REJECTED / «не заложен» / «не принят»), не дожидаясь обычного issue_not_before.
*/
@Column(name = "urgent_reissue_from")
var urgentReissueFrom: LocalDateTime? = null,
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now() var updatedAt: LocalDateTime = LocalDateTime.now()
) { ) {
@@ -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 = ""
)
}
@@ -1,7 +0,0 @@
package space.nstart.pcp_tgu_service.entity
/** Описывает способ создания отслеживаемого плана. */
enum class TrackedPlanSource {
AUTO_CHAIN,
MANUAL
}
@@ -1,9 +0,0 @@
package space.nstart.pcp_tgu_service.entity
/** Описывает состояние жизненного цикла отслеживаемого плана. */
enum class TrackedPlanStatus {
NEW,
WAITING_CONFIRMATION,
CONFIRMED,
SUPERSEDED
}
@@ -10,6 +10,7 @@ import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
/** /**
* Парсит raw payload баллистики в доменную модель расчёта без доступа к БД. * Парсит raw payload баллистики в доменную модель расчёта без доступа к БД.
@@ -42,11 +43,18 @@ class VisibilityPayloadParser(
return SpacecraftPoints(spacecraftId = spacecraftId, points = points) 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 = private fun toLocalDateTime(value: String): LocalDateTime =
try { try {
Instant.parse(value).atZone(zoneId).toLocalDateTime() Instant.parse(value).atZone(zoneId).toLocalDateTime()
} catch (_: DateTimeParseException) { } catch (_: DateTimeParseException) {
LocalDateTime.parse(value) LocalDateTime.parse(value)
} }.truncatedTo(ChronoUnit.MICROS)
} }
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<PlanRecalculationJobEntity, String> {
/** Атомарно ставит или переоткрывает 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<PlanRecalculationJobEntity>
}
@@ -4,44 +4,25 @@ import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
/** Доступ к persisted расписанию business-plan. */ /** Доступ к persisted планам (только выданные и их история; будущее не хранится). */
interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> { interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
/** Ищет persisted plan по стабильному business key. */ /** Возвращает планы КА в порядке старта (для read-проекции и истории). */
fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(
spacecraftId: String,
startTime: LocalDateTime,
endTime: LocalDateTime,
kppId: String
): PlannedPlanEntity?
/** Возвращает планы КА в порядке старта. */
fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<PlannedPlanEntity> fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<PlannedPlanEntity>
/** Возвращает все планы в порядке старта. */ /** Возвращает все планы в порядке старта (для read-проекции и истории). */
fun findAllByOrderByStartTime(): List<PlannedPlanEntity> fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
/** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */ /**
fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( * Создаёт новую строку выдаваемого плана сразу в статусе ISSUING.
spacecraftId: String, *
status: PlannedPlanStatus, * В on-demand-модели каждая выдача — это INSERT с новым plan_id; апсерта по бизнес-ключу нет,
endTime: LocalDateTime * повторная выдача того же окна после негатива создаёт отдельную строку (история хранится вечно).
): List<PlannedPlanEntity> */
/** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */
fun findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(
spacecraftId: String,
status: PlannedPlanStatus,
startTime: LocalDateTime
): PlannedPlanEntity?
/** Атомарно создаёт или обновляет PLANNED plan по business key, сохраняя stable planId. */
@Query( @Query(
value = """ value = """
WITH upsert AS (
INSERT INTO planned_plan( INSERT INTO planned_plan(
plan_id, plan_id,
spacecraft_id, spacecraft_id,
@@ -61,42 +42,18 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
:startTime, :startTime,
:endTime, :endTime,
:kppId, :kppId,
'PLANNED', 'ISSUING',
:issueNotBefore, :issueNotBefore,
:basePlanId, :basePlanId,
:chainVersion, :chainVersion,
:now, :now,
: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 * RETURNING *
)
SELECT *
FROM upsert
""", """,
nativeQuery = true nativeQuery = true
) )
fun upsertPlannedByBusinessKey( fun insertIssuingPlan(
@Param("planId") planId: UUID, @Param("planId") planId: UUID,
@Param("spacecraftId") spacecraftId: String, @Param("spacecraftId") spacecraftId: String,
@Param("startTime") startTime: LocalDateTime, @Param("startTime") startTime: LocalDateTime,
@@ -108,7 +65,7 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
@Param("now") now: LocalDateTime @Param("now") now: LocalDateTime
): PlannedPlanEntity ): PlannedPlanEntity
/** Блокирует plan для обработки decision event или issue state transition. */ /** Блокирует plan для обработки decision/feedback event или issue state transition. */
@Query( @Query(
value = """ value = """
SELECT * SELECT *
@@ -119,25 +76,4 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
nativeQuery = true nativeQuery = true
) )
fun lockByPlanId(@Param("planId") planId: UUID): PlannedPlanEntity? 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<PlannedPlanEntity>
} }
@@ -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<TrackedPlanEntity, UUID> {
/** Возвращает отслеживаемые планы КА в указанном статусе. */
fun findAllBySpacecraftIdAndStatus(spacecraftId: String, status: TrackedPlanStatus): List<TrackedPlanEntity>
/** Возвращает все tracked-планы КА, упорядоченные по времени старта. */
fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<TrackedPlanEntity>
/** Возвращает планы, подтверждённые с указанного момента, упорядоченные по времени старта. */
fun findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime(
spacecraftId: String,
status: TrackedPlanStatus,
confirmedAt: LocalDateTime
): List<TrackedPlanEntity>
/** Возвращает отслеживаемый план по его бизнес-полям и статусу. */
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?
}
@@ -1,11 +1,14 @@
package space.nstart.pcp_tgu_service.service package space.nstart.pcp_tgu_service.service
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException 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.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
@@ -18,7 +21,11 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaReposit
import java.time.LocalDateTime import java.time.LocalDateTime
/** /**
* Обрабатывает decision events идемпотентно по eventId и строго по attemptId. * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
*
* - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
* - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ
* запускается срочная перевыдача ([reissueOnNearestWindow]).
*/ */
@Service @Service
class HandlePlanDecisionUseCase( class HandlePlanDecisionUseCase(
@@ -26,15 +33,15 @@ class HandlePlanDecisionUseCase(
private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository,
private val plannedPlanRepository: PlannedPlanRepository, private val plannedPlanRepository: PlannedPlanRepository,
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
private val planRecalculationJobService: PlanRecalculationJobService,
private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService, 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 @Transactional
fun handle(message: PlanDecisionMessage) { fun handle(message: PlanDecisionMessage) {
val inserted = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) if (!claimEvent(message.eventId)) {
if (inserted == 0) {
return 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( private fun recordDecisionOnAttempt(
message: PlanDecisionMessage, message: PlanDecisionMessage,
attempt: PlanIssueAttemptEntity attempt: PlanIssueAttemptEntity
@@ -96,20 +147,20 @@ class HandlePlanDecisionUseCase(
val now = LocalDateTime.now() val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt) recordDecisionOnAttempt(message, attempt)
plan.status = PlannedPlanStatus.ACCEPTED plan.status = PlannedPlanStatus.AWAITING_LAYIN
plan.acceptedAt = message.decisionTime plan.acceptedAt = message.decisionTime
plan.updatedAt = now plan.updatedAt = now
state.currentBasePlanId = plan.planId state.currentBasePlanId = plan.planId
state.activePlanId = null state.activePlanId = null
state.activeAttemptId = null state.activeAttemptId = null
state.urgentReissueFrom = null
state.chainVersion += 1 state.chainVersion += 1
state.updatedAt = now state.updatedAt = now
planIssueAttemptRepository.save(attempt) planIssueAttemptRepository.save(attempt)
plannedPlanRepository.save(plan) plannedPlanRepository.save(plan)
spacecraftPlanningStateJpaRepository.save(state) spacecraftPlanningStateJpaRepository.save(state)
planRecalculationJobService.enqueue(message.spacecraftId, "PLAN_ACCEPTED")
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
} }
@@ -122,43 +173,37 @@ class HandlePlanDecisionUseCase(
val now = LocalDateTime.now() val now = LocalDateTime.now()
recordDecisionOnAttempt(message, attempt) recordDecisionOnAttempt(message, attempt)
state.activePlanId = null plan.status = PlannedPlanStatus.REJECTED
state.activeAttemptId = null
state.updatedAt = now
if (now < plan.startTime.minusMinutes(planningProperties.minReissueLeadMinutes)) {
plan.status = PlannedPlanStatus.PLANNED
plan.issueNotBefore = now
plan.updatedAt = now plan.updatedAt = now
} else {
plan.status = PlannedPlanStatus.EXPIRED
plan.updatedAt = now
promoteNextPlanOrEnqueueRebuild(plan.spacecraftId, now)
}
planIssueAttemptRepository.save(attempt) planIssueAttemptRepository.save(attempt)
plannedPlanRepository.save(plan) plannedPlanRepository.save(plan)
spacecraftPlanningStateJpaRepository.save(state)
reissueOnNearestWindow(state, plan, now)
} }
/** /**
* Decision use case только меняет state. Новую попытку позже создаст IssueDuePlanWorker. * Общий маршрут после негатива (REJECTED/«не заложен»/«не принят»): снимает активную выдачу
* и взводит срочную перевыдачу на ближайшую ЗРВ. Базовый указатель не трогаем — проекция
* сама подберёт ближайшее окно (для принятого-и-провалившегося плана он же служит якорем цепочки).
*/ */
private fun promoteNextPlanOrEnqueueRebuild(spacecraftId: String, now: LocalDateTime) { private fun reissueOnNearestWindow(
val nextPlan = plannedPlanRepository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( state: SpacecraftPlanningStateEntity,
spacecraftId = spacecraftId, failedPlan: PlannedPlanEntity,
status = PlannedPlanStatus.PLANNED, now: LocalDateTime
startTime = now ) {
state.activePlanId = null
state.activeAttemptId = null
state.urgentReissueFrom = maxOf(
now.plusMinutes(planningProperties.reissuePrepMinutes),
failedPlan.startTime
) )
state.updatedAt = now
spacecraftPlanningStateJpaRepository.save(state)
}
if (nextPlan == null) { private fun claimEvent(eventId: java.util.UUID): Boolean =
planRecalculationJobService.enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0
} else {
nextPlan.issueNotBefore = now
nextPlan.updatedAt = now
plannedPlanRepository.save(nextPlan)
}
}
private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
@@ -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"
}
}
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.server.ResponseStatusException import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp_tgu_service.config.PlanningProperties 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.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus 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.PlanIssueAttemptRepository
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
/** /**
* Выдаёт due plans из БД через попытки и короткий BPMN process per attempt. * Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ
* ([PlanProjectionService]) и материализуется в planned_plan ровно в момент выдачи (статус ISSUING).
*/ */
@Service @Service
class IssueDuePlanWorker( class IssueDuePlanWorker(
private val planningProperties: PlanningProperties, private val planningProperties: PlanningProperties,
private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository,
private val plannedPlanRepository: PlannedPlanRepository, private val plannedPlanRepository: PlannedPlanRepository,
private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository,
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
private val planProjectionService: PlanProjectionService,
private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter, private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter,
private val transactionTemplate: TransactionTemplate private val transactionTemplate: TransactionTemplate
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
/** Периодически claim'ит due plans и стартует BPMN вне DB transaction. */ /** Периодически по активным КА считает голову проекции и выдаёт дозревший план. */
@Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}")
fun issueDuePlans() { fun issueDuePlans() {
val prepared = transactionTemplate.execute { prepareDuePlans(limit = 10) }.orEmpty() spacecraftSnapshotRepository.findAllByActiveTrue().forEach { snapshot ->
prepared.forEach { preparedAttempt -> startPreparedAttempt(preparedAttempt) } val prepared = transactionTemplate.execute { prepareDueForSpacecraft(snapshot.spacecraftId) }
prepared?.let { preparedAttempt -> startPreparedAttempt(preparedAttempt) }
}
} }
/** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */ /** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */
@@ -47,50 +54,103 @@ class IssueDuePlanWorker(
return startPreparedAttempt(prepared) return startPreparedAttempt(prepared)
} }
private fun prepareDuePlans(limit: Int): List<PreparedAttempt> { /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val issueBefore = now.plusMinutes(planningProperties.notificationBeforeStartMinutes) val state = getOrCreateLockedState(spacecraftId)
return plannedPlanRepository.findDueForUpdate(now, issueBefore, limit) if (state.activeAttemptId != null) {
.mapNotNull { plan -> preparePlan(plan, now) } 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? { private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
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) val state = getOrCreateLockedState(plan.spacecraftId)
if (state.activeAttemptId != null) { if (state.activeAttemptId != null) {
return null return null
} }
if (plan.status !in REISSUABLE_STATUSES) {
return null
}
val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)?.attemptNo ?: 0) + 1 plan.status = PlannedPlanStatus.ISSUING
val attempt = planIssueAttemptRepository.save( 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( PlanIssueAttemptEntity(
attemptId = UUID.randomUUID(), attemptId = UUID.randomUUID(),
planId = plan.planId, planId = planId,
attemptNo = attemptNo, attemptNo = attemptNo,
status = PlanIssueAttemptStatus.STARTING, status = PlanIssueAttemptStatus.STARTING,
createdAt = now, createdAt = now,
updatedAt = 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 { private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse {
@@ -159,4 +219,15 @@ class IssueDuePlanWorker(
val planId: UUID, val planId: UUID,
val attemptId: 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
)
}
} }
@@ -48,6 +48,23 @@ class PlanCalculationService(
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> { fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */ /** Все доступные кандидаты начала/окончания плана для указанного КА. */
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints) val candidates: List<StartCandidate> = 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 -> val previousStartIndex: Int = candidates.indexOfFirst { candidate ->
candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime
@@ -85,7 +102,14 @@ class PlanCalculationService(
} }
if (fromStartIndex !in candidates.indices) { if (fromStartIndex !in candidates.indices) {
return emptyList() // База перекрывается с окнами, но якорь хвоста подобрать не удалось — не
// схлопываемся в пустоту, а строим свежую цепочку с самого раннего окна.
return buildChain(
spacecraftId = spacecraftPoints.spacecraftId,
candidates = candidates,
fromStartIndex = 0,
initialLowerEndBound = null
)
} }
return buildChain( return buildChain(
@@ -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<CalculatedPlan> {
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
)
}
@@ -4,32 +4,51 @@ import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException 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.entity.PlannedPlanEntity
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
/** /**
* Read-only query service для persisted planned_plan. * Read-сервис планов: объединяет persisted (выданные, история вечно) с вычисленной
* на лету проекцией будущего ([PlanProjectionService]). Проекция в БД не пишется.
*/ */
@Service @Service
class PlanQueryService( class PlanQueryService(
private val plannedPlanRepository: PlannedPlanRepository private val plannedPlanRepository: PlannedPlanRepository,
private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository,
private val planProjectionService: PlanProjectionService
) { ) {
/** Возвращает все persisted планы с отсечением глубокой истории. */ /** Все планы (persisted ∪ проекция) с отсечением глубокой истории по `historyDays`. */
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getAllPlans(historyDays: Long): List<PlannedPlanEntity> { fun getAllPlans(historyDays: Long): List<PlanResponse> {
val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) val historySince = historySince(historyDays)
return plannedPlanRepository.findAllByOrderByStartTime() val persisted = plannedPlanRepository.findAllByOrderByStartTime()
.filter { plan -> plan.endTime >= historySince } .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) @Transactional(readOnly = true)
fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List<PlannedPlanEntity> { fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List<PlanResponse> {
val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) val historySince = historySince(historyDays)
return plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
.filter { plan -> plan.endTime >= historySince } .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. */ /** Возвращает persisted plan по ID для test-only/debug API. */
@@ -37,4 +56,17 @@ class PlanQueryService(
fun getByPlanId(planId: UUID): PlannedPlanEntity = fun getByPlanId(planId: UUID): PlannedPlanEntity =
plannedPlanRepository.findById(planId) plannedPlanRepository.findById(planId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") } .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") }
private fun combine(persisted: List<PlannedPlanEntity>, projected: List<CalculatedPlan>): List<PlanResponse> =
(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"
} }
@@ -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())
}
}
@@ -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<PlanRecalculationJobEntity> =
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
}
}
@@ -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<String>,
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"
}
@@ -9,16 +9,16 @@ import java.security.MessageDigest
import java.time.LocalDateTime import java.time.LocalDateTime
/** /**
* Сохраняет raw visibility payload и ставит recalculation только при изменении hash. * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота
* ([PlanProjectionService]), поэтому отдельный triggers пересчёта здесь не нужен.
*/ */
@Service @Service
class SpacecraftVisibilitySnapshotService( class SpacecraftVisibilitySnapshotService(
private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository,
private val visibilityPayloadParser: VisibilityPayloadParser, private val visibilityPayloadParser: VisibilityPayloadParser
private val planRecalculationJobService: PlanRecalculationJobService
) { ) {
/** /**
* Сохраняет snapshot. Recalculation job создаётся только для нового или изменившегося payload. * Сохраняет snapshot. Hash используется только для метаданных и диагностики изменений окон.
*/ */
@Transactional @Transactional
fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) { fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) {
@@ -42,7 +42,6 @@ class SpacecraftVisibilitySnapshotService(
updatedAt = now updatedAt = now
) )
) )
planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED")
return return
} }
@@ -60,10 +59,6 @@ class SpacecraftVisibilitySnapshotService(
} }
spacecraftVisibilitySnapshotRepository.save(existing) spacecraftVisibilitySnapshotRepository.save(existing)
if (changed) {
planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED")
}
} }
private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata { private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata {
@@ -14,19 +14,23 @@ spring:
planning: planning:
notification-before-start-minutes: 263 notification-before-start-minutes: 263
target-plan-duration-minutes: 180 target-plan-duration-minutes: 1440
target-plan-duration-delta-minutes: 60 target-plan-duration-delta-minutes: 240
external-update-enabled: true external-update-enabled: true
external-points-lookahead-days: 3 external-points-lookahead-days: 10
in-memory-history-days: 14 in-memory-history-days: 14
external-update-fixed-delay-ms: 600000 external-update-fixed-delay-ms: 600000
notification-check-fixed-delay-ms: 30000 notification-check-fixed-delay-ms: 30000
min-reissue-lead-minutes: 5 min-reissue-lead-minutes: 5
reissue-prep-minutes: 30
worker-lock-ttl-seconds: 300 worker-lock-ttl-seconds: 300
topics: topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
station-params-changed: pcp.tgu.station-params-changed.v1
tgu: tgu:
test-controller: test-controller:
@@ -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;
@@ -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';
@@ -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;
@@ -0,0 +1,2 @@
-- Legacy-таблица из V1/V2: заменена на planned_plan (V4), в коде не используется.
DROP TABLE IF EXISTS tracked_plan;
@@ -0,0 +1,2 @@
-- Расписание считается on-demand из снапшота ЗРВ; отдельная очередь пересчёта больше не нужна.
DROP TABLE IF EXISTS plan_recalculation_job;
@@ -0,0 +1,4 @@
-- Время, начиная с которого нужно срочно выдать следующий план после негативного события
-- (REJECTED / «не заложен» / «не принят»). NULL — срочной перевыдачи не требуется.
ALTER TABLE spacecraft_planning_state
ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;
@@ -114,6 +114,7 @@ class ExternalPointsClientTest {
externalUpdateFixedDelayMs = 0, externalUpdateFixedDelayMs = 0,
notificationCheckFixedDelayMs = 30_000, notificationCheckFixedDelayMs = 30_000,
minReissueLeadMinutes = 5, minReissueLeadMinutes = 5,
reissuePrepMinutes = 30,
workerLockTtlSeconds = 300 workerLockTtlSeconds = 300
) )
} }
@@ -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)
}
}
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
starter: AcceptedPlanSlotsUpdateProcessStarter starter: AcceptedPlanSlotsUpdateProcessStarter
): AcceptedPlanSlotsUpdateWorker = ): AcceptedPlanSlotsUpdateWorker =
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, acceptedPlanSlotsUpdateJobRepository = repository,
acceptedPlanSlotsUpdateProcessStarter = starter, acceptedPlanSlotsUpdateProcessStarter = starter,
transactionTemplate = transactionTemplate() transactionTemplate = transactionTemplate()
@@ -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 <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
}
@@ -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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>()
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlannedPlanEntity>,
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
state: SpacecraftPlanningStateEntity,
projection: List<CalculatedPlan> = 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<CalculatedPlan>): PlanProjectionService {
val service = mock(PlanProjectionService::class.java)
`when`(service.project(spacecraftId)).thenReturn(projection)
return service
}
private fun plannedRepository(store: MutableMap<UUID, PlannedPlanEntity>): 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<UUID, PlanIssueAttemptEntity>): 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 <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
@Suppress("UNCHECKED_CAST")
private fun <T> anyOrNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
}
@@ -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<String, VisibilityRefreshJobEntity>
): 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<VisibilityRefreshJobEntity>(anyNonNull())).thenAnswer { invocation ->
val jobs = invocation.arguments[0] as Iterable<VisibilityRefreshJobEntity>
jobs.forEach { job -> store[job.spacecraftId] = job }
jobs.toList()
}
return repository
}
private fun recalculationJobRepository(
store: MutableMap<String, PlanRecalculationJobEntity>
): 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<PlanRecalculationJobEntity>(anyNonNull())).thenAnswer { invocation ->
val jobs = invocation.arguments[0] as Iterable<PlanRecalculationJobEntity>
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 <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
}
@@ -72,6 +72,37 @@ class PlanCalculationServiceTest {
assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime }) 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 @Test
fun `calculatePlans returns empty list when only one window is available`() { fun `calculatePlans returns empty list when only one window is available`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L) val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L)
@@ -307,6 +338,7 @@ class PlanCalculationServiceTest {
externalUpdateFixedDelayMs = 0, externalUpdateFixedDelayMs = 0,
notificationCheckFixedDelayMs = 30_000, notificationCheckFixedDelayMs = 30_000,
minReissueLeadMinutes = 5, minReissueLeadMinutes = 5,
reissuePrepMinutes = 30,
workerLockTtlSeconds = 300 workerLockTtlSeconds = 300
) )
) )
@@ -1,8 +1,10 @@
package space.nstart.pcp_tgu_service.service package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals 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.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.junit.jupiter.api.Test
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.never 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.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.domain.PlanDecision
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus 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.PlannedPlanRepository
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
import java.util.UUID import java.util.UUID
@@ -33,198 +37,114 @@ import java.util.UUID
class PlanIssueAndDecisionFlowTest { class PlanIssueAndDecisionFlowTest {
@Test @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 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 planStore = linkedMapOf(plan.planId to plan)
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>() val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val plannedRepository = plannedRepository(planStore) val worker = issueWorker(planStore, attemptStore, state)
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 firstResponse = worker.issuePlanNow(plan.planId) 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.activePlanId = null
state.activeAttemptId = null state.activeAttemptId = null
plan.status = PlannedPlanStatus.PLANNED plan.status = PlannedPlanStatus.REJECTED
val secondResponse = worker.issuePlanNow(plan.planId) val secondResponse = worker.issuePlanNow(plan.planId)
assertEquals(1, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.attemptNo) assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
assertEquals(2, attemptStore.values.first { it.attemptId == secondResponse.attemptId }.attemptNo) assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
assertNotEquals(firstResponse.attemptId, secondResponse.attemptId) 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) assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
assertTrue(blockedResponse?.message?.startsWith("404 NOT_FOUND") == true) assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true)
} }
@Test @Test
fun `mark started does not roll accepted plan and attempt back after BPMN start race`() { 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))
val planStore = linkedMapOf(plan.planId to plan)
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
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<UUID, PlanIssueAttemptEntity>()
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<UUID, PlanIssueAttemptEntity>()
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`() {
val spacecraftId = "25544" val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
`when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::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) val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)
useCase.handle(message) useCase.handle(message)
useCase.handle(message) useCase.handle(message)
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
assertEquals(plan.planId, state.currentBasePlanId) assertEquals(plan.planId, state.currentBasePlanId)
assertEquals(null, state.activePlanId) assertNull(state.activePlanId)
assertEquals(null, state.activeAttemptId) assertNull(state.activeAttemptId)
verify(recalculationJobService).enqueue(spacecraftId, "PLAN_ACCEPTED") assertNull(state.urgentReissueFrom)
verify(slotsUpdateJobService).enqueue(plan.planId) verify(slotsUpdateJobService).enqueue(plan.planId)
} }
@Test @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 spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) 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 state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
val useCase = decisionUseCase( val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
plan = plan,
attempt = attempt,
state = state,
recalculationJobService = recalculationJobService,
slotsUpdateJobService = slotsUpdateJobService
)
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED))
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
assertEquals(PlannedPlanStatus.PLANNED, plan.status) assertEquals(PlannedPlanStatus.REJECTED, plan.status)
assertEquals(null, state.activeAttemptId) assertNull(state.activeAttemptId)
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") assertNotNull(state.urgentReissueFrom)
verify(slotsUpdateJobService, never()).enqueue(plan.planId) verify(slotsUpdateJobService, never()).enqueue(plan.planId)
} }
@Test @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 spacecraftId = "25544"
val currentPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusMinutes(2), LocalDateTime.now().plusMinutes(20), PlannedPlanStatus.WAITING_DECISION) val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN)
val nextPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2), PlannedPlanStatus.PLANNED) val stalePlan = plannedPlan(spacecraftId, time(2), time(3), PlannedPlanStatus.REJECTED)
val attempt = attempt(currentPlan.planId, status = PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = currentPlan.planId, activeAttemptId = attempt.attemptId) val useCase = HandlePlanDecisionUseCase(
val planStore = linkedMapOf(currentPlan.planId to currentPlan, nextPlan.planId to nextPlan) processedPlanDecisionEventRepository = processedRepository(),
val recalculationJobService = mock(PlanRecalculationJobService::class.java) planIssueAttemptRepository = attemptRepository(linkedMapOf()),
val useCase = decisionUseCase( plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base, stalePlan.planId to stalePlan)),
plan = currentPlan, spacecraftPlanningStateJpaRepository = stateRepository(state),
attempt = attempt, acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
state = state, planningProperties = planningProperties()
plannedRepository = plannedRepository(planStore),
recalculationJobService = recalculationJobService
) )
useCase.handle(decisionMessage(spacecraftId, currentPlan.planId, attempt.attemptId, PlanDecision.REJECTED)) useCase.handleLayinFailed(feedbackMessage(spacecraftId, stalePlan.planId))
assertEquals(PlannedPlanStatus.EXPIRED, currentPlan.status) assertEquals(PlannedPlanStatus.REJECTED, stalePlan.status)
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) assertNull(state.urgentReissueFrom)
assertEquals(null, state.activeAttemptId)
assertEquals(true, nextPlan.issueNotBefore != null)
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF")
} }
@Test @Test
fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() { fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() {
val spacecraftId = "25544" val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
val activeAttemptId = UUID.randomUUID() val activeAttemptId = UUID.randomUUID()
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId)
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
val useCase = decisionUseCase( val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
plan = plan,
attempt = attempt,
state = state,
recalculationJobService = recalculationJobService,
slotsUpdateJobService = slotsUpdateJobService
)
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED))
@@ -232,59 +152,66 @@ class PlanIssueAndDecisionFlowTest {
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
assertEquals(plan.planId, state.activePlanId) assertEquals(plan.planId, state.activePlanId)
assertEquals(activeAttemptId, state.activeAttemptId) assertEquals(activeAttemptId, state.activeAttemptId)
assertEquals(null, state.currentBasePlanId) assertNull(state.currentBasePlanId)
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_ACCEPTED")
verify(slotsUpdateJobService, never()).enqueue(plan.planId) verify(slotsUpdateJobService, never()).enqueue(plan.planId)
} }
@Test @Test
fun `terminal attempt is not reprocessed by new decision event`() { fun `terminal attempt is not reprocessed by new decision event`() {
val spacecraftId = "25544" val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.ACCEPTED) val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
val oldEventId = UUID.randomUUID() val attempt = attempt(plan.planId, PlanIssueAttemptStatus.ACCEPTED)
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.ACCEPTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = plan.planId)
attempt.decisionEventId = oldEventId
val state = SpacecraftPlanningStateEntity(
spacecraftId = spacecraftId,
currentBasePlanId = plan.planId,
activePlanId = null,
activeAttemptId = null
)
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
val useCase = decisionUseCase( val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
plan = plan,
attempt = attempt,
state = state,
recalculationJobService = recalculationJobService,
slotsUpdateJobService = slotsUpdateJobService
)
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED))
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
assertEquals(oldEventId, attempt.decisionEventId) assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
assertEquals(PlannedPlanStatus.ACCEPTED, plan.status)
assertEquals(plan.planId, state.currentBasePlanId) assertEquals(plan.planId, state.currentBasePlanId)
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") assertNull(state.urgentReissueFrom)
verify(slotsUpdateJobService, never()).enqueue(plan.planId) 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( private fun decisionUseCase(
plan: PlannedPlanEntity, plan: PlannedPlanEntity,
attempt: PlanIssueAttemptEntity, attempt: PlanIssueAttemptEntity,
state: SpacecraftPlanningStateEntity, state: SpacecraftPlanningStateEntity,
processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(), processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(),
recalculationJobService: PlanRecalculationJobService = mock(PlanRecalculationJobService::class.java), slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
plannedRepository: PlannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan))
): HandlePlanDecisionUseCase = ): HandlePlanDecisionUseCase =
HandlePlanDecisionUseCase( HandlePlanDecisionUseCase(
processedPlanDecisionEventRepository = processedRepository, processedPlanDecisionEventRepository = processedRepository,
planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)), planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)),
plannedPlanRepository = plannedRepository, plannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)),
spacecraftPlanningStateJpaRepository = stateRepository(state), spacecraftPlanningStateJpaRepository = stateRepository(state),
planRecalculationJobService = recalculationJobService,
acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, acceptedPlanSlotsUpdateJobService = slotsUpdateJobService,
planningProperties = planningProperties() planningProperties = planningProperties()
) )
@@ -292,20 +219,21 @@ class PlanIssueAndDecisionFlowTest {
private fun issueWorker( private fun issueWorker(
planStore: MutableMap<UUID, PlannedPlanEntity>, planStore: MutableMap<UUID, PlannedPlanEntity>,
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>, attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
beforeStartReturns: (ProcessStartResponse) -> Unit state: SpacecraftPlanningStateEntity
): IssueDuePlanWorker { ): IssueDuePlanWorker {
val starter = mock(BpmnPlanIssueProcessStarter::class.java) val starter = mock(BpmnPlanIssueProcessStarter::class.java)
`when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
val p = invocation.arguments[0] as PlannedPlanEntity val p = invocation.arguments[0] as PlannedPlanEntity
val a = invocation.arguments[1] as PlanIssueAttemptEntity val a = invocation.arguments[1] as PlanIssueAttemptEntity
ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId)
.also(beforeStartReturns)
} }
return IssueDuePlanWorker( return IssueDuePlanWorker(
planningProperties = planningProperties(), planningProperties = planningProperties(),
spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java),
plannedPlanRepository = plannedRepository(planStore), plannedPlanRepository = plannedRepository(planStore),
planIssueAttemptRepository = attemptRepository(attemptStore), planIssueAttemptRepository = attemptRepository(attemptStore),
spacecraftPlanningStateJpaRepository = stateRepository(SpacecraftPlanningStateEntity(spacecraftId = planStore.values.first().spacecraftId)), spacecraftPlanningStateJpaRepository = stateRepository(state),
planProjectionService = mock(PlanProjectionService::class.java),
bpmnPlanIssueProcessStarter = starter, bpmnPlanIssueProcessStarter = starter,
transactionTemplate = transactionTemplate() transactionTemplate = transactionTemplate()
) )
@@ -326,26 +254,6 @@ class PlanIssueAndDecisionFlowTest {
store[plan.planId] = plan store[plan.planId] = plan
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 return repository
} }
@@ -376,7 +284,7 @@ class PlanIssueAndDecisionFlowTest {
spacecraftId: String, spacecraftId: String,
start: LocalDateTime, start: LocalDateTime,
end: LocalDateTime, end: LocalDateTime,
status: PlannedPlanStatus = PlannedPlanStatus.PLANNED status: PlannedPlanStatus
): PlannedPlanEntity = ): PlannedPlanEntity =
PlannedPlanEntity( PlannedPlanEntity(
planId = UUID.randomUUID(), planId = UUID.randomUUID(),
@@ -412,10 +320,19 @@ class PlanIssueAndDecisionFlowTest {
reason = null 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 time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
private fun planningProperties(): PlanningProperties = 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 = private fun transactionTemplate(): TransactionTemplate =
TransactionTemplate( TransactionTemplate(
@@ -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 <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
}
@@ -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())
}
@@ -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<UUID, PlannedPlanEntity>()
// Устаревший 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<UUID, PlannedPlanEntity>): 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 <T> anyNonNull(): T {
org.mockito.ArgumentMatchers.any<T>()
return null as T
}
}
@@ -35,9 +35,8 @@ class VisibilityFlowTest {
} }
@Test @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 repository = mock(SpacecraftVisibilitySnapshotRepository::class.java)
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
val payload = rawPayloadArray(stationId = 7) val payload = rawPayloadArray(stationId = 7)
val existing = SpacecraftVisibilitySnapshotEntity( val existing = SpacecraftVisibilitySnapshotEntity(
spacecraftId = "25544", spacecraftId = "25544",
@@ -48,27 +47,25 @@ class VisibilityFlowTest {
) )
`when`(repository.findById("25544")).thenReturn(Optional.of(existing)) `when`(repository.findById("25544")).thenReturn(Optional.of(existing))
`when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } `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)) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0))
assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt) assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt)
verify(recalculationJobService, never()).enqueue("25544", "VISIBILITY_CHANGED") verify(repository, times(1)).save(anyNonNull())
} }
@Test @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 repository = mock(SpacecraftVisibilitySnapshotRepository::class.java)
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
`when`(repository.findById("25544")).thenReturn(Optional.empty()) `when`(repository.findById("25544")).thenReturn(Optional.empty())
`when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } `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) val payload = rawPayloadArray(stationId = 7)
service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0))
verify(repository, times(1)).save(anyNonNull()) verify(repository, times(1)).save(anyNonNull())
verify(recalculationJobService).enqueue("25544", "VISIBILITY_CHANGED")
} }
private fun parser(): VisibilityPayloadParser = private fun parser(): VisibilityPayloadParser =