Files
dc-observatio/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md
T
Дмитрий Соловьев 057452f19d pcp-tgu-ops-ui: fork-forward в редакторе — выбор будущей ЗРВ для перевыдачи невозвратного плана
Селектор «Целевая ЗРВ» в EditorToolbar: текущий слот (replace, по умолчанию)
либо будущая ЗРВ из проекции pcp-tgu-service (fetchSpacecraftPlans →
GET /api/plans/{spacecraftId}, фильтр futureZrvWindows: PLANNED строго в
будущем, кроме базы — та же проекция, что валидирует бэк). Выбор окна
переводит обзор редактора и заставляет ensureDraft слать mode=fork-forward
(naive-UTC окно + kpp); селектор блокируется после создания DRAFT.
2026-06-03 14:12:21 +03:00

323 lines
49 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}` — отказ от черновика (с удалением клон-миссии). ✅ СДЕЛАНО — [DeleteDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt) (404 нет / 422 не DRAFT / 204 успех; удаление клон-миссии best-effort, строка удаляется первой) + [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) `DELETE /api/plans/{draftPlanId}`; 4 unit-теста в `DeleteDraftUseCaseTest`. Фронт: `abandonDraft` + кнопка «Отмена» в редакторе + авто-отмена незавершённого черновика при смене плана/уходе (best-effort, не оставляет осиротевший DRAFT).
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** (сердце корректности живого плана).
> **Реализовано:**
> - `POST /api/plans/{draftPlanId}/promote` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [IssueDuePlanWorker.promoteDraftNow](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуя `startPreparedAttempt`).
> - **Оптимистичный лок базы:** [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt) снимает `spacecraft_planning_state.chain_version` на момент создания черновика и хранит его в `planned_plan.chain_version` DRAFT-строки. На promote [assertDraftBaseUnchanged](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) требует совпадения с текущей версией цепочки; любой ACCEPT/grace-сдвиг базы её инкрементит → **409** «база уехала, посмотрите дифф». Прежние версии остаются в БД (откат = новый черновик из старой версии).
> - **Контроль слота/дрейфа ЗРВ** ([assertDraftSlotStillValid](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt)): окно черновика должно совпадать с реальной будущей ЗРВ — для fork-forward слот берётся из проекции, для replace учитывается окно заменяемого базового плана (проекция, заякоренная после плана в полёте, его не возвращает). Сместился снапшот → **422**.
> - **Гард точки невозврата** переиспользован (`assertReissuableInSlot`): заморожено / активная выдача другого плана → 422 / 409. Вход в цепочку штатный: на внешний ACCEPT существующий `acceptPlan` делает супершединг (`chainVersion++`); `basePlanId` черновика не трогаем (провенанс — что замещаем/продолжаем).
> - Не-DRAFT план → 422, неизвестный черновик → 404.
> - Тесты: 7 кейсов promote в `IssueDuePlanWorkerOnDemandTest` (успех; конкурентный сдвиг базы → 409; не-DRAFT → 422; неизвестный → 404; заморозка → 422; чужая активная выдача → 409; дрейф окна → 422) + 1 кейс снимка версии в `CreateDraftUseCaseTest`. Весь модуль зелёный.
- `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 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote)
**Модель: Sonnet.**
> **Реализовано (ядро):**
> - API-слой: [missionPlanningApi.ts](services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts) — `updateSurveyRoute` (PATCH), `deleteMode` (DELETE), `validateMission` (POST validate, тип `MissionValidation`/`ModeConflict`); новый [tguPlanApi.ts](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts) — `createDraft`/`promoteDraft`. Прямые вызовы через Vite-прокси: `/api/pcp-mission`→:7010, новый `/api/pcp-tgu`→:7011 (без BFF, как и было решено).
> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): ленивое создание DRAFT (`ensureDraft`, mode=replace) на первую сохранённую правку; `saveSurveyRoute`/`saveDropRoute`/`deleteMode` адресуются в `draftPlanId`, а не в живой план; после каждой серверной правки — перечитывание включений DRAFT + `validate`; смена выбранного плана сбрасывает черновик; дедуп включения (локальная правка ⊕ перечитанная plan-origin копия по `planModeId`).
> - Маппинг авторитетных конфликтов бэка на включения редактора: [editorValidation.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts) (по `planModeId`) + 3 теста.
> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением.
> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные.
>
> **fork-forward в UI — ✅ СДЕЛАНО:**
> - Селектор «Целевая ЗРВ» в [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Текущий слот (правка)» (replace, по умолчанию) ⊕ будущие ЗРВ КА. Список — из проекции pcp-tgu-service ([fetchSpacecraftPlans](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts), `GET /api/plans/{spacecraftId}`), отфильтрованной до PLANNED строго в будущем, кроме базы ([editorForkForward.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts) `futureZrvWindows` + 5 тестов). Это та же проекция, с которой бэк сверяет окно (`assertWindowMatchesProjection`).
> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): выбор окна переводит обзор редактора на него; `ensureDraft` шлёт `toCreateDraftRequest(targetWindow)` — fork-forward (naive-окно + kpp) при выбранной ЗРВ, иначе replace. Селектор блокируется после создания DRAFT (режим зафиксирован). Окна — naive-UTC (без зоны), как ждёт `LocalDateTime` бэка.
> - Приёмка: `tsc -b`, `vitest` (46) и `vite build` — зелёные.
>
> **Вынесено в отдельные подзадачи (не входит в ядро):**
> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote).
> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора.
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `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 |