c76a823dbc
Раньше вся будущая цепочка планов материализовалась в 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.
298 lines
32 KiB
Markdown
298 lines
32 KiB
Markdown
# Задача: 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 -> терминальный статус + срочная выдача ближайшей ЗРВ
|
||
```
|