Files
dc-observatio/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md
T

299 lines
41 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.
# Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ
> **Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 9).**
> Источник — разбор кода `pcp-tgu-service` и `pcp-mission-planing-service` (см. раздел 2).
> Цель документа — зафиксировать модель, API, статусы, миграции и риски, чтобы дальше резать на фазы.
---
## 1. Контекст и цель
Оператор в `pcp-tgu-ops-ui` (редактор включений, `src/features/tgu-editor/`) правит съёмку и сброс
актуального плана: строит новую съёмку, корректирует или удаляет существующую, формирует сброс.
План — **живой**: он выдаётся на борт КА и исполняется. Поэтому:
- **Нельзя** писать правки сразу в действующий план (сейчас `saveSurveyRoute`/`saveDropRoute` именно так и делают — мгновенный персист в живую миссию).
- Нужна **версия-черновик**: правки идут в неё, на каждое изменение считаются конфликты/наложения, оператор видит результат, и только по явному действию **«Применить» отправляет план вручную**.
- Нужна **возможность отката**: прежняя версия сохраняется.
- **Гард с первой кладки:** перевыдать план можно, только если он **строго в будущем и ещё не заложен**.
Сценарий оператора: *план поправили → всё проверили → отправили вручную*.
---
## 2. Что уже есть в бэке (находки разбора)
Версионирование/цепочка и ручная выдача **существуют наполовину** — строим поверх, не дублируем.
### Ось 1 — расписание/выдача: `pcp-tgu-service`, таблица `planned_plan`
- `PlannedPlanEntity` уже несёт: `base_plan_id`, `chain_version`, `issue_not_before`, `accepted_at`, `status`.
- `PlannedPlanStatus`: `PLANNED`(виртуальный, не персистится) → `ISSUING``WAITING_DECISION``ACCEPTED``AWAITING_LAYIN``LAID_IN`; терминалы `REJECTED/LAYIN_FAILED/NOT_ACCEPTED/START_AMBIGUOUS`.
- Цепочка на КА — `SpacecraftPlanningStateEntity` (`currentBasePlanId`, `chainVersion`, `activePlanId`, `urgentReissueFrom`).
- **Ручная выдача уже есть:** `IssueDuePlanWorker.issuePlanNow(planId)``prepareSpecificPlan` → BPMN. Наружу — `POST /test/tgu/plans/{planId}/issue` (за флагом `tgu.test-controller.enabled`, по умолчанию выключен).
- Гард перевыдачи: `REISSUABLE_STATUSES = {PLANNED, REJECTED, LAYIN_FAILED, NOT_ACCEPTED, START_AMBIGUOUS}`.
- Супершединг на приём: `HandlePlanDecisionUseCase.acceptPlan``status=AWAITING_LAYIN`, `currentBasePlanId=plan`, `chainVersion++`; старые планы цепочки **остаются** (это и есть история/откат).
- Выдача склеивает слот и контент по одному id: `BpmnPlanIssueProcessStarter` стартует `createSatelliteMission` с `satellitePlanId = planId`; BPMN тянет контент миссии по этому id.
- Проекция (`PlanProjectionService`) считает будущее из снапшота ЗРВ; `CalculatedPlan` = только слот `{spacecraftId, startTime, endTime, kppId}`, **без контента**.
### Ось 2 — содержимое: `pcp-mission-planing-service`
- Модель: `Mission(status="NEW") → Plan(plan_number, stage4_snapshot_id) → Mode(SURVEY|DROP)`.
- `SurveyModeEntity`: `status: SurveyModeStatus`, `source: SurveyModeSource` (`MANUAL` для ручных), `contourWkt`, `roll`, `lat/long`, `duration`.
- API уже есть: `survey-route/build` (превью), `{planId}/survey-route` и `{planId}/drop-route` (**персист в живую миссию сразу**, авто-создание миссии/плана при отсутствии), `{id}/modes`, `confirm`, `update-status`, `delete mission`.
- **Нет**: операции клонирования миссии, update/delete отдельного режима, расчёта конфликтов/наложений (`mode_booking` — это бронь слота, не конфликт).
**Связь осей: `planId` (tgu) == `missionId` (mission-planing).**
---
## 3. Модель версионирования: «клон миссии = версия»
Выбрано (согласовано): **одна правка-сессия = новая версия с новым id**, клонирующая контент.
Поскольку `planId == missionId`, новая версия = новый `planId` = новый `missionId`. Это совмещает обе оси:
один новый id и в расписании (`planned_plan`), и в контенте (`missions/plans/modes`).
```
Жизненный цикл версии:
P0 (актуальная, в будущем, не заложена)
│ оператор начинает правку → CLONE
P1 = DRAFT (planned_plan: status=DRAFT, base_plan_id=P0, chain_version=след.)
mission M1 = клон M0 (все modes скопированы)
│ правки идут только в M1; на каждую — VALIDATE (конфликты)
│ «Применить» (ручная отправка) → ISSUE(P1) [гард: P0 строго в будущем и не заложен]
P1: ISSUING → WAITING_DECISION → (внешний ACCEPT) → AWAITING_LAYIN
supersede: currentBasePlanId=P1, chain_version++
P0 остаётся в БД как история → откат = клонировать P0 в новый DRAFT и применить
```
Ключевые свойства:
- **Живой план не трогается** до «Применить»: правки только в `DRAFT`-клоне.
- **Откат**: прежние версии — обычные записи цепочки (`base_plan_id`), восстановление = новый клон из старой версии → ревалидация против «сейчас» → отправка. Не реактивация устаревшего снапшота.
- **Оптимистичная конкуренция**: на «Применить» проверяем, что база `P0` всё ещё актуальна (не уехала вперёд, не заложена); иначе — отказ «план изменился, посмотрите дифф».
### 3a. Два режима порождения версии
Клон-черновик создаётся одним из двух способов в зависимости от состояния базы:
**A. Перевыдача (replace)** — база **строго в будущем и не заложена**.
Новая версия занимает **тот же слот** и супершедит базу (раздел 3, основной флоу). Окно/КПП копируются из базы.
**B. Форк вперёд (fork-forward)** — база **уже заложена (`LAID_IN`)**.
Тот же план перевыдать нельзя (он на борту). Но можно **взять его за основу нового плана на следующую закладку** и от него повести новую цепочку:
- Создаётся новый план (`base_plan_id = заложенный`, новая цепочка), это **не** замена слота, а продолжение вперёд.
- **Окно нового плана задаёт оператор, но только выбором из доступных ЗРВ, строго в будущем** (не произвольный ввод). Так окно гарантированно совпадает с реальным выдаваемым слотом (1:1 с ЗРВ), и проблема «окно ↔ слот» снимается на входе.
- Чтобы не заполнять с нуля, черновик **преднаполняется маршрутами заложенного плана, попавшими в окно нового плана**: переносятся те `Mode`(SURVEY/DROP), чей интервал `[timeStart, timeStart+duration)` пересекается с заданным окном `[start, stop)`. Дальше оператор правит/дополняет и отправляет вручную.
```
LAID_IN P0 ──(нельзя replace)
│ оператор: «создать план на след. закладку на основе P0», задаёт окно W
P2 = DRAFT (base_plan_id=P0, окно=W)
mission M2: скопированы только modes из M0, пересёкшиеся с W
│ правки + VALIDATE → отправка вручную → ISSUE(P2) → ... → ACCEPT → новая база цепочки
```
Граница между A и B — точка невозврата из раздела 4: пока база правима в своём слоте — `replace`; как только заложена — только `fork-forward`.
---
## 4. Статусы и точка невозврата
### Новый статус
Добавить `PlannedPlanStatus.DRAFT` — персистится, но **не часть проекции/головы цепочки**: не выдаётся воркером,
не учитывается как «голова» в `PlanProjectionService`, помечается отдельно в `PlanQueryService` (видна как черновик, а не как запланированный слот).
### Гард перевыдачи / точка невозврата
**План становится невозвратным (заменить в своём слоте нельзя — доступен только `fork-forward`), когда выполнено любое из:**
- `now ≥ startTime 10 мин` — порог заморозки перед стартом (в конфиг, напр. `layinFreezeMinutes = 10`); **или**
- `status = LAID_IN` — пришло подтверждение закладки.
Соответственно **`replace`-перевыдача** базы `P0` разрешена, только если `now < startTime 10 мин` **и** `status ≠ LAID_IN`.
Лок конкуренции по КА действует **только для чужого плана**: нельзя стартовать выдачу, пока по КА летит attempt **другого** плана (`state.activeAttemptId != null && state.activePlanId != planId`). Перевыдача **того же** плана разрешена даже поверх его собственной in-flight attempt (в т.ч. авто-выдачи за `notificationBeforeStartMinutes`) — новая attempt замещает летящую. Это и сохраняет право «перевыдать именно этот план» до порога заморозки, несмотря на то что авто-выдача стартует сильно раньше (`notificationBeforeStartMinutes ≫ layinFreezeMinutes`).
Важно: статус `AWAITING_LAYIN` **сам по себе НЕ** делает план невозвратным — принятый план правим, пока до старта больше 10 минут и нет подтверждения закладки. (Прежняя неоднозначность снята.)
**Невозвратный план — не тупик:** `replace` запрещён, но доступен **`fork-forward`** (раздел 3a) — создать новый план на следующую ЗРВ на основе текущего и повести новую цепочку. То есть невозвратность закрывает замену слота, но не закрывает развитие плана вперёд.
---
## 5. API — что добавить
### `pcp-mission-planing-service` (контент)
1. `POST /api/missions/{id}/clone``{ newMissionId }`. Клон миссии в новый `missionId` (= новый `planId`). Два варианта наполнения:
- **replace**: глубокий клон — все `Mode`(SURVEY/DROP) с контурами.
- **fork-forward**: клон **только тех `Mode`**, чей интервал `[timeStart, timeStart+duration)` пересекается с переданным окном `{ windowStart, windowStop }` (окно задаёт оператор). Маршруты вне окна не переносятся.
2. `PATCH /api/missions/survey-modes/{modeId}` — правка съёмки (перестроить маршрут: окно/крен → новый контур). Сейчас правки нет, есть только смена статуса.
3. `DELETE /api/missions/modes/{modeId}` — удалить включение из черновика.
4. `POST /api/missions/{id}/validate``{ conflicts: [...] }`**новая логика** (раздел 6).
5. (опц.) `GET /api/missions/{id}/versions` или связка через tgu — список версий для отката.
> Существующие `survey-route`/`drop-route` остаются, но **адресуются на DRAFT-миссию**, а не на живую. Авто-создание миссии на лету для DRAFT-сценария отключить/пересмотреть.
### `pcp-tgu-service` (расписание/выдача)
1. `POST /api/plans/{planId}/draft` → создаёт `DRAFT`-план (`base_plan_id`), триггерит клон миссии, возвращает `{ draftPlanId }`. Параметр `mode`:
- `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку.
- `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ.
2. `POST /api/plans/{planId}/issue`**боевой** эндпоинт ручной выдачи (поднять из `TestTguController` в `PlanController`), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную».
3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии).
4. Цепочка/откат: переиспользуем `base_plan_id`/`chain_version`; восстановление версии = `draft(from=Pk)``issue`.
---
## 6. Валидация (конфликты) — «Проверить → бэкенд»
> **РЕШЕНО (MVP): валидация — номинальная заглушка.** На первом этапе `POST /api/missions/{id}/validate` существует и возвращает корректный контракт (пустой/тривиальный список конфликтов), но **реальных правил не считает**. Главное — endpoint есть и UI на него завязан. Полноценные правила — отдельной задачей.
**TODO (отдельная задача — реальные правила валидации):**
- [ ] **Пересечение по времени** между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс).
- [ ] **Видимость цели**: окно съёмки внутри окна видимости точки (баллистика).
- [ ] **Сеанс связи**: окно сброса внутри ЗРВ станции.
- [ ] **Порядок сброса**: сбрасываемые съёмки завершаются строго до начала зоны (уже есть в UI как фильтр кандидатов — закрепить на бэке).
- [ ] **Бортовые ограничения**: длительность/скважность, энергетика, память (обсудить).
- [ ] Возврат **диффа конфликтов** на затронутые режимы (для отзывчивости), а не всего плана.
Сейчас проверка локальная (`editorConflicts.ts`); при переносе на бэк UI делает оптимистичное локальное превью контура сразу + дебаунс-синк с эндпоинтом за авторитетными конфликтами.
---
## 7. Фронт — раскладка флоу (после бэка)
- **Открытие плана** = read-only текущей версии. Версия/черновик не создаётся.
- **Первая правка** (`+ Съёмка` / `+ Сброс` / правка / удаление) → `POST /plans/{planId}/draft` → редактор переключается на DRAFT-миссию; дальнейшие правки адресуются в неё.
- **Каждая правка** → мутация черновика + `validate` → показ конфликтов. (Заменяет локальный `checkVisibility` и заглушку `saveEditedPlan`.)
- **Кнопки** наконец непротиворечивы:
- `+ Съёмка / + Сброс` — правка в черновик (не молчаливый персист в живой план).
- **Проверить** — полный пересчёт `validate` (или убрать, т.к. валидация на каждой правке).
- **Применить** — `POST /plans/{draftPlanId}/issue` (ручная отправка), кнопка погашена, если база не проходит гард (не в будущем / заложена).
- **Удалить съёмку** = `DELETE mode` в черновике. **Корректировать** = `PATCH survey-mode` (перестроение маршрута).
- **Откат** = выбрать версию из цепочки → «Восстановить» → новый черновик из неё → ревалидация → отправка.
> Правый инфо-панель включения (виток, аппарат, крен, захват, центр + место под задания) — **уже сделан** в предыдущей итерации (`WorkInspector`), переиспользуется как есть.
---
## 8. Миграции и конфиг
- **tgu**: значение enum `DRAFT` (хранится строкой — миграция данных не нужна, но проверить CHECK-констрейнты/маппинг в `PlanQueryService` и проекции, чтобы DRAFT не попадал в голову цепочки). Возможна колонка-владелец черновика/`created_by` для GC. Снять флаг `tgu.test-controller` с боевого `issue` (вынести в `PlanController`).
- **mission-planing**: схема не меняется (клон = новые строки; validate/patch/delete — без новых таблиц). Проверить каскады при `DELETE mode` и при удалении DRAFT-миссии.
- **GC/TTL**: брошенные DRAFT (`planned_plan` + клон-миссия) чистить по TTL — иначе копятся.
---
## 9. Открытые решения (нужно закрыть до кода)
1. **GC-политика черновиков** — TTL и кто чистит (scheduled worker по аналогии с `*UpdateWorker`).
2. **Видимость DRAFT в общем read-пути** — показывать только автору сессии или всем как «черновик»; как метить в `PlanResponse`.
**Решено (зафиксировано):**
- Версия = **клон миссии** (новый `planId==missionId`).
- Два режима: `replace` (база в будущем, не заложена → замена слота) и `fork-forward` (база невозвратна → новый план на следующую ЗРВ, новая цепочка).
- **Точка невозврата:** план невозвратен, когда `now ≥ startTime 10 мин` ИЛИ `status = LAID_IN`. До этого (в т.ч. в `AWAITING_LAYIN`) план правим через `replace`. Порог 10 мин — в конфиг (`layinFreezeMinutes`).
- **Отмена прежней базы на борту — не нужна:** при супершединге достаточно того, что новый план перекрывает старый по слоту. Явного сигнала «отменить ранее заложенное» не вводим.
- **Валидация в MVP — номинальная заглушка** (endpoint есть, правил нет); реальные правила — отдельной задачей (TODO в разделе 6).
- При `fork-forward` окно нового плана оператор **выбирает из доступных будущих ЗРВ** (не произвольный ввод) — окно совпадает с реальным слотом, снимая проблему «окно ↔ слот ЗРВ». Преднаполнение — маршруты базы, **пересёкшиеся с окном нового плана** `[start, stop)`.
---
## 10. Риски
- **Супершединг уже выданного/заложенного** (решено: отмену на борту не вводим, опираемся на перекрытие слота). Остаточный риск: новый план должен надёжно перекрыть старый по слоту/времени — если перекрытие неполное, борт может частично исполнить старую базу. Контроль перекрытия слота при `replace` обязателен.
- **Порог заморозки (10 мин) vs lead-time выдачи** — *снято в Ф1*: `notificationBeforeStartMinutes=263``layinFreezeMinutes=10`, поэтому в окне `[startTime 263м, startTime 10м)` план обычно уже в авто-выдаче (`ISSUING/WAITING_DECISION`). Лок активной attempt больше этому не мешает: перевыдача **того же** плана разрешена поверх его in-flight attempt (новая замещает старую). Остаточный момент: летят два BPMN-процесса по одному плану, решение по старой attempt запишется как stale (lifecycle не трогает) — для Ф1 приемлемо; явную отмену/cancel старой attempt можно добавить позже, если понадобится.
- **Две оси по одному id**: клон должен атомарно создавать и `planned_plan(DRAFT)`, и клон-миссию; частичный сбой → осиротевший id. Нужна согласованность (saga/компенсация или общий триггер через BPMN).
- **Дрейф снапшота ЗРВ** под DRAFT: окно базы могло сместиться, пока правили. На «Применить» — ревалидация окна против актуальной проекции.
- **Конкуренция операторов**: оптимистичный лок на базе обязателен, иначе тихий клобер.
---
## 11. Фазы реализации
> Каждая фаза — самостоятельный PR с тестами и критериями приёмки. Помета **«Модель»** — рекомендация,
> кому отдавать: `Sonnet` (механика по чёткому контракту), `Opus` (reasoning-heavy: гонки/согласованность),
> `Sonnet+ревью Opus` (можно Sonnet, но дифф смотрит Opus). Порядок — снизу вверх по зависимостям;
> фазы 2–4 можно вести параллельно после согласования контрактов.
### Фаза 1 — Боевой endpoint ручной выдачи + гард точки невозврата — ✅ СДЕЛАНО
**Модель: Opus** (тонкое взаимодействие порога, lead-time и лока attempt).
> **Реализовано** (commit на ветке `tgu-ops-ui`):
> - `POST /api/plans/{planId}/issue` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → `IssueDuePlanWorker.issuePlanNow`; дублирующий endpoint удалён из `TestTguController`.
> - Гард [`assertReissuableInSlot`](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): активная выдача **другого** плана КА → **409**, `LAID_IN` → **422**, `now ≥ startTime layinFreezeMinutes` → **422**; нет плана → **404**. `REISSUABLE_STATUSES` удалён (гард по времени/статусу заместил список).
> - **Перевыдача того же плана поверх авто-выдачи разрешена:** если план уже ушёл в авто-выдачу за `notificationBeforeStartMinutes` (attempt в полёте по этому же плану), оператор всё равно может перевыдать его вручную вплоть до порога заморозки — новая attempt (`attemptNo++`) замещает летящую, активная attempt КА переключается на неё. Блокирует только активная выдача **другого** плана. Это реализует требование: «в автомате выдаём за 263 мин, но сохраняем возможность перевыдать именно этот план, пока до старта > 10 мин».
> - `layinFreezeMinutes` добавлен в `PlanningProperties` + `config-repo/pcp-tgu-service.yaml` + `application.yml` (дефолт 10).
> - Тесты: 6 граничных кейсов в `IssueDuePlanWorkerOnDemandTest` (вкл. перевыдачу того же плана поверх in-flight attempt и блокировку 409 на чужой план); `PlanIssueAndDecisionFlowTest` переписан под новую семантику (повтор того же плана замещает attempt, а не падает). Весь модуль `:services:pcp-tgu-service:test` зелёный.
- `pcp-tgu-service`:
- Вынести ручную выдачу из [TestTguController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt#L60) в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt): `POST /api/plans/{planId}/issue`.
- Гард в [IssueDuePlanWorker.prepareSpecificPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt#L135): разрешить только если `now < startTime layinFreezeMinutes` **и** `status ≠ LAID_IN` **и** нет активной attempt по КА (`state.activeAttemptId == null`). Иначе `409/422` с понятной причиной.
- Пересмотреть `REISSUABLE_STATUSES` — гард по времени/статусу замещает узкий список статусов (правимы и `AWAITING_LAYIN`, и `ACCEPTED`, пока до старта > порога).
- `layinFreezeMinutes` в `PlanningProperties` + `config-repo/pcp-tgu-service.yaml` (дефолт 10).
- **Приёмка:** будущий не-`LAID_IN` план > порога → attempt создаётся, BPMN стартует; `now ≥ startTime10мин` → отказ; `LAID_IN` → отказ; активная attempt по КА → отказ.
- **Тесты:** unit-таблица граничных случаев (время × статус) по образцу `IssueDuePlanWorkerOnDemandTest`.
### Фаза 2 — Черновик-клон (replace + fork-forward) — ✅ СДЕЛАНО
**Модель: Sonnet+ревью Opus** (механика clone простая, но атомарность двух осей смотрит Opus).
> **Реализовано:**
> - `POST /api/missions/{id}/clone` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt): replace — полный клон; fork-forward — только включения, перекрывающиеся с окном `(modeStop > windowStart && modeStart < windowStop)`. DropMode клонируется только если хотя бы одна из его съёмок вошла в клон (иначе пустой drop пропускается). Перекрытие: открытый правый конец `[start, stop)`.
> - `PlannedPlanStatus.DRAFT` в [PlannedPlanStatus](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt) — персистируется, не входит в проекцию и read-путь.
> - `POST /api/plans/{planId}/draft` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt): создаёт DRAFT-план с `planId = newMissionId` (инвариант planId==missionId сохраняется); при сбоге save — компенсация (delete клон-миссии). Для fork-forward валидирует, что окно есть в проекции КА.
> - DRAFT отфильтрован в [PlanQueryService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) (оба метода).
> - [MissionPlaningClient](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt) + `MissionPlaningProperties` + `mission-planing.base-url` в yaml.
> - Тесты: 6 кейсов в `MissionCloneTest` (граничные случаи предиката перекрытия); 1 кейс в `PlanQueryServiceTest` (DRAFT не виден). Оба модуля зелёные.
- `pcp-mission-planing-service`: `POST /api/missions/{id}/clone` body `{ mode, windowStart?, windowStop? }``{ newMissionId }`. `replace` — полный клон `Plan`+`Mode`; `fork-forward` — только `Mode`, пересёкшиеся с окном `[start,stop)`. Валидировать, что окно `fork-forward` = существующая будущая ЗРВ.
- `pcp-tgu-service`: `POST /api/plans/{planId}/draft` body `{ mode, windowStart?, windowStop? }` → создаёт `planned_plan(DRAFT, base_plan_id)`, триггерит clone миссии, возвращает `{ draftPlanId }`. **Атомарность:** оба создания координируются; при сбое clone — компенсация (не оставлять осиротевший `planned_plan`).
- `PlannedPlanStatus.DRAFT`; [PlanProjectionService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt) и `PlanQueryService``DRAFT` не голова цепочки и не в проекции.
- Миграция: проверить enum/CHECK-констрейнт статуса.
- **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша.
- **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе.
### Фаза 3 — Правки контента в черновик — ✅ СДЕЛАНО
**Модель: Sonnet.**
> **Реализовано:**
> - `PATCH /api/missions/survey-modes/{modeId}` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [updateSurveyRoute](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): перестраивает контур существующей съёмки тем же `buildContour`, что и `/survey-route/build`, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в `MANUAL` (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит `SurveyRouteBuildRequestDTO`.
> - `DELETE /api/missions/modes/{modeId}` → [deleteMode](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): диспатч SURVEY/DROP. Съёмка — снимаются брони слотов (`mode_booking`) и связи `drop_mode_survey`; сброс, оставшийся без единой съёмки, удаляется (осиротевший); другие съёмки того же сброса не трогаются. Сброс — удаляются только его связи и он сам, съёмки независимы. Нет режима → 404.
> - `saveSurveyRoute`/`saveDropRoute` больше **не создают живую миссию на лету** — адресуется только существующая миссия-черновик; нет миссии → 404. В `saveSurveyRoute` проверка миссии вынесена **до** дорогого `buildContour` (fail-fast). Swagger-описания и kdoc обновлены.
> - Репозиторий: `DropModeSurveyRepository.findAllBySurveyMode_Id` (поиск связей по съёмке).
> - Тесты: `MissionEditModesTest` (6) — каскады DELETE (осиротевший сброс удаляется; общий сброс с другой съёмкой остаётся; удаление сброса не трогает съёмки; 404 на неизвестный режим) + 404 на `saveSurveyRoute`/`saveDropRoute` без миссии. Весь модуль зелёный.
- `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`.
- Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария.
- **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы.
- **Тесты:** PATCH меняет контур; DELETE с каскадами.
### Фаза 4 — Валидация-заглушка — ✅ СДЕЛАНО
**Модель: Sonnet.**
> **Реализовано:**
> - `POST /api/missions/{id}/validate` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [validateMission](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): проверяет существование миссии (404, если нет) и возвращает корректный контракт с **пустым** списком конфликтов. Реальные правила — TODO раздела 6.
> - Контракт `MissionValidationResponse { missionId, conflicts: [ModeConflict] }`, где `ModeConflict { modeId, rule, severity, message, relatedModeId? }` ([MissionValidationDTOs.kt](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt)). Коды правил (`ConflictRule`: TIME_OVERLAP/TARGET_VISIBILITY/COMMS_SESSION/DROP_ORDER/ONBOARD_LIMIT) и уровни (`ConflictSeverity`: error/warning) заведены сразу как стабильная схема — появление реальных правил не сломает фронт. Дифф по режимам (а не весь план).
> - Тесты: 2 кейса в `MissionEditModesTest` (валидация существующей миссии → пустой список; неизвестной → 404). Весь модуль зелёный.
- `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6.
- **Приёмка:** `200` с пустым diff; контракт стабилен для фронта.
### Фаза 5 — «Применить» = promote
**Модель: Opus** (сердце корректности живого плана).
- `pcp-tgu-service`: promote `draftPlanId` → выдача с **оптимистичным локом базы** (проверить, что `P0`/`currentBasePlanId` не изменились с момента создания черновика; иначе `409` «план изменился, посмотрите дифф»). Вход в цепочку: при внешнем `ACCEPT` существующий [acceptPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt#L142) делает супершединг (`chainVersion++`); для `fork-forward` `base_plan_id` уже выставлен.
- Контроль **полноты перекрытия слота** при `replace` (остаточный риск раздела 10).
- **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен).
- **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки.
### Фаза 6 — Фронт (редактор)
**Модель: Sonnet.**
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку [saveEditedPlan](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts#L13) на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`.
- [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): lazy-создание черновика на первую правку; адресация правок в `DRAFT`; на каждой правке — `validate`.
- [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = `validate` (или скрыть), «Применить» = `promote` (гасить по гарду точки невозврата).
- Выбор будущей ЗРВ для `fork-forward` (мини-панель/диалог); откат: список версий цепочки → «восстановить».
- **Приёмка:** ручной прогон сценария (правка → проверка → применить); кнопки соответствуют состоянию; `tsc`/`vitest` зелёные.
### Сводка по моделям
| Фаза | Тема | Модель |
|---|---|---|
| 1 | Endpoint выдачи + гард | **Opus** |
| 2 | Черновик-клон | Sonnet + ревью Opus |
| 3 | Правки контента | Sonnet |
| 4 | Валидация-заглушка | Sonnet |
| 5 | Promote | **Opus** |
| 6 | Фронт | Sonnet |