Files
dc-observatio/docs/tasks/TASK_tgu-plan-on-demand-redesign.md
T
Дмитрий Соловьев c76a823dbc Редизайн 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.
2026-06-02 14:32:51 +03:00

298 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Задача: pcp-tgu-service — «храним только выданное» + закладочный флоу
> ТЗ для кодинг-агента и для приёмки. Раздел 0 — промпт запуска.
> Разделы 1–9 — что и как делаем. Раздел 10 — **критерии приёмки** (по ним проверяем результат).
> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`.
---
## 0. Промпт для запуска агента (copy-paste)
```
Ты работаешь в монорепозитории observatio-terrae (pcp), модуль services/pcp-tgu-service (Kotlin/Spring Boot).
Задача: перестроить хранение и выдачу планов ТГУ — перестать материализовать
будущую цепочку планов в БД (хранить только выданные планы и их историю — вечно),
считать будущее на лету как проекцию, и добавить закладочный флоу с реакцией
на два Kafka-события «не заложен» и «не принят».
Перед началом обязательно прочитай:
1. /AGENTS.md — правила работы, стиль, формат финального ответа.
2. /docs/tasks/TASK_tgu-plan-on-demand-redesign.md — это ТЗ.
3. Текущий код: services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/
(service/, entity/, repository/, controller/, integration/kafka/, config/).
Делай строго по фазам раздела 5. Всё — в одной ветке/одном PR.
Ядро расчёта цепочки (PlanCalculationService) НЕ меняем — оно уже монотонно-корректно.
После работы прогоняй ./gradlew :services:pcp-tgu-service:test из корня монорепо.
В конце дай отчёт в формате из AGENTS.md и пройдись по разделу 10 (критерии приёмки).
```
---
## 1. Контекст и цель
`pcp-tgu-service` (порт `:7011`) — роллинг-планировщик сеансов управления по каждому операционному КА. Поток: список КА из НСИ → окна видимости (ЗРВ) из баллистики → расчёт цепочки планов (~`target` 1440±240 мин, привязка 1:1 к ЗРВ, строгая монотонность) → выдача дозревшего плана через короткий BPMN → решение из Kafka. UI (через агрегатор `:7008`) рисует таймлайн планов.
**Проблема сейчас.** Таблица `planned_plan` материализует **всю будущую цепочку** как `PLANNED`-строки и переписывает её на каждое изменение хэша ЗРВ. Это копит «лишнее»: пачки `SUPERSEDED`/`EXPIRED` на каждый пересчёт, рост без границ, отдельная машинерия `plan_recalculation_job`, и целый класс багов рассинхрона цепочки со снапшотом («схлопывание в SUPERSEDED», «зомби-PLANNED»). При этом будущая цепочка — **детерминированная проекция** от (снапшот ЗРВ + базовый план + конфиг).
**Цель.**
1. Хранить в `planned_plan` **только планы, перешедшие в выдачу**, и их историю — **вечно** (ретеншн не делаем).
2. Будущее (не выданное) — **не хранить вовсе**, считать на лету как проекцию (в т.ч. для UI).
3. Добавить **закладочный флоу**: после `ACCEPTED` план оптимистично считается заложенным; реагируем только на явный негатив — два раздельных Kafka-события «не заложен» и «не принят», оба пока ведут себя одинаково (срочно выдать план на ближайшую ЗРВ), но разнесены для будущего расхождения.
4. Реагировать на **изменение параметров станций** (Kafka-событие): станция могла выйти из строя — нужно заново запросить ЗРВ у баллистики по затронутым КА и пересобрать цепочку. В on-demand-модели это сводится к форсу `visibility_refresh_job` → новый снапшот → проекция пересчитается сама.
5. Снести мёртвый код (`tracked_plan`) и ставшую ненужной машинерию пересчёта (`plan_recalculation_job`).
Масштаб: **100150 КА**, горизонт планирования **до 10 суток**, деплой **23 реплики** (горизонтальное масштабирование уже обеспечено координацией через БД: `FOR UPDATE SKIP LOCKED` + pessimistic-lock на КА).
---
## 2. Границы задачи (scope)
**В scope:**
- Перестроение хранения/выдачи планов в `pcp-tgu-service` (backend, Kotlin).
- Новый сервис проекции; переписанный воркер выдачи; read-эндпоинт, отдающий persisted ∪ проекцию.
- Новые статусы плана и закладочный флоу с двумя новыми Kafka-консьюмерами.
- Удаление мёртвого кода и снятие `plan_recalculation_job`.
- Миграции БД, изменения конфига (`application.yml` + `config-repo/pcp-tgu-service.yaml`).
- Юнит-тесты на новую логику.
**Вне scope (без отдельного запроса):**
- Фронтенд `pcp-tgu-ops-ui` и агрегатор `pcp-ui-service` — контракт `/api/plans` сохраняем неизменным, фронт не трогаем.
- Изменение ядра расчёта `PlanCalculationService` (кроме переиспользования; алгоритм не меняем).
- BPMN-схемы (`issueSatellitePlan`, `updateAcceptedPlanSlots`) — не меняем.
- Источник/продюсер новых Kafka-событий «не заложен»/«не принят» — мы только консьюмеры; контракт ниже фиксируем.
- Ретеншн/архивация — НЕ делаем (выданное храним вечно).
---
## 3. Текущая архитектура (точки изменения)
- [PlannedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt) / `planned_plan` — хранит всю цепочку, upsert по бизнес-ключу `(spacecraft, start, end, kpp)`.
- [RebuildPlanScheduleUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt) — пересобирает цепочку: `calculatePlans/After` → upsert → `supersedeObsoleteFuturePlans``expireMissedPlans`.
- [PlanCalculationService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt) — **ядро, не трогаем**.
- [SpacecraftVisibilitySnapshotService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt) — на изменение хэша ставит `plan_recalculation_job`.
- [PlanRecalculationWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt) / `PlanRecalculationJobService` / `plan_recalculation_job`**снимаем целиком**.
- [IssueDuePlanWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) — сейчас сканирует `PLANNED` (`findDueForUpdate`). Переписываем на on-demand.
- [HandlePlanDecisionUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt) — обработка `ACCEPTED/REJECTED`; `promoteNextPlanOrEnqueueRebuild` завязан на хранимые `PLANNED` → переписываем.
- [PlanQueryService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) / [PlanController](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) — read-путь, дополняем проекцией.
- Мёртвый код: [TrackedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt), `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository` — на репозиторий нет ссылок.
---
## 4. Целевая архитектура
- `planned_plan` пополняется строкой **только в момент выдачи** (статус `ISSUING`). Дальше строка живёт по жизненному циклу выдачи и **хранится вечно**.
- **Будущее не хранится.** `PlanProjectionService.project(spacecraftId)` считает цепочку на лету из последнего снапшота ЗРВ + текущей базы + конфига, переиспользуя `PlanCalculationService`.
- **Выдача** ([IssueDuePlanWorker]) идёт по активным КА, считает голову проекции; если голова «дозрела» (`issue_not_before ≤ now`) или взведён `urgent_reissue_from` — материализует **ровно эту голову** в `ISSUING` и выдаёт. Никаких `PLANNED`-строк в БД.
- **Read** ([PlanQueryService]) отдаёт `persisted (вся история) ∪ проекция будущего` (виртуальный `PLANNED`, в БД не пишется), дедуп по бизнес-ключу. Контракт `/api/plans` неизменен.
- **Закладочный флоу**: `ACCEPTED``AWAITING_LAYIN` (оптимистично = база, цепочка идёт дальше). Два новых события гасят план терминально (`LAYIN_FAILED` / `NOT_ACCEPTED`) и взводят срочную перевыдачу на ближайшую ЗРВ с лидом `now + reissue-prep-minutes`.
---
## 5. Фазы работ
### Фаза 0. Чистка мёртвого кода
- Удалить `TrackedPlanEntity`, `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository`.
- Миграция `V7__drop_tracked_plan.sql`: `DROP TABLE IF EXISTS tracked_plan;`
- Убедиться, что компиляция/тесты не ссылались на эти классы.
### Фаза 1. On-demand проекция, конец материализации цепочки
- Новый `PlanProjectionService` с `project(spacecraftId): List<CalculatedPlan>` и `projectHead(spacecraftId): CalculatedPlan?`:
читает `spacecraft_visibility_snapshot`, текущую базу (`spacecraft_planning_state.currentBasePlanId`), считает цепочку через `PlanCalculationService` (`calculatePlans`/`calculatePlansAfter`). Опционально — in-memory кэш по `payload_hash` (если делаешь — инвалидируй при смене снапшота/базы; при 150 КА можно и без кэша).
- Переписать `IssueDuePlanWorker` на on-demand: идём по активным КА (`spacecraft_snapshot.active = true` / `spacecraft_planning_state`), для каждого через `PlanProjectionService.projectHead` берём ближайший план; если у КА нет активной попытки (`activeAttemptId == null`) и (`issue_not_before(head) ≤ now` **или** взведён `urgent_reissue_from`) — материализуем голову в `planned_plan` со статусом `ISSUING` и стартуем выдачу. Сохраняем claim-семантику с lock на КА.
- `RebuildPlanScheduleUseCase`: убрать материализацию (`upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans`). Логику расчёта забирает `PlanProjectionService`; сам use case либо удаляется, либо превращается в тонкую обёртку над проекцией (если на него ссылается `TestTguController` — переориентировать/упростить).
- **Снять машинерию пересчёта**: удалить `PlanRecalculationWorker`, `PlanRecalculationJobService`, `PlanRecalculationJobEntity`, `PlanRecalculationJobRepository`; убрать `enqueue(...)` из `SpacecraftVisibilitySnapshotService` (hash-проверку оставить — она просто перестаёт что-либо ставить). Миграция `V8__drop_plan_recalculation_job.sql`: `DROP TABLE IF EXISTS plan_recalculation_job;`
- `PlannedPlanRepository`: удалить ставшие мёртвыми `upsertPlannedByBusinessKey`, `findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime`, `findAllBySpacecraftIdAndStatusAndStartTimeLessThanEqualOrderByStartTime`, `findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime`. Оставить/добавить: `lockByPlanId`, выборка истории по КА и по всем для read, защита от дублей выдачи.
### Фаза 2. Read-проекция для UI
- `PlanQueryService.getAllPlans/getPlansBySpacecraftId`: вернуть `persisted projection`. Persisted — все строки `planned_plan` (история вечна; `historyDays` оставить как фильтр **отображения** левого края, БД не режем). Projection — `PlanProjectionService.project(...)` по активным КА, виртуальный статус `PLANNED`, дедуп: проекционный план не добавляем, если его бизнес-ключ уже есть среди persisted.
- `PlanResponse`: научить представлять виртуальные планы (детерминированный `planId` из бизнес-ключа, либо nullable — на усмотрение, но контракт JSON для фронта не ломать; `status = PLANNED`). Добавить фабрику `from(CalculatedPlan, status)`.
### Фаза 3. Статусы и закладочный флоу
- `PlannedPlanStatus`: добавить `AWAITING_LAYIN`, `LAID_IN`, `LAYIN_FAILED`, `NOT_ACCEPTED`. `PLANNED` оставить только как виртуальный для проекции (в БД не писать).
- `HandlePlanDecisionUseCase` (топик `satellite-plan-decision`):
- `ACCEPTED` → план `ACCEPTED` затем `AWAITING_LAYIN`, `currentBasePlanId = plan`, проекция продолжается; `acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)` (как есть).
- `REJECTED` → план терминально `REJECTED` → общий `reissueOnNearestWindow(...)`.
- Удалить `promoteNextPlanOrEnqueueRebuild` (он завязан на хранимые `PLANNED`).
- Новые события (раздельные консьюмеры, общее поведение):
- `TopicsProperties`: добавить `layinFailed` и `planNotAccepted`.
- DTO `PlanExecutionFeedbackMessage(eventId, spacecraftId, planId, occurredAt, reason)` — пока общий на оба события.
- Консьюмеры `LayinFailedConsumer`, `PlanNotAcceptedConsumer` (groupId `${spring.kafka.consumer.group-id}`).
- В use case — два метода `handleLayinFailed(msg)` / `handleNotAccepted(msg)`, **оба идемпотентны** по `eventId` через `processed_plan_decision_event`, оба зовут общий `reissueOnNearestWindow(...)`; статус помечаемого плана соответственно `LAYIN_FAILED` / `NOT_ACCEPTED`.
- `reissueOnNearestWindow(spacecraftId, failedPlanId, now)` (общий для REJECTED-после-cutoff / LAYIN_FAILED / NOT_ACCEPTED):
- под lock на КА: помечает план терминальным статусом (история — навечно), снимает его с роли базы (`currentBasePlanId` ← предыдущая валидная база или `null`), чистит `activePlanId/activeAttemptId`;
- ставит `spacecraft_planning_state.urgent_reissue_from = now + reissue-prep-minutes`.
- `IssueDuePlanWorker` urgent-path: если у КА `urgent_reissue_from != null` — голову проекции берём как ближайшую ЗРВ со `startTime ≥ urgent_reissue_from`, материализуем и выдаём немедленно (не ждём `issue_not_before`), после успешного старта `urgent_reissue_from = null`.
- Миграция `V9__planning_state_urgent_reissue.sql`: `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;`
### Фаза 3b. Событие «изменились параметры станций»
- `TopicsProperties` + конфиги: `topics.station-params-changed: pcp.tgu.station-params-changed.v1`.
- DTO `StationParamsChangedMessage(eventId, stationId?, occurredAt, reason?)`.
- Консьюмер `StationParamsChangedConsumer` (groupId `${spring.kafka.consumer.group-id}`).
- Use case `handleStationParamsChanged(msg)`: **идемпотентен** по `eventId` (общий ledger `processed_plan_decision_event`); ставит `visibility_refresh_job` (`VisibilityRefreshJobService.enqueue`, reason `STATION_PARAMS_CHANGED`) по **всем активным КА** (`spacecraft_snapshot.active = true`). Дальше [VisibilityRefreshWorker] перечитывает ЗРВ у баллистики → новый снапшот → on-demand проекция пересобирает цепочку автоматически.
- `stationId` в событии пока **не используется для сужения** (карты «станция → КА» в tgu нет; баллистика считает ЗРВ по-КА). Поле оставляем на будущее для точечного refresh; сейчас поведение — blanket refresh всех активных КА (при 150 КА дёшево).
- Уже выданные/принятые планы (`ISSUING`/`AWAITING_LAYIN`) ретроспективно не меняются — пересобирается только будущая часть цепочки (после базы). Если станция текущего плана отказала по сути — это придёт отдельным негативным событием (`layin-failed`/`not-accepted`).
### Фаза 4. Конфиг
- `PlanningProperties` + `application.yml` + `config-repo/pcp-tgu-service.yaml`:
- `external-points-lookahead-days: 3 → 10`;
- новый `reissue-prep-minutes: 30`;
- `min-reissue-lead-minutes` — оставить (REJECTED-до-cutoff).
- `TopicsProperties` + конфиги: `topics.layin-failed: pcp.tgu.layin-failed.v1`, `topics.plan-not-accepted: pcp.tgu.plan-not-accepted.v1`, `topics.station-params-changed: pcp.tgu.station-params-changed.v1` (с возможностью переопределить).
### Фаза 5. Тесты
- Сохранить `PlanCalculationServiceTest` (ядро не тронуто).
- Новые: `PlanProjectionServiceTest`, `IssueDuePlanWorker` (on-demand голова + urgent-path), `HandlePlanDecisionUseCase` (`ACCEPTED→AWAITING_LAYIN`; `REJECTED→reissue`), консьюмеры `layin-failed`/`not-accepted` (идемпотентность, раздельность, общий reissue), консьюмер `station-params-changed` (идемпотентность + постановка refresh по всем активным КА), read-проекция (`persisted projection` + дедуп).
- Удалить тесты снятой машинерии (recalc worker, supersede/expire); поправить `RebuildPlanScheduleUseCaseTest`.
---
## 6. Модель данных и миграции
| Миграция | Действие |
|---|---|
| `V7__drop_tracked_plan.sql` | `DROP TABLE IF EXISTS tracked_plan;` |
| `V8__drop_plan_recalculation_job.sql` | `DROP TABLE IF EXISTS plan_recalculation_job;` |
| `V9__planning_state_urgent_reissue.sql` | `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` |
`planned_plan` — схему не меняем (новые значения статуса — это `VARCHAR`, миграция не нужна). Индексы оставляем. `ddl-auto: validate` — значит сущности должны точно соответствовать таблицам после миграций.
---
## 7. Конфиг (итоговые значения)
```yaml
planning:
notification-before-start-minutes: 263
target-plan-duration-minutes: 1440
target-plan-duration-delta-minutes: 240
external-update-enabled: true
external-points-lookahead-days: 10 # было 3
in-memory-history-days: 14
external-update-fixed-delay-ms: 600000
notification-check-fixed-delay-ms: 30000
min-reissue-lead-minutes: 5
reissue-prep-minutes: 30 # новый
worker-lock-ttl-seconds: 300
topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
layin-failed: pcp.tgu.layin-failed.v1 # новый
plan-not-accepted: pcp.tgu.plan-not-accepted.v1 # новый
station-params-changed: pcp.tgu.station-params-changed.v1 # новый
```
---
## 8. Контракты Kafka (входящие, мы — консьюмер)
Существующий `satellite-plan-decision` (`PlanDecisionMessage`) — без изменений.
Новые топики, общий DTO `PlanExecutionFeedbackMessage`:
```json
{
"eventId": "uuid", // dedup-ключ (идемпотентность)
"spacecraftId": "string",
"planId": "uuid", // план, по которому пришёл негатив (текущая база)
"occurredAt": "2026-06-02T10:00:00",
"reason": "string|null"
}
```
- `pcp.tgu.layin-failed.v1` — «не заложен».
- `pcp.tgu.plan-not-accepted.v1` — «не принят».
- Поведение пока идентично; обработчики раздельные.
Новый топик `pcp.tgu.station-params-changed.v1`, DTO `StationParamsChangedMessage`:
```json
{
"eventId": "uuid", // dedup-ключ (идемпотентность)
"stationId": "string|null", // какая станция изменилась; пока не используется для сужения
"occurredAt": "2026-06-02T10:00:00",
"reason": "string|null"
}
```
- При получении — поставить `visibility_refresh_job` по всем активным КА (перезапрос ЗРВ у баллистики), цепочка пересоберётся через on-demand проекцию.
---
## 9. Допущения и риски
- **Источник «ближайшей ЗРВ»** для срочной перевыдачи — тот же последний снапшот ЗРВ; если в нём нет окна со `startTime ≥ urgent_reissue_from`, голова пуста — КА ждёт следующего refresh окон (это корректное поведение, не ошибка).
- **Идемпотентность** новых событий — обязательна (at-least-once Kafka), через общий `processed_plan_decision_event`.
- **`ddl-auto: validate`**: после правок прогнать миграции на чистой БД и убедиться, что Hibernate-валидация проходит.
- **Совместимость UI**: JSON-форма `/api/plans` не меняется; виртуальные планы отдаются с тем же набором полей и `status: "PLANNED"`.
- **Сборка ресурсов**: при правке `application.yml` помнить про `./gradlew :services:pcp-tgu-service:processResources` (IntelliJ-рестарт не всегда пересобирает ресурсы).
---
## 10. Критерии приёмки (по ним проверяем)
### 10.1. Хранение «только выданное»
- [ ] При штатной работе (КА с окнами ЗРВ, ни одного дозревшего плана) в `planned_plan` **нет ни одной строки** для этого КА. Будущее видно только через `/api/plans` как `PLANNED`.
- [ ] Строка в `planned_plan` появляется **ровно в момент выдачи** и сразу в статусе `ISSUING` (не `PLANNED`).
- [ ] Статус `PLANNED` **никогда не пишется** в БД (проверяемо: `SELECT count(*) FROM planned_plan WHERE status='PLANNED'` = 0 в любой момент).
- [ ] Выданные планы и их история **не удаляются** (ретеншн-джоба нет; нет `DELETE`/`SUPERSEDED`-массовой чистки будущего).
- [ ] В коде нет материализации цепочки: `upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans` удалены.
### 10.2. Проекция будущего
- [ ] `GET /api/plans` и `GET /api/plans/{spacecraftId}` возвращают persisted-планы **плюс** вычисленную проекцию будущего; JSON-контракт прежний, фронт работает без изменений.
- [ ] Проекция совпадает по содержанию с тем, что раньше материализовалось (та же монотонная цепочка из `PlanCalculationService`): для одних и тех же окон ЗРВ набор `(start,end,kpp)` будущих планов идентичен прежнему поведению.
- [ ] Нет дублей: если бизнес-ключ есть в persisted, проекция его не добавляет повторно.
- [ ] Левый край истории по-прежнему регулируется `historyDays`.
### 10.3. Снятие машинерии и мёртвого кода
- [ ] Таблиц `tracked_plan` и `plan_recalculation_job` нет (миграции `DROP` применены); связанные сущности/репозитории/воркеры/сервисы удалены.
- [ ] `SpacecraftVisibilitySnapshotService` больше не ставит recalc-job; сохранение снапшота и hash-проверка работают.
- [ ] Проект компилируется без ссылок на удалённые классы; неиспользуемые методы репозитория убраны.
### 10.4. Закладочный флоу (оптимизм + реакция на негатив)
- [ ] `ACCEPTED` → план переходит в `AWAITING_LAYIN`, становится базой; цепочка продолжается; `updateAcceptedPlanSlots`-job ставится. Никакого ожидания подтверждения закладки в виде доп. строк.
- [ ] Отсутствие любых событий после `ACCEPTED` = «заложен» (план остаётся базой, ничего не делаем).
- [ ] Событие `pcp.tgu.layin-failed.v1` по базовому плану → план в `LAYIN_FAILED`, взводится `urgent_reissue_from = now + reissue-prep-minutes`, и ближайшая ЗРВ ≥ этого времени **выдаётся немедленно** (не дожидаясь `issue_not_before`).
- [ ] Событие `pcp.tgu.plan-not-accepted.v1` → то же поведение, но статус `NOT_ACCEPTED`; обработчик **отдельный** (разные методы/консьюмеры), общее ядро `reissueOnNearestWindow`.
- [ ] Оба события **идемпотентны**: повтор того же `eventId` — no-op (повторной перевыдачи нет).
- [ ] `reissue-prep-minutes` берётся из конфига (по умолчанию 30); изменение значения меняет лид срочной выдачи.
- [ ] Если в снапшоте нет ЗРВ ≥ `urgent_reissue_from` — выдачи нет, флаг сохраняется, повторная попытка произойдёт после обновления окон (без ошибок/исключений в логе).
### 10.4b. Реакция на изменение параметров станций
- [ ] Событие `pcp.tgu.station-params-changed.v1` ставит `visibility_refresh_job` по всем активным КА (reason `STATION_PARAMS_CHANGED`).
- [ ] После перезапроса ЗРВ снапшоты обновляются, и `/api/plans` отдаёт цепочку, пересобранную по новым окнам (без перезапуска сервиса и без ручного rebuild).
- [ ] Событие идемпотентно по `eventId` (повтор — без повторной постановки refresh).
- [ ] Уже выданные/принятые планы ретроспективно не меняются; пересобирается только будущее.
### 10.5. Масштабирование / корректность под несколькими подами
- [ ] Claim во всех воркерах — `FOR UPDATE SKIP LOCKED`; выдача плана и обработка решения — под lock на КА (один план не выдаётся дважды при 2–3 подах).
- [ ] У одного КА одновременно не более одной активной попытки выдачи (`activeAttemptId`).
- [ ] Сервис стартует и работает с `external-points-lookahead-days: 10`.
### 10.6. Конфиг и миграции
- [ ] `application.yml` и `config-repo/pcp-tgu-service.yaml` содержат итоговые значения раздела 7 (lookahead 10, `reissue-prep-minutes: 30`, два новых топика).
- [ ] `ddl-auto: validate` проходит на чистой БД после применения всех миграций (V7–V9).
### 10.7. Тесты и сборка
- [ ] `./gradlew :services:pcp-tgu-service:test` — зелёный.
- [ ] Есть тесты на: проекцию, on-demand выдачу, urgent-path, `ACCEPTED→AWAITING_LAYIN`, оба новых консьюмера (идемпотентность + раздельность + общий reissue), read-объединение с дедупом.
- [ ] Старые тесты снятой машинерии удалены, оставшиеся — зелёные.
---
## 11. Команды проверки
```bash
# из корня монорепо
./gradlew :services:pcp-tgu-service:test
./gradlew :services:pcp-tgu-service:processResources # после правок application.yml
# ручная проверка флоу (нужен tgu.test-controller.enabled=true локально):
# - запустить сервис на :7011, дождаться окон ЗРВ
# - GET http://localhost:7011/api/plans/{spacecraftId} -> в ответе PLANNED-проекция, в БД строк нет
# - сэмулировать ACCEPTED -> в БД ACCEPTED/AWAITING_LAYIN, проекция продолжается
# - отправить layin-failed/not-accepted -> терминальный статус + срочная выдача ближайшей ЗРВ
```