Редизайн 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:
@@ -31,19 +31,23 @@ springdoc:
|
||||
|
||||
planning:
|
||||
notification-before-start-minutes: 263
|
||||
target-plan-duration-minutes: 180
|
||||
target-plan-duration-delta-minutes: 60
|
||||
target-plan-duration-minutes: 1440
|
||||
target-plan-duration-delta-minutes: 240
|
||||
external-update-enabled: true
|
||||
external-points-lookahead-days: 3
|
||||
external-points-lookahead-days: 10
|
||||
in-memory-history-days: 14
|
||||
external-update-fixed-delay-ms: 600000
|
||||
notification-check-fixed-delay-ms: 30000
|
||||
min-reissue-lead-minutes: 5
|
||||
reissue-prep-minutes: 30
|
||||
worker-lock-ttl-seconds: 300
|
||||
|
||||
topics:
|
||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
|
||||
layin-failed: pcp.tgu.layin-failed.v1
|
||||
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
|
||||
station-params-changed: pcp.tgu.station-params-changed.v1
|
||||
|
||||
tgu:
|
||||
test-controller:
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
# Задача: pcp-tgu-service — «храним только выданное» + закладочный флоу
|
||||
|
||||
> ТЗ для кодинг-агента и для приёмки. Раздел 0 — промпт запуска.
|
||||
> Разделы 1–9 — что и как делаем. Раздел 10 — **критерии приёмки** (по ним проверяем результат).
|
||||
> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Промпт для запуска агента (copy-paste)
|
||||
|
||||
```
|
||||
Ты работаешь в монорепозитории observatio-terrae (pcp), модуль services/pcp-tgu-service (Kotlin/Spring Boot).
|
||||
|
||||
Задача: перестроить хранение и выдачу планов ТГУ — перестать материализовать
|
||||
будущую цепочку планов в БД (хранить только выданные планы и их историю — вечно),
|
||||
считать будущее на лету как проекцию, и добавить закладочный флоу с реакцией
|
||||
на два Kafka-события «не заложен» и «не принят».
|
||||
|
||||
Перед началом обязательно прочитай:
|
||||
1. /AGENTS.md — правила работы, стиль, формат финального ответа.
|
||||
2. /docs/tasks/TASK_tgu-plan-on-demand-redesign.md — это ТЗ.
|
||||
3. Текущий код: services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/
|
||||
(service/, entity/, repository/, controller/, integration/kafka/, config/).
|
||||
|
||||
Делай строго по фазам раздела 5. Всё — в одной ветке/одном PR.
|
||||
Ядро расчёта цепочки (PlanCalculationService) НЕ меняем — оно уже монотонно-корректно.
|
||||
После работы прогоняй ./gradlew :services:pcp-tgu-service:test из корня монорепо.
|
||||
В конце дай отчёт в формате из AGENTS.md и пройдись по разделу 10 (критерии приёмки).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и цель
|
||||
|
||||
`pcp-tgu-service` (порт `:7011`) — роллинг-планировщик сеансов управления по каждому операционному КА. Поток: список КА из НСИ → окна видимости (ЗРВ) из баллистики → расчёт цепочки планов (~`target` 1440±240 мин, привязка 1:1 к ЗРВ, строгая монотонность) → выдача дозревшего плана через короткий BPMN → решение из Kafka. UI (через агрегатор `:7008`) рисует таймлайн планов.
|
||||
|
||||
**Проблема сейчас.** Таблица `planned_plan` материализует **всю будущую цепочку** как `PLANNED`-строки и переписывает её на каждое изменение хэша ЗРВ. Это копит «лишнее»: пачки `SUPERSEDED`/`EXPIRED` на каждый пересчёт, рост без границ, отдельная машинерия `plan_recalculation_job`, и целый класс багов рассинхрона цепочки со снапшотом («схлопывание в SUPERSEDED», «зомби-PLANNED»). При этом будущая цепочка — **детерминированная проекция** от (снапшот ЗРВ + базовый план + конфиг).
|
||||
|
||||
**Цель.**
|
||||
1. Хранить в `planned_plan` **только планы, перешедшие в выдачу**, и их историю — **вечно** (ретеншн не делаем).
|
||||
2. Будущее (не выданное) — **не хранить вовсе**, считать на лету как проекцию (в т.ч. для UI).
|
||||
3. Добавить **закладочный флоу**: после `ACCEPTED` план оптимистично считается заложенным; реагируем только на явный негатив — два раздельных Kafka-события «не заложен» и «не принят», оба пока ведут себя одинаково (срочно выдать план на ближайшую ЗРВ), но разнесены для будущего расхождения.
|
||||
4. Реагировать на **изменение параметров станций** (Kafka-событие): станция могла выйти из строя — нужно заново запросить ЗРВ у баллистики по затронутым КА и пересобрать цепочку. В on-demand-модели это сводится к форсу `visibility_refresh_job` → новый снапшот → проекция пересчитается сама.
|
||||
5. Снести мёртвый код (`tracked_plan`) и ставшую ненужной машинерию пересчёта (`plan_recalculation_job`).
|
||||
|
||||
Масштаб: **100–150 КА**, горизонт планирования **до 10 суток**, деплой **2–3 реплики** (горизонтальное масштабирование уже обеспечено координацией через БД: `FOR UPDATE SKIP LOCKED` + pessimistic-lock на КА).
|
||||
|
||||
---
|
||||
|
||||
## 2. Границы задачи (scope)
|
||||
|
||||
**В scope:**
|
||||
- Перестроение хранения/выдачи планов в `pcp-tgu-service` (backend, Kotlin).
|
||||
- Новый сервис проекции; переписанный воркер выдачи; read-эндпоинт, отдающий persisted ∪ проекцию.
|
||||
- Новые статусы плана и закладочный флоу с двумя новыми Kafka-консьюмерами.
|
||||
- Удаление мёртвого кода и снятие `plan_recalculation_job`.
|
||||
- Миграции БД, изменения конфига (`application.yml` + `config-repo/pcp-tgu-service.yaml`).
|
||||
- Юнит-тесты на новую логику.
|
||||
|
||||
**Вне scope (без отдельного запроса):**
|
||||
- Фронтенд `pcp-tgu-ops-ui` и агрегатор `pcp-ui-service` — контракт `/api/plans` сохраняем неизменным, фронт не трогаем.
|
||||
- Изменение ядра расчёта `PlanCalculationService` (кроме переиспользования; алгоритм не меняем).
|
||||
- BPMN-схемы (`issueSatellitePlan`, `updateAcceptedPlanSlots`) — не меняем.
|
||||
- Источник/продюсер новых Kafka-событий «не заложен»/«не принят» — мы только консьюмеры; контракт ниже фиксируем.
|
||||
- Ретеншн/архивация — НЕ делаем (выданное храним вечно).
|
||||
|
||||
---
|
||||
|
||||
## 3. Текущая архитектура (точки изменения)
|
||||
|
||||
- [PlannedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt) / `planned_plan` — хранит всю цепочку, upsert по бизнес-ключу `(spacecraft, start, end, kpp)`.
|
||||
- [RebuildPlanScheduleUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt) — пересобирает цепочку: `calculatePlans/After` → upsert → `supersedeObsoleteFuturePlans` → `expireMissedPlans`.
|
||||
- [PlanCalculationService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt) — **ядро, не трогаем**.
|
||||
- [SpacecraftVisibilitySnapshotService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt) — на изменение хэша ставит `plan_recalculation_job`.
|
||||
- [PlanRecalculationWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt) / `PlanRecalculationJobService` / `plan_recalculation_job` — **снимаем целиком**.
|
||||
- [IssueDuePlanWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) — сейчас сканирует `PLANNED` (`findDueForUpdate`). Переписываем на on-demand.
|
||||
- [HandlePlanDecisionUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt) — обработка `ACCEPTED/REJECTED`; `promoteNextPlanOrEnqueueRebuild` завязан на хранимые `PLANNED` → переписываем.
|
||||
- [PlanQueryService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) / [PlanController](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) — read-путь, дополняем проекцией.
|
||||
- Мёртвый код: [TrackedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt), `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository` — на репозиторий нет ссылок.
|
||||
|
||||
---
|
||||
|
||||
## 4. Целевая архитектура
|
||||
|
||||
- `planned_plan` пополняется строкой **только в момент выдачи** (статус `ISSUING`). Дальше строка живёт по жизненному циклу выдачи и **хранится вечно**.
|
||||
- **Будущее не хранится.** `PlanProjectionService.project(spacecraftId)` считает цепочку на лету из последнего снапшота ЗРВ + текущей базы + конфига, переиспользуя `PlanCalculationService`.
|
||||
- **Выдача** ([IssueDuePlanWorker]) идёт по активным КА, считает голову проекции; если голова «дозрела» (`issue_not_before ≤ now`) или взведён `urgent_reissue_from` — материализует **ровно эту голову** в `ISSUING` и выдаёт. Никаких `PLANNED`-строк в БД.
|
||||
- **Read** ([PlanQueryService]) отдаёт `persisted (вся история) ∪ проекция будущего` (виртуальный `PLANNED`, в БД не пишется), дедуп по бизнес-ключу. Контракт `/api/plans` неизменен.
|
||||
- **Закладочный флоу**: `ACCEPTED` → `AWAITING_LAYIN` (оптимистично = база, цепочка идёт дальше). Два новых события гасят план терминально (`LAYIN_FAILED` / `NOT_ACCEPTED`) и взводят срочную перевыдачу на ближайшую ЗРВ с лидом `now + reissue-prep-minutes`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Фазы работ
|
||||
|
||||
### Фаза 0. Чистка мёртвого кода
|
||||
- Удалить `TrackedPlanEntity`, `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository`.
|
||||
- Миграция `V7__drop_tracked_plan.sql`: `DROP TABLE IF EXISTS tracked_plan;`
|
||||
- Убедиться, что компиляция/тесты не ссылались на эти классы.
|
||||
|
||||
### Фаза 1. On-demand проекция, конец материализации цепочки
|
||||
- Новый `PlanProjectionService` с `project(spacecraftId): List<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 -> терминальный статус + срочная выдача ближайшей ЗРВ
|
||||
```
|
||||
+3
@@ -35,6 +35,9 @@ data class PlanningProperties(
|
||||
/** Минимальный запас времени для повторной выдачи после REJECTED. */
|
||||
val minReissueLeadMinutes: Long,
|
||||
|
||||
/** Минимальное время на подготовку срочно перевыдаваемого плана (после негатива), в минутах. */
|
||||
val reissuePrepMinutes: Long,
|
||||
|
||||
/** TTL lease'а worker job claim в секундах. */
|
||||
val workerLockTtlSeconds: Long
|
||||
)
|
||||
|
||||
+10
-1
@@ -9,5 +9,14 @@ data class TopicsProperties(
|
||||
val visibilityWindowsChanged: String,
|
||||
|
||||
/** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */
|
||||
val satellitePlanDecision: String
|
||||
val satellitePlanDecision: String,
|
||||
|
||||
/** Topic-событие «не заложен» по принятому плану. */
|
||||
val layinFailed: String,
|
||||
|
||||
/** Topic-событие «не принят» по принятому плану. */
|
||||
val planNotAccepted: String,
|
||||
|
||||
/** Topic-событие об изменении параметров станций (перезапрос ЗРВ). */
|
||||
val stationParamsChanged: String
|
||||
)
|
||||
|
||||
+4
-4
@@ -17,20 +17,20 @@ class PlanController(
|
||||
private val planQueryService: PlanQueryService
|
||||
) {
|
||||
|
||||
/** Возвращает планы по всем КА с указанным окном подтверждённой истории. */
|
||||
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
||||
@GetMapping
|
||||
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono<List<PlanResponse>> =
|
||||
Mono.fromCallable {
|
||||
planQueryService.getAllPlans(historyDays).map(PlanResponse::from)
|
||||
planQueryService.getAllPlans(historyDays)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Возвращает цепочку планов для одного КА. */
|
||||
/** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
|
||||
@GetMapping("/{spacecraftId}")
|
||||
fun getPlansBySpacecraftId(
|
||||
@PathVariable spacecraftId: String,
|
||||
@RequestParam(defaultValue = "7") historyDays: Long
|
||||
): Mono<List<PlanResponse>> =
|
||||
Mono.fromCallable {
|
||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from)
|
||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
|
||||
+7
-10
@@ -18,10 +18,9 @@ import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
|
||||
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
||||
import space.nstart.pcp_tgu_service.service.PlanProjectionService
|
||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||
import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService
|
||||
import space.nstart.pcp_tgu_service.service.PlatformService
|
||||
import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase
|
||||
import space.nstart.pcp_tgu_service.service.TestPlanDecisionService
|
||||
import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService
|
||||
import java.nio.file.Files
|
||||
@@ -37,8 +36,7 @@ import java.util.UUID
|
||||
@RequestMapping("/test/tgu")
|
||||
class TestTguController(
|
||||
private val visibilityRefreshJobService: VisibilityRefreshJobService,
|
||||
private val planRecalculationJobService: PlanRecalculationJobService,
|
||||
private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase,
|
||||
private val planProjectionService: PlanProjectionService,
|
||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
|
||||
private val testPlanDecisionService: TestPlanDecisionService,
|
||||
@@ -56,12 +54,11 @@ class TestTguController(
|
||||
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER")
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Ставит и сразу выполняет rebuild persisted schedule для КА. */
|
||||
@PostMapping("/spacecraft/{spacecraftId}/rebuild-plans")
|
||||
fun rebuildPlans(@PathVariable spacecraftId: String): Mono<Void> =
|
||||
Mono.fromRunnable<Void> {
|
||||
planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER")
|
||||
rebuildPlanScheduleUseCase.rebuild(spacecraftId)
|
||||
/** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */
|
||||
@GetMapping("/spacecraft/{spacecraftId}/projection")
|
||||
fun getProjection(@PathVariable spacecraftId: String): Mono<List<PlanResponse>> =
|
||||
Mono.fromCallable {
|
||||
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */
|
||||
|
||||
+27
@@ -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?
|
||||
)
|
||||
+23
@@ -1,7 +1,9 @@
|
||||
package space.nstart.pcp_tgu_service.dto
|
||||
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -43,5 +45,26 @@ data class PlanResponse(
|
||||
kppId = plan.kppId,
|
||||
status = plan.status
|
||||
)
|
||||
|
||||
/**
|
||||
* Виртуальный (не сохранённый) план будущей проекции со статусом PLANNED.
|
||||
*
|
||||
* planId детерминированно выводится из бизнес-ключа, чтобы один и тот же расчётный план
|
||||
* стабильно опознавался фронтом между запросами.
|
||||
*/
|
||||
fun projected(plan: CalculatedPlan): PlanResponse =
|
||||
PlanResponse(
|
||||
planId = deterministicPlanId(plan),
|
||||
spacecraftId = plan.spacecraftId,
|
||||
startTime = plan.startTime,
|
||||
endTime = plan.endTime,
|
||||
kppId = plan.kppId,
|
||||
status = PlannedPlanStatus.PLANNED
|
||||
)
|
||||
|
||||
private fun deterministicPlanId(plan: CalculatedPlan): UUID {
|
||||
val businessKey = "${plan.spacecraftId}|${plan.startTime}|${plan.endTime}|${plan.kppId}"
|
||||
return UUID.nameUUIDFromBytes(businessKey.toByteArray(StandardCharsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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?
|
||||
)
|
||||
-48
@@ -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 = "")
|
||||
}
|
||||
-3
@@ -47,9 +47,6 @@ class PlannedPlanEntity(
|
||||
@Column(name = "accepted_at")
|
||||
var acceptedAt: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "superseded_by_plan_id")
|
||||
var supersededByPlanId: UUID? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
|
||||
+14
-2
@@ -4,12 +4,24 @@ package space.nstart.pcp_tgu_service.entity
|
||||
* Жизненный цикл persisted business-plan в расписании TGU.
|
||||
*/
|
||||
enum class PlannedPlanStatus {
|
||||
/** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */
|
||||
PLANNED,
|
||||
ISSUING,
|
||||
WAITING_DECISION,
|
||||
ACCEPTED,
|
||||
|
||||
/** Принят и оптимистично считается заложенным; текущий базовый план цепочки. */
|
||||
AWAITING_LAYIN,
|
||||
|
||||
/** Подтверждённая закладка (терминальный успех). */
|
||||
LAID_IN,
|
||||
|
||||
/** Пришло событие «не заложен» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
|
||||
LAYIN_FAILED,
|
||||
|
||||
/** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
|
||||
NOT_ACCEPTED,
|
||||
|
||||
REJECTED,
|
||||
SUPERSEDED,
|
||||
EXPIRED,
|
||||
START_AMBIGUOUS
|
||||
}
|
||||
|
||||
+7
@@ -27,6 +27,13 @@ class SpacecraftPlanningStateEntity(
|
||||
@Column(name = "chain_version", nullable = false)
|
||||
var chainVersion: Long = 0,
|
||||
|
||||
/**
|
||||
* Если задано — ближайшую ЗРВ со стартом не раньше этого времени нужно выдать срочно
|
||||
* (после REJECTED / «не заложен» / «не принят»), не дожидаясь обычного issue_not_before.
|
||||
*/
|
||||
@Column(name = "urgent_reissue_from")
|
||||
var urgentReissueFrom: LocalDateTime? = null,
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: LocalDateTime = LocalDateTime.now()
|
||||
) {
|
||||
|
||||
-63
@@ -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 = ""
|
||||
)
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/** Описывает способ создания отслеживаемого плана. */
|
||||
enum class TrackedPlanSource {
|
||||
AUTO_CHAIN,
|
||||
MANUAL
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/** Описывает состояние жизненного цикла отслеживаемого плана. */
|
||||
enum class TrackedPlanStatus {
|
||||
NEW,
|
||||
WAITING_CONFIRMATION,
|
||||
CONFIRMED,
|
||||
SUPERSEDED
|
||||
}
|
||||
+10
-2
@@ -10,6 +10,7 @@ import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Парсит raw payload баллистики в доменную модель расчёта без доступа к БД.
|
||||
@@ -42,11 +43,18 @@ class VisibilityPayloadParser(
|
||||
return SpacecraftPoints(spacecraftId = spacecraftId, points = points)
|
||||
}
|
||||
|
||||
/** Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. */
|
||||
/**
|
||||
* Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client.
|
||||
*
|
||||
* Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time`
|
||||
* хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется.
|
||||
* Без обрезки проекция (наносекунды) и persisted-планы (микросекунды) перестают совпадать по
|
||||
* бизнес-ключу — ломается дедуп в read-пути и матчинг базы в [calculatePlansAfter].
|
||||
*/
|
||||
private fun toLocalDateTime(value: String): LocalDateTime =
|
||||
try {
|
||||
Instant.parse(value).atZone(zoneId).toLocalDateTime()
|
||||
} catch (_: DateTimeParseException) {
|
||||
LocalDateTime.parse(value)
|
||||
}
|
||||
}.truncatedTo(ChronoUnit.MICROS)
|
||||
}
|
||||
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
-85
@@ -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>
|
||||
}
|
||||
+12
-76
@@ -4,44 +4,25 @@ import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/** Доступ к persisted расписанию business-plan. */
|
||||
/** Доступ к persisted планам (только выданные и их история; будущее не хранится). */
|
||||
interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
/** Ищет persisted plan по стабильному business key. */
|
||||
fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(
|
||||
spacecraftId: String,
|
||||
startTime: LocalDateTime,
|
||||
endTime: LocalDateTime,
|
||||
kppId: String
|
||||
): PlannedPlanEntity?
|
||||
|
||||
/** Возвращает планы КА в порядке старта. */
|
||||
/** Возвращает планы КА в порядке старта (для read-проекции и истории). */
|
||||
fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает все планы в порядке старта. */
|
||||
/** Возвращает все планы в порядке старта (для read-проекции и истории). */
|
||||
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */
|
||||
fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(
|
||||
spacecraftId: String,
|
||||
status: PlannedPlanStatus,
|
||||
endTime: LocalDateTime
|
||||
): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */
|
||||
fun findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(
|
||||
spacecraftId: String,
|
||||
status: PlannedPlanStatus,
|
||||
startTime: LocalDateTime
|
||||
): PlannedPlanEntity?
|
||||
|
||||
/** Атомарно создаёт или обновляет PLANNED plan по business key, сохраняя stable planId. */
|
||||
/**
|
||||
* Создаёт новую строку выдаваемого плана сразу в статусе ISSUING.
|
||||
*
|
||||
* В on-demand-модели каждая выдача — это INSERT с новым plan_id; апсерта по бизнес-ключу нет,
|
||||
* повторная выдача того же окна после негатива создаёт отдельную строку (история хранится вечно).
|
||||
*/
|
||||
@Query(
|
||||
value = """
|
||||
WITH upsert AS (
|
||||
INSERT INTO planned_plan(
|
||||
plan_id,
|
||||
spacecraft_id,
|
||||
@@ -61,42 +42,18 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
:startTime,
|
||||
:endTime,
|
||||
:kppId,
|
||||
'PLANNED',
|
||||
'ISSUING',
|
||||
:issueNotBefore,
|
||||
:basePlanId,
|
||||
:chainVersion,
|
||||
:now,
|
||||
:now
|
||||
)
|
||||
ON CONFLICT (spacecraft_id, start_time, end_time, kpp_id) DO UPDATE SET
|
||||
status = CASE
|
||||
WHEN planned_plan.status IN ('SUPERSEDED', 'EXPIRED') THEN 'PLANNED'
|
||||
ELSE planned_plan.status
|
||||
END,
|
||||
issue_not_before = CASE
|
||||
WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.issue_not_before
|
||||
ELSE planned_plan.issue_not_before
|
||||
END,
|
||||
base_plan_id = CASE
|
||||
WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.base_plan_id
|
||||
ELSE planned_plan.base_plan_id
|
||||
END,
|
||||
chain_version = CASE
|
||||
WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.chain_version
|
||||
ELSE planned_plan.chain_version
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.updated_at
|
||||
ELSE planned_plan.updated_at
|
||||
END
|
||||
RETURNING *
|
||||
)
|
||||
SELECT *
|
||||
FROM upsert
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun upsertPlannedByBusinessKey(
|
||||
fun insertIssuingPlan(
|
||||
@Param("planId") planId: UUID,
|
||||
@Param("spacecraftId") spacecraftId: String,
|
||||
@Param("startTime") startTime: LocalDateTime,
|
||||
@@ -108,7 +65,7 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
@Param("now") now: LocalDateTime
|
||||
): PlannedPlanEntity
|
||||
|
||||
/** Блокирует plan для обработки decision event или issue state transition. */
|
||||
/** Блокирует plan для обработки decision/feedback event или issue state transition. */
|
||||
@Query(
|
||||
value = """
|
||||
SELECT *
|
||||
@@ -119,25 +76,4 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
nativeQuery = true
|
||||
)
|
||||
fun lockByPlanId(@Param("planId") planId: UUID): PlannedPlanEntity?
|
||||
|
||||
/** Claim due plans через PostgreSQL row locks, чтобы несколько pod не выдали один plan дважды. */
|
||||
@Query(
|
||||
value = """
|
||||
SELECT *
|
||||
FROM planned_plan
|
||||
WHERE status = 'PLANNED'
|
||||
AND (issue_not_before IS NULL OR issue_not_before <= :now)
|
||||
AND start_time <= :issueBefore
|
||||
AND start_time > :now
|
||||
ORDER BY start_time
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findDueForUpdate(
|
||||
@Param("now") now: LocalDateTime,
|
||||
@Param("issueBefore") issueBefore: LocalDateTime,
|
||||
@Param("limit") limit: Int
|
||||
): List<PlannedPlanEntity>
|
||||
}
|
||||
|
||||
-46
@@ -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?
|
||||
}
|
||||
+80
-35
@@ -1,11 +1,14 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
@@ -18,7 +21,11 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaReposit
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* Обрабатывает decision events идемпотентно по eventId и строго по attemptId.
|
||||
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
|
||||
*
|
||||
* - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
|
||||
* - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ
|
||||
* запускается срочная перевыдача ([reissueOnNearestWindow]).
|
||||
*/
|
||||
@Service
|
||||
class HandlePlanDecisionUseCase(
|
||||
@@ -26,15 +33,15 @@ class HandlePlanDecisionUseCase(
|
||||
private val planIssueAttemptRepository: PlanIssueAttemptRepository,
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
|
||||
private val planRecalculationJobService: PlanRecalculationJobService,
|
||||
private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService,
|
||||
private val planningProperties: space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
private val planningProperties: PlanningProperties
|
||||
) {
|
||||
/** Применяет решение; повтор того же eventId является успешным no-op. */
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/** Применяет решение по attempt; повтор того же eventId — успешный no-op. */
|
||||
@Transactional
|
||||
fun handle(message: PlanDecisionMessage) {
|
||||
val inserted = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now())
|
||||
if (inserted == 0) {
|
||||
if (!claimEvent(message.eventId)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,6 +79,50 @@ class HandlePlanDecisionUseCase(
|
||||
}
|
||||
}
|
||||
|
||||
/** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */
|
||||
@Transactional
|
||||
fun handleLayinFailed(message: PlanExecutionFeedbackMessage) {
|
||||
applyNegativeFeedback(message, PlannedPlanStatus.LAYIN_FAILED)
|
||||
}
|
||||
|
||||
/** Событие «не принят» по базовому плану — пока поведение идентично «не заложен». */
|
||||
@Transactional
|
||||
fun handleNotAccepted(message: PlanExecutionFeedbackMessage) {
|
||||
applyNegativeFeedback(message, PlannedPlanStatus.NOT_ACCEPTED)
|
||||
}
|
||||
|
||||
private fun applyNegativeFeedback(message: PlanExecutionFeedbackMessage, terminalStatus: PlannedPlanStatus) {
|
||||
if (!claimEvent(message.eventId)) {
|
||||
return
|
||||
}
|
||||
|
||||
val plan = plannedPlanRepository.lockByPlanId(message.planId)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found")
|
||||
if (plan.spacecraftId != message.spacecraftId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Feedback spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}"
|
||||
)
|
||||
}
|
||||
|
||||
val state = getOrCreateLockedState(message.spacecraftId)
|
||||
if (state.currentBasePlanId != plan.planId || plan.status != PlannedPlanStatus.AWAITING_LAYIN) {
|
||||
// Событие пришло не по текущему базовому плану (устарело/дубль логики) — ничего не делаем.
|
||||
log.info(
|
||||
"Negative feedback ignored: plan {} is not the current AWAITING_LAYIN base (status={}, base={}).",
|
||||
plan.planId, plan.status, state.currentBasePlanId
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
plan.status = terminalStatus
|
||||
plan.updatedAt = now
|
||||
plannedPlanRepository.save(plan)
|
||||
|
||||
reissueOnNearestWindow(state, plan, now)
|
||||
}
|
||||
|
||||
private fun recordDecisionOnAttempt(
|
||||
message: PlanDecisionMessage,
|
||||
attempt: PlanIssueAttemptEntity
|
||||
@@ -96,20 +147,20 @@ class HandlePlanDecisionUseCase(
|
||||
val now = LocalDateTime.now()
|
||||
recordDecisionOnAttempt(message, attempt)
|
||||
|
||||
plan.status = PlannedPlanStatus.ACCEPTED
|
||||
plan.status = PlannedPlanStatus.AWAITING_LAYIN
|
||||
plan.acceptedAt = message.decisionTime
|
||||
plan.updatedAt = now
|
||||
|
||||
state.currentBasePlanId = plan.planId
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = null
|
||||
state.chainVersion += 1
|
||||
state.updatedAt = now
|
||||
|
||||
planIssueAttemptRepository.save(attempt)
|
||||
plannedPlanRepository.save(plan)
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
planRecalculationJobService.enqueue(message.spacecraftId, "PLAN_ACCEPTED")
|
||||
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
|
||||
}
|
||||
|
||||
@@ -122,43 +173,37 @@ class HandlePlanDecisionUseCase(
|
||||
val now = LocalDateTime.now()
|
||||
recordDecisionOnAttempt(message, attempt)
|
||||
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.updatedAt = now
|
||||
|
||||
if (now < plan.startTime.minusMinutes(planningProperties.minReissueLeadMinutes)) {
|
||||
plan.status = PlannedPlanStatus.PLANNED
|
||||
plan.issueNotBefore = now
|
||||
plan.status = PlannedPlanStatus.REJECTED
|
||||
plan.updatedAt = now
|
||||
} else {
|
||||
plan.status = PlannedPlanStatus.EXPIRED
|
||||
plan.updatedAt = now
|
||||
promoteNextPlanOrEnqueueRebuild(plan.spacecraftId, now)
|
||||
}
|
||||
|
||||
planIssueAttemptRepository.save(attempt)
|
||||
plannedPlanRepository.save(plan)
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
reissueOnNearestWindow(state, plan, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decision use case только меняет state. Новую попытку позже создаст IssueDuePlanWorker.
|
||||
* Общий маршрут после негатива (REJECTED/«не заложен»/«не принят»): снимает активную выдачу
|
||||
* и взводит срочную перевыдачу на ближайшую ЗРВ. Базовый указатель не трогаем — проекция
|
||||
* сама подберёт ближайшее окно (для принятого-и-провалившегося плана он же служит якорем цепочки).
|
||||
*/
|
||||
private fun promoteNextPlanOrEnqueueRebuild(spacecraftId: String, now: LocalDateTime) {
|
||||
val nextPlan = plannedPlanRepository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(
|
||||
spacecraftId = spacecraftId,
|
||||
status = PlannedPlanStatus.PLANNED,
|
||||
startTime = now
|
||||
private fun reissueOnNearestWindow(
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
failedPlan: PlannedPlanEntity,
|
||||
now: LocalDateTime
|
||||
) {
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = maxOf(
|
||||
now.plusMinutes(planningProperties.reissuePrepMinutes),
|
||||
failedPlan.startTime
|
||||
)
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
}
|
||||
|
||||
if (nextPlan == null) {
|
||||
planRecalculationJobService.enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF")
|
||||
} else {
|
||||
nextPlan.issueNotBefore = now
|
||||
nextPlan.updatedAt = now
|
||||
plannedPlanRepository.save(nextPlan)
|
||||
}
|
||||
}
|
||||
private fun claimEvent(eventId: java.util.UUID): Boolean =
|
||||
processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0
|
||||
|
||||
private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity {
|
||||
spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
||||
|
||||
+46
@@ -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"
|
||||
}
|
||||
}
|
||||
+100
-29
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
@@ -16,28 +17,34 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
||||
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Выдаёт due plans из БД через попытки и короткий BPMN process per attempt.
|
||||
* Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ
|
||||
* ([PlanProjectionService]) и материализуется в planned_plan ровно в момент выдачи (статус ISSUING).
|
||||
*/
|
||||
@Service
|
||||
class IssueDuePlanWorker(
|
||||
private val planningProperties: PlanningProperties,
|
||||
private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository,
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val planIssueAttemptRepository: PlanIssueAttemptRepository,
|
||||
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
|
||||
private val planProjectionService: PlanProjectionService,
|
||||
private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter,
|
||||
private val transactionTemplate: TransactionTemplate
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
/** Периодически claim'ит due plans и стартует BPMN вне DB transaction. */
|
||||
/** Периодически по активным КА считает голову проекции и выдаёт дозревший план. */
|
||||
@Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}")
|
||||
fun issueDuePlans() {
|
||||
val prepared = transactionTemplate.execute { prepareDuePlans(limit = 10) }.orEmpty()
|
||||
prepared.forEach { preparedAttempt -> startPreparedAttempt(preparedAttempt) }
|
||||
spacecraftSnapshotRepository.findAllByActiveTrue().forEach { snapshot ->
|
||||
val prepared = transactionTemplate.execute { prepareDueForSpacecraft(snapshot.spacecraftId) }
|
||||
prepared?.let { preparedAttempt -> startPreparedAttempt(preparedAttempt) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */
|
||||
@@ -47,50 +54,103 @@ class IssueDuePlanWorker(
|
||||
return startPreparedAttempt(prepared)
|
||||
}
|
||||
|
||||
private fun prepareDuePlans(limit: Int): List<PreparedAttempt> {
|
||||
/** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */
|
||||
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val issueBefore = now.plusMinutes(planningProperties.notificationBeforeStartMinutes)
|
||||
return plannedPlanRepository.findDueForUpdate(now, issueBefore, limit)
|
||||
.mapNotNull { plan -> preparePlan(plan, now) }
|
||||
val state = getOrCreateLockedState(spacecraftId)
|
||||
if (state.activeAttemptId != null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val projection = planProjectionService.project(spacecraftId)
|
||||
if (projection.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val urgentFrom = state.urgentReissueFrom
|
||||
val head = if (urgentFrom != null) {
|
||||
projection.firstOrNull { plan -> !plan.startTime.isBefore(urgentFrom) }
|
||||
} else {
|
||||
projection.first()
|
||||
} ?: return null
|
||||
|
||||
if (!head.startTime.isAfter(now)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes)
|
||||
val due = urgentFrom != null || !now.isBefore(issueNotBefore)
|
||||
if (!due) {
|
||||
return null
|
||||
}
|
||||
|
||||
val plan = materializeIssuingPlan(head, state, issueNotBefore, now)
|
||||
val attempt = createStartingAttempt(plan.planId, now)
|
||||
|
||||
state.activePlanId = plan.planId
|
||||
state.activeAttemptId = attempt.attemptId
|
||||
state.urgentReissueFrom = null
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||
}
|
||||
|
||||
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
||||
if (plan.status !in setOf(PlannedPlanStatus.PLANNED, PlannedPlanStatus.REJECTED)) {
|
||||
return null
|
||||
}
|
||||
return preparePlan(plan, now)
|
||||
}
|
||||
|
||||
private fun preparePlan(plan: PlannedPlanEntity, now: LocalDateTime): PreparedAttempt? {
|
||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||
if (state.activeAttemptId != null) {
|
||||
return null
|
||||
}
|
||||
if (plan.status !in REISSUABLE_STATUSES) {
|
||||
return null
|
||||
}
|
||||
|
||||
val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)?.attemptNo ?: 0) + 1
|
||||
val attempt = planIssueAttemptRepository.save(
|
||||
plan.status = PlannedPlanStatus.ISSUING
|
||||
plan.updatedAt = now
|
||||
plannedPlanRepository.save(plan)
|
||||
|
||||
val attempt = createStartingAttempt(plan.planId, now)
|
||||
state.activePlanId = plan.planId
|
||||
state.activeAttemptId = attempt.attemptId
|
||||
state.urgentReissueFrom = null
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||
}
|
||||
|
||||
private fun materializeIssuingPlan(
|
||||
head: CalculatedPlan,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
issueNotBefore: LocalDateTime,
|
||||
now: LocalDateTime
|
||||
): PlannedPlanEntity =
|
||||
plannedPlanRepository.insertIssuingPlan(
|
||||
planId = UUID.randomUUID(),
|
||||
spacecraftId = head.spacecraftId,
|
||||
startTime = head.startTime,
|
||||
endTime = head.endTime,
|
||||
kppId = head.kppId,
|
||||
issueNotBefore = issueNotBefore,
|
||||
basePlanId = state.currentBasePlanId,
|
||||
chainVersion = state.chainVersion,
|
||||
now = now
|
||||
)
|
||||
|
||||
private fun createStartingAttempt(planId: UUID, now: LocalDateTime): PlanIssueAttemptEntity {
|
||||
val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)?.attemptNo ?: 0) + 1
|
||||
return planIssueAttemptRepository.save(
|
||||
PlanIssueAttemptEntity(
|
||||
attemptId = UUID.randomUUID(),
|
||||
planId = plan.planId,
|
||||
planId = planId,
|
||||
attemptNo = attemptNo,
|
||||
status = PlanIssueAttemptStatus.STARTING,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
|
||||
plan.status = PlannedPlanStatus.ISSUING
|
||||
plan.updatedAt = now
|
||||
plannedPlanRepository.save(plan)
|
||||
|
||||
state.activePlanId = plan.planId
|
||||
state.activeAttemptId = attempt.attemptId
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||
}
|
||||
|
||||
private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse {
|
||||
@@ -159,4 +219,15 @@ class IssueDuePlanWorker(
|
||||
val planId: UUID,
|
||||
val attemptId: UUID
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** Статусы persisted-плана, который допустимо выдать вручную через issuePlanNow (test-only). */
|
||||
val REISSUABLE_STATUSES = setOf(
|
||||
PlannedPlanStatus.PLANNED,
|
||||
PlannedPlanStatus.REJECTED,
|
||||
PlannedPlanStatus.LAYIN_FAILED,
|
||||
PlannedPlanStatus.NOT_ACCEPTED,
|
||||
PlannedPlanStatus.START_AMBIGUOUS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -48,6 +48,23 @@ class PlanCalculationService(
|
||||
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
|
||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||
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 ->
|
||||
candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime
|
||||
@@ -85,7 +102,14 @@ class PlanCalculationService(
|
||||
}
|
||||
|
||||
if (fromStartIndex !in candidates.indices) {
|
||||
return emptyList()
|
||||
// База перекрывается с окнами, но якорь хвоста подобрать не удалось — не
|
||||
// схлопываемся в пустоту, а строим свежую цепочку с самого раннего окна.
|
||||
return buildChain(
|
||||
spacecraftId = spacecraftPoints.spacecraftId,
|
||||
candidates = candidates,
|
||||
fromStartIndex = 0,
|
||||
initialLowerEndBound = null
|
||||
)
|
||||
}
|
||||
|
||||
return buildChain(
|
||||
|
||||
+51
@@ -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
|
||||
)
|
||||
}
|
||||
+42
-10
@@ -4,32 +4,51 @@ import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Read-only query service для persisted planned_plan.
|
||||
* Read-сервис планов: объединяет persisted (выданные, история — вечно) с вычисленной
|
||||
* на лету проекцией будущего ([PlanProjectionService]). Проекция в БД не пишется.
|
||||
*/
|
||||
@Service
|
||||
class PlanQueryService(
|
||||
private val plannedPlanRepository: PlannedPlanRepository
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository,
|
||||
private val planProjectionService: PlanProjectionService
|
||||
) {
|
||||
/** Возвращает все persisted планы с отсечением глубокой истории. */
|
||||
/** Все планы (persisted ∪ проекция) с отсечением глубокой истории по `historyDays`. */
|
||||
@Transactional(readOnly = true)
|
||||
fun getAllPlans(historyDays: Long): List<PlannedPlanEntity> {
|
||||
val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0))
|
||||
return plannedPlanRepository.findAllByOrderByStartTime()
|
||||
fun getAllPlans(historyDays: Long): List<PlanResponse> {
|
||||
val historySince = historySince(historyDays)
|
||||
val persisted = plannedPlanRepository.findAllByOrderByStartTime()
|
||||
.filter { plan -> plan.endTime >= historySince }
|
||||
val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() }
|
||||
|
||||
val projected = spacecraftSnapshotRepository.findAllByActiveTrue()
|
||||
.flatMap { snapshot -> planProjectionService.project(snapshot.spacecraftId) }
|
||||
.filter { plan -> plan.businessKey() !in persistedKeys }
|
||||
|
||||
return combine(persisted, projected)
|
||||
}
|
||||
|
||||
/** Возвращает persisted планы одного КА с отсечением глубокой истории. */
|
||||
/** Планы одного КА (persisted ∪ проекция) с отсечением глубокой истории. */
|
||||
@Transactional(readOnly = true)
|
||||
fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List<PlannedPlanEntity> {
|
||||
val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0))
|
||||
return plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
|
||||
fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List<PlanResponse> {
|
||||
val historySince = historySince(historyDays)
|
||||
val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
|
||||
.filter { plan -> plan.endTime >= historySince }
|
||||
val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() }
|
||||
|
||||
val projected = planProjectionService.project(spacecraftId)
|
||||
.filter { plan -> plan.businessKey() !in persistedKeys }
|
||||
|
||||
return combine(persisted, projected)
|
||||
}
|
||||
|
||||
/** Возвращает persisted plan по ID для test-only/debug API. */
|
||||
@@ -37,4 +56,17 @@ class PlanQueryService(
|
||||
fun getByPlanId(planId: UUID): PlannedPlanEntity =
|
||||
plannedPlanRepository.findById(planId)
|
||||
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") }
|
||||
|
||||
private fun combine(persisted: List<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"
|
||||
}
|
||||
|
||||
-20
@@ -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())
|
||||
}
|
||||
}
|
||||
-85
@@ -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
|
||||
}
|
||||
}
|
||||
-113
@@ -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"
|
||||
}
|
||||
+4
-9
@@ -9,16 +9,16 @@ import java.security.MessageDigest
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* Сохраняет raw visibility payload и ставит recalculation только при изменении hash.
|
||||
* Сохраняет raw visibility payload. Расписание считается on-demand из снапшота
|
||||
* ([PlanProjectionService]), поэтому отдельный triggers пересчёта здесь не нужен.
|
||||
*/
|
||||
@Service
|
||||
class SpacecraftVisibilitySnapshotService(
|
||||
private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository,
|
||||
private val visibilityPayloadParser: VisibilityPayloadParser,
|
||||
private val planRecalculationJobService: PlanRecalculationJobService
|
||||
private val visibilityPayloadParser: VisibilityPayloadParser
|
||||
) {
|
||||
/**
|
||||
* Сохраняет snapshot. Recalculation job создаётся только для нового или изменившегося payload.
|
||||
* Сохраняет snapshot. Hash используется только для метаданных и диагностики изменений окон.
|
||||
*/
|
||||
@Transactional
|
||||
fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) {
|
||||
@@ -42,7 +42,6 @@ class SpacecraftVisibilitySnapshotService(
|
||||
updatedAt = now
|
||||
)
|
||||
)
|
||||
planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,10 +59,6 @@ class SpacecraftVisibilitySnapshotService(
|
||||
}
|
||||
|
||||
spacecraftVisibilitySnapshotRepository.save(existing)
|
||||
|
||||
if (changed) {
|
||||
planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata {
|
||||
|
||||
@@ -14,19 +14,23 @@ spring:
|
||||
|
||||
planning:
|
||||
notification-before-start-minutes: 263
|
||||
target-plan-duration-minutes: 180
|
||||
target-plan-duration-delta-minutes: 60
|
||||
target-plan-duration-minutes: 1440
|
||||
target-plan-duration-delta-minutes: 240
|
||||
external-update-enabled: true
|
||||
external-points-lookahead-days: 3
|
||||
external-points-lookahead-days: 10
|
||||
in-memory-history-days: 14
|
||||
external-update-fixed-delay-ms: 600000
|
||||
notification-check-fixed-delay-ms: 30000
|
||||
min-reissue-lead-minutes: 5
|
||||
reissue-prep-minutes: 30
|
||||
worker-lock-ttl-seconds: 300
|
||||
|
||||
topics:
|
||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
|
||||
layin-failed: pcp.tgu.layin-failed.v1
|
||||
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
|
||||
station-params-changed: pcp.tgu.station-params-changed.v1
|
||||
|
||||
tgu:
|
||||
test-controller:
|
||||
|
||||
+5
@@ -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;
|
||||
+11
@@ -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';
|
||||
+19
@@ -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;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- Расписание считается on-demand из снапшота ЗРВ; отдельная очередь пересчёта больше не нужна.
|
||||
DROP TABLE IF EXISTS plan_recalculation_job;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
-- Время, начиная с которого нужно срочно выдать следующий план после негативного события
|
||||
-- (REJECTED / «не заложен» / «не принят»). NULL — срочной перевыдачи не требуется.
|
||||
ALTER TABLE spacecraft_planning_state
|
||||
ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;
|
||||
+1
@@ -114,6 +114,7 @@ class ExternalPointsClientTest {
|
||||
externalUpdateFixedDelayMs = 0,
|
||||
notificationCheckFixedDelayMs = 30_000,
|
||||
minReissueLeadMinutes = 5,
|
||||
reissuePrepMinutes = 30,
|
||||
workerLockTtlSeconds = 300
|
||||
)
|
||||
}
|
||||
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
|
||||
starter: AcceptedPlanSlotsUpdateProcessStarter
|
||||
): AcceptedPlanSlotsUpdateWorker =
|
||||
AcceptedPlanSlotsUpdateWorker(
|
||||
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300),
|
||||
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300),
|
||||
acceptedPlanSlotsUpdateJobRepository = repository,
|
||||
acceptedPlanSlotsUpdateProcessStarter = starter,
|
||||
transactionTemplate = transactionTemplate()
|
||||
|
||||
+52
@@ -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
|
||||
}
|
||||
}
|
||||
+220
@@ -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
|
||||
}
|
||||
}
|
||||
-301
@@ -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
|
||||
}
|
||||
}
|
||||
+32
@@ -72,6 +72,37 @@ class PlanCalculationServiceTest {
|
||||
assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculatePlansAfter rebuilds a fresh chain when the base plan is entirely in the past`() {
|
||||
// Регресс: при обновлении окна тянутся из [now, now+Nд], и принятый базовый план
|
||||
// со временем уходит в прошлое за левый край окна. Тогда хвост нельзя сопоставить
|
||||
// с базой — но расписание не должно схлопнуться в пустоту (иначе supersede погасил
|
||||
// бы всё будущее). Должна строиться свежая цепочка с самого раннего окна.
|
||||
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
|
||||
val futureStart = LocalDateTime.of(2026, 3, 10, 10, 0)
|
||||
val spacecraftPoints = spacecraftPoints(
|
||||
window("KPP-1", futureStart),
|
||||
window("KPP-2", futureStart.plusHours(8)),
|
||||
window("KPP-3", futureStart.plusHours(16)),
|
||||
window("KPP-4", futureStart.plusHours(23).plusMinutes(55), 15),
|
||||
window("KPP-5", futureStart.plusHours(24).plusMinutes(10))
|
||||
)
|
||||
// Базовый план целиком раньше любого доступного окна (состарился и выпал из горизонта).
|
||||
val agedOutBasePlan = CalculatedPlan(
|
||||
spacecraftId = "25544",
|
||||
startTime = futureStart.minusDays(2),
|
||||
endTime = futureStart.minusDays(1),
|
||||
kppId = "KPP-OLD"
|
||||
)
|
||||
|
||||
val plans = service.calculatePlansAfter(spacecraftPoints, agedOutBasePlan)
|
||||
|
||||
assertChainInvariant(plans)
|
||||
assertTrue(plans.isNotEmpty(), "расписание не должно схлопнуться в пустоту: $plans")
|
||||
assertEquals(service.calculatePlans(spacecraftPoints), plans, "должна строиться та же свежая цепочка")
|
||||
assertEquals(futureStart, plans.first().startTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `calculatePlans returns empty list when only one window is available`() {
|
||||
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L)
|
||||
@@ -307,6 +338,7 @@ class PlanCalculationServiceTest {
|
||||
externalUpdateFixedDelayMs = 0,
|
||||
notificationCheckFixedDelayMs = 30_000,
|
||||
minReissueLeadMinutes = 5,
|
||||
reissuePrepMinutes = 30,
|
||||
workerLockTtlSeconds = 300
|
||||
)
|
||||
)
|
||||
|
||||
+106
-189
@@ -1,8 +1,10 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
@@ -16,6 +18,7 @@ import org.springframework.transaction.support.TransactionTemplate
|
||||
import space.nstart.pcp_tgu_service.config.PlanningProperties
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
|
||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
@@ -26,6 +29,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
@@ -33,198 +37,114 @@ import java.util.UUID
|
||||
class PlanIssueAndDecisionFlowTest {
|
||||
|
||||
@Test
|
||||
fun `issue worker creates one active attempt and repeated issue of same plan creates new attempt after state is cleared`() {
|
||||
fun `issuePlanNow creates one active attempt and repeated manual issue creates a new attempt after state cleared`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13))
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED)
|
||||
val planStore = linkedMapOf(plan.planId to plan)
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val plannedRepository = plannedRepository(planStore)
|
||||
val attemptRepository = attemptRepository(attemptStore)
|
||||
val stateRepository = stateRepository(state)
|
||||
val starter = mock(BpmnPlanIssueProcessStarter::class.java)
|
||||
`when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||
val p = invocation.arguments[0] as PlannedPlanEntity
|
||||
val a = invocation.arguments[1] as PlanIssueAttemptEntity
|
||||
ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId)
|
||||
}
|
||||
val worker = IssueDuePlanWorker(
|
||||
planningProperties = planningProperties(),
|
||||
plannedPlanRepository = plannedRepository,
|
||||
planIssueAttemptRepository = attemptRepository,
|
||||
spacecraftPlanningStateJpaRepository = stateRepository,
|
||||
bpmnPlanIssueProcessStarter = starter,
|
||||
transactionTemplate = transactionTemplate()
|
||||
)
|
||||
val worker = issueWorker(planStore, attemptStore, state)
|
||||
|
||||
val firstResponse = worker.issuePlanNow(plan.planId)
|
||||
val blockedResponse = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull()
|
||||
val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull()
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
plan.status = PlannedPlanStatus.PLANNED
|
||||
plan.status = PlannedPlanStatus.REJECTED
|
||||
val secondResponse = worker.issuePlanNow(plan.planId)
|
||||
|
||||
assertEquals(1, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.attemptNo)
|
||||
assertEquals(2, attemptStore.values.first { it.attemptId == secondResponse.attemptId }.attemptNo)
|
||||
assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
|
||||
assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
|
||||
assertNotEquals(firstResponse.attemptId, secondResponse.attemptId)
|
||||
assertEquals(PlanIssueAttemptStatus.STARTED, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.status)
|
||||
assertEquals(firstResponse.processInstanceKey, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.processInstanceKey)
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
|
||||
assertTrue(blockedResponse?.message?.startsWith("404 NOT_FOUND") == true)
|
||||
assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mark started does not roll accepted plan and attempt back after BPMN start race`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13))
|
||||
val planStore = linkedMapOf(plan.planId to plan)
|
||||
val attemptStore = linkedMapOf<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`() {
|
||||
fun `accepted decision moves plan to AWAITING_LAYIN sets base and enqueues slots update once`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
|
||||
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
|
||||
val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
|
||||
`when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
val useCase = decisionUseCase(plan, attempt, state, processedRepository, recalculationJobService, slotsUpdateJobService)
|
||||
val useCase = decisionUseCase(plan, attempt, state, processedRepository, slotsUpdateJobService)
|
||||
val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)
|
||||
|
||||
useCase.handle(message)
|
||||
useCase.handle(message)
|
||||
|
||||
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
|
||||
assertEquals(PlannedPlanStatus.ACCEPTED, plan.status)
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
|
||||
assertEquals(plan.planId, state.currentBasePlanId)
|
||||
assertEquals(null, state.activePlanId)
|
||||
assertEquals(null, state.activeAttemptId)
|
||||
verify(recalculationJobService).enqueue(spacecraftId, "PLAN_ACCEPTED")
|
||||
assertNull(state.activePlanId)
|
||||
assertNull(state.activeAttemptId)
|
||||
assertNull(state.urgentReissueFrom)
|
||||
verify(slotsUpdateJobService).enqueue(plan.planId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected before cutoff reopens same plan without direct new attempt`() {
|
||||
fun `rejected decision marks plan REJECTED and arms urgent reissue`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION)
|
||||
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
val useCase = decisionUseCase(
|
||||
plan = plan,
|
||||
attempt = attempt,
|
||||
state = state,
|
||||
recalculationJobService = recalculationJobService,
|
||||
slotsUpdateJobService = slotsUpdateJobService
|
||||
)
|
||||
val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
|
||||
|
||||
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED))
|
||||
|
||||
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
|
||||
assertEquals(PlannedPlanStatus.PLANNED, plan.status)
|
||||
assertEquals(null, state.activeAttemptId)
|
||||
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF")
|
||||
assertEquals(PlannedPlanStatus.REJECTED, plan.status)
|
||||
assertNull(state.activeAttemptId)
|
||||
assertNotNull(state.urgentReissueFrom)
|
||||
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected after cutoff expires plan and promotes next planned plan`() {
|
||||
fun `layin failed on current base marks LAYIN_FAILED and arms urgent reissue idempotently`() {
|
||||
assertNegativeFeedbackArmsReissue(PlannedPlanStatus.LAYIN_FAILED) { useCase, message ->
|
||||
useCase.handleLayinFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan not accepted on current base marks NOT_ACCEPTED and arms urgent reissue idempotently`() {
|
||||
assertNegativeFeedbackArmsReissue(PlannedPlanStatus.NOT_ACCEPTED) { useCase, message ->
|
||||
useCase.handleNotAccepted(message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative feedback for a plan that is not the current base is ignored`() {
|
||||
val spacecraftId = "25544"
|
||||
val currentPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusMinutes(2), LocalDateTime.now().plusMinutes(20), PlannedPlanStatus.WAITING_DECISION)
|
||||
val nextPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2), PlannedPlanStatus.PLANNED)
|
||||
val attempt = attempt(currentPlan.planId, status = PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = currentPlan.planId, activeAttemptId = attempt.attemptId)
|
||||
val planStore = linkedMapOf(currentPlan.planId to currentPlan, nextPlan.planId to nextPlan)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
val useCase = decisionUseCase(
|
||||
plan = currentPlan,
|
||||
attempt = attempt,
|
||||
state = state,
|
||||
plannedRepository = plannedRepository(planStore),
|
||||
recalculationJobService = recalculationJobService
|
||||
val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val stalePlan = plannedPlan(spacecraftId, time(2), time(3), PlannedPlanStatus.REJECTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId)
|
||||
val useCase = HandlePlanDecisionUseCase(
|
||||
processedPlanDecisionEventRepository = processedRepository(),
|
||||
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
|
||||
plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base, stalePlan.planId to stalePlan)),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(state),
|
||||
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
|
||||
planningProperties = planningProperties()
|
||||
)
|
||||
|
||||
useCase.handle(decisionMessage(spacecraftId, currentPlan.planId, attempt.attemptId, PlanDecision.REJECTED))
|
||||
useCase.handleLayinFailed(feedbackMessage(spacecraftId, stalePlan.planId))
|
||||
|
||||
assertEquals(PlannedPlanStatus.EXPIRED, currentPlan.status)
|
||||
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
|
||||
assertEquals(null, state.activeAttemptId)
|
||||
assertEquals(true, nextPlan.issueNotBefore != null)
|
||||
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF")
|
||||
assertEquals(PlannedPlanStatus.REJECTED, stalePlan.status)
|
||||
assertNull(state.urgentReissueFrom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
|
||||
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val activeAttemptId = UUID.randomUUID()
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
val useCase = decisionUseCase(
|
||||
plan = plan,
|
||||
attempt = attempt,
|
||||
state = state,
|
||||
recalculationJobService = recalculationJobService,
|
||||
slotsUpdateJobService = slotsUpdateJobService
|
||||
)
|
||||
val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
|
||||
|
||||
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED))
|
||||
|
||||
@@ -232,59 +152,66 @@ class PlanIssueAndDecisionFlowTest {
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
|
||||
assertEquals(plan.planId, state.activePlanId)
|
||||
assertEquals(activeAttemptId, state.activeAttemptId)
|
||||
assertEquals(null, state.currentBasePlanId)
|
||||
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_ACCEPTED")
|
||||
assertNull(state.currentBasePlanId)
|
||||
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal attempt is not reprocessed by new decision event`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.ACCEPTED)
|
||||
val oldEventId = UUID.randomUUID()
|
||||
val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.ACCEPTED)
|
||||
attempt.decisionEventId = oldEventId
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
currentBasePlanId = plan.planId,
|
||||
activePlanId = null,
|
||||
activeAttemptId = null
|
||||
)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.ACCEPTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = plan.planId)
|
||||
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
val useCase = decisionUseCase(
|
||||
plan = plan,
|
||||
attempt = attempt,
|
||||
state = state,
|
||||
recalculationJobService = recalculationJobService,
|
||||
slotsUpdateJobService = slotsUpdateJobService
|
||||
)
|
||||
val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
|
||||
|
||||
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED))
|
||||
|
||||
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
|
||||
assertEquals(oldEventId, attempt.decisionEventId)
|
||||
assertEquals(PlannedPlanStatus.ACCEPTED, plan.status)
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
|
||||
assertEquals(plan.planId, state.currentBasePlanId)
|
||||
verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF")
|
||||
assertNull(state.urgentReissueFrom)
|
||||
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
||||
}
|
||||
|
||||
private fun assertNegativeFeedbackArmsReissue(
|
||||
expectedStatus: PlannedPlanStatus,
|
||||
invoke: (HandlePlanDecisionUseCase, PlanExecutionFeedbackMessage) -> Unit
|
||||
) {
|
||||
val spacecraftId = "25544"
|
||||
val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId)
|
||||
val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
|
||||
`when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
|
||||
val useCase = HandlePlanDecisionUseCase(
|
||||
processedPlanDecisionEventRepository = processedRepository,
|
||||
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
|
||||
plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base)),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(state),
|
||||
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
|
||||
planningProperties = planningProperties()
|
||||
)
|
||||
val message = feedbackMessage(spacecraftId, base.planId)
|
||||
|
||||
invoke(useCase, message)
|
||||
invoke(useCase, message)
|
||||
|
||||
assertEquals(expectedStatus, base.status)
|
||||
assertNotNull(state.urgentReissueFrom)
|
||||
}
|
||||
|
||||
private fun decisionUseCase(
|
||||
plan: PlannedPlanEntity,
|
||||
attempt: PlanIssueAttemptEntity,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(),
|
||||
recalculationJobService: PlanRecalculationJobService = mock(PlanRecalculationJobService::class.java),
|
||||
slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
|
||||
plannedRepository: PlannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan))
|
||||
slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
): HandlePlanDecisionUseCase =
|
||||
HandlePlanDecisionUseCase(
|
||||
processedPlanDecisionEventRepository = processedRepository,
|
||||
planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)),
|
||||
plannedPlanRepository = plannedRepository,
|
||||
plannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(state),
|
||||
planRecalculationJobService = recalculationJobService,
|
||||
acceptedPlanSlotsUpdateJobService = slotsUpdateJobService,
|
||||
planningProperties = planningProperties()
|
||||
)
|
||||
@@ -292,20 +219,21 @@ class PlanIssueAndDecisionFlowTest {
|
||||
private fun issueWorker(
|
||||
planStore: MutableMap<UUID, PlannedPlanEntity>,
|
||||
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
|
||||
beforeStartReturns: (ProcessStartResponse) -> Unit
|
||||
state: SpacecraftPlanningStateEntity
|
||||
): IssueDuePlanWorker {
|
||||
val starter = mock(BpmnPlanIssueProcessStarter::class.java)
|
||||
`when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||
val p = invocation.arguments[0] as PlannedPlanEntity
|
||||
val a = invocation.arguments[1] as PlanIssueAttemptEntity
|
||||
ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId)
|
||||
.also(beforeStartReturns)
|
||||
}
|
||||
return IssueDuePlanWorker(
|
||||
planningProperties = planningProperties(),
|
||||
spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java),
|
||||
plannedPlanRepository = plannedRepository(planStore),
|
||||
planIssueAttemptRepository = attemptRepository(attemptStore),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(SpacecraftPlanningStateEntity(spacecraftId = planStore.values.first().spacecraftId)),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(state),
|
||||
planProjectionService = mock(PlanProjectionService::class.java),
|
||||
bpmnPlanIssueProcessStarter = starter,
|
||||
transactionTemplate = transactionTemplate()
|
||||
)
|
||||
@@ -326,26 +254,6 @@ class PlanIssueAndDecisionFlowTest {
|
||||
store[plan.planId] = plan
|
||||
plan
|
||||
}
|
||||
`when`(repository.findDueForUpdate(anyNonNull(), anyNonNull(), org.mockito.ArgumentMatchers.anyInt())).thenAnswer { invocation ->
|
||||
val now = invocation.arguments[0] as LocalDateTime
|
||||
val issueBefore = invocation.arguments[1] as LocalDateTime
|
||||
store.values.filter { plan ->
|
||||
plan.status == PlannedPlanStatus.PLANNED &&
|
||||
(plan.issueNotBefore == null || !plan.issueNotBefore!!.isAfter(now)) &&
|
||||
!plan.startTime.isAfter(issueBefore) &&
|
||||
plan.startTime.isAfter(now)
|
||||
}.sortedBy { plan -> plan.startTime }
|
||||
}
|
||||
`when`(repository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull()))
|
||||
.thenAnswer { invocation ->
|
||||
store.values
|
||||
.filter { plan ->
|
||||
plan.spacecraftId == invocation.arguments[0] &&
|
||||
plan.status == invocation.arguments[1] &&
|
||||
plan.startTime > invocation.arguments[2] as LocalDateTime
|
||||
}
|
||||
.minByOrNull { plan -> plan.startTime }
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
@@ -376,7 +284,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
spacecraftId: String,
|
||||
start: LocalDateTime,
|
||||
end: LocalDateTime,
|
||||
status: PlannedPlanStatus = PlannedPlanStatus.PLANNED
|
||||
status: PlannedPlanStatus
|
||||
): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
@@ -412,10 +320,19 @@ class PlanIssueAndDecisionFlowTest {
|
||||
reason = null
|
||||
)
|
||||
|
||||
private fun feedbackMessage(spacecraftId: String, planId: UUID): PlanExecutionFeedbackMessage =
|
||||
PlanExecutionFeedbackMessage(
|
||||
eventId = UUID.randomUUID(),
|
||||
spacecraftId = spacecraftId,
|
||||
planId = planId,
|
||||
occurredAt = LocalDateTime.now(),
|
||||
reason = null
|
||||
)
|
||||
|
||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong())
|
||||
|
||||
private fun planningProperties(): PlanningProperties =
|
||||
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300)
|
||||
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300)
|
||||
|
||||
private fun transactionTemplate(): TransactionTemplate =
|
||||
TransactionTemplate(
|
||||
|
||||
+115
@@ -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
|
||||
}
|
||||
}
|
||||
+78
@@ -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())
|
||||
}
|
||||
-188
@@ -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
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -35,9 +35,8 @@ class VisibilityFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same visibility payload updates fetchedAt but does not enqueue recalculation`() {
|
||||
fun `same visibility payload updates fetchedAt and persists snapshot`() {
|
||||
val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
val payload = rawPayloadArray(stationId = 7)
|
||||
val existing = SpacecraftVisibilitySnapshotEntity(
|
||||
spacecraftId = "25544",
|
||||
@@ -48,27 +47,25 @@ class VisibilityFlowTest {
|
||||
)
|
||||
`when`(repository.findById("25544")).thenReturn(Optional.of(existing))
|
||||
`when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] }
|
||||
val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService)
|
||||
val service = SpacecraftVisibilitySnapshotService(repository, parser())
|
||||
|
||||
service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0))
|
||||
|
||||
assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt)
|
||||
verify(recalculationJobService, never()).enqueue("25544", "VISIBILITY_CHANGED")
|
||||
verify(repository, times(1)).save(anyNonNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changed visibility payload stores raw payload and enqueues recalculation`() {
|
||||
fun `new visibility payload stores raw snapshot`() {
|
||||
val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java)
|
||||
val recalculationJobService = mock(PlanRecalculationJobService::class.java)
|
||||
`when`(repository.findById("25544")).thenReturn(Optional.empty())
|
||||
`when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] }
|
||||
val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService)
|
||||
val service = SpacecraftVisibilitySnapshotService(repository, parser())
|
||||
val payload = rawPayloadArray(stationId = 7)
|
||||
|
||||
service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0))
|
||||
|
||||
verify(repository, times(1)).save(anyNonNull())
|
||||
verify(recalculationJobService).enqueue("25544", "VISIBILITY_CHANGED")
|
||||
}
|
||||
|
||||
private fun parser(): VisibilityPayloadParser =
|
||||
|
||||
Reference in New Issue
Block a user