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