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

44 KiB
Raw Blame History

Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ

Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 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(виртуальный, не персистится) → ISSUINGWAITING_DECISIONACCEPTEDAWAITING_LAYINLAID_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.acceptPlanstatus=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=263layinFreezeMinutes=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 в PlanControllerIssueDuePlanWorker.issuePlanNow; дублирующий endpoint удалён из TestTguController.
  • Гард assertReissuableInSlot: активная выдача другого плана КА → 409, LAID_IN422, now ≥ startTime layinFreezeMinutes422; нет плана → 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 в PlanController: POST /api/plans/{planId}/issue.
    • Гард в IssueDuePlanWorker.prepareSpecificPlan: разрешить только если 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: replace — полный клон; fork-forward — только включения, перекрывающиеся с окном (modeStop > windowStart && modeStart < windowStop). DropMode клонируется только если хотя бы одна из его съёмок вошла в клон (иначе пустой drop пропускается). Перекрытие: открытый правый конец [start, stop).
  • PlannedPlanStatus.DRAFT в PlannedPlanStatus — персистируется, не входит в проекцию и read-путь.
  • POST /api/plans/{planId}/draft в PlanControllerCreateDraftUseCase: создаёт DRAFT-план с planId = newMissionId (инвариант planId==missionId сохраняется); при сбоге save — компенсация (delete клон-миссии). Для fork-forward валидирует, что окно есть в проекции КА.
  • DRAFT отфильтрован в PlanQueryService (оба метода).
  • MissionPlaningClient + 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 и PlanQueryServiceDRAFT не голова цепочки и не в проекции.
  • Миграция: проверить enum/CHECK-констрейнт статуса.
  • Приёмка: replace-клон копирует все режимы; fork-forward — только пересёкшиеся; DRAFT не виден как запланированный слот; сбой clone не оставляет осиротыша.
  • Тесты: предикат перекрытия (границы окна), фильтр DRAFT в проекции/запросе.

Фаза 3 — Правки контента в черновик — СДЕЛАНО

Модель: Sonnet.

Реализовано:

  • PATCH /api/missions/survey-modes/{modeId} в MissionControllerupdateSurveyRoute: перестраивает контур существующей съёмки тем же buildContour, что и /survey-route/build, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в MANUAL (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит SurveyRouteBuildRequestDTO.
  • DELETE /api/missions/modes/{modeId}deleteMode: диспатч 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 на адресацию DRAFT-миссии; убрать авто-создание живой миссии для editor-сценария.
  • Приёмка: правка/удаление затрагивают только DRAFT; живая миссия неизменна; контур пересчитан корректно; каскады при DELETE (связь drop_mode ↔ survey) целы.
  • Тесты: PATCH меняет контур; DELETE с каскадами.

Фаза 4 — Валидация-заглушка — СДЕЛАНО

Модель: Sonnet.

Реализовано:

  • POST /api/missions/{id}/validate в MissionControllervalidateMission: проверяет существование миссии (404, если нет) и возвращает корректный контракт с пустым списком конфликтов. Реальные правила — TODO раздела 6.
  • Контракт MissionValidationResponse { missionId, conflicts: [ModeConflict] }, где ModeConflict { modeId, rule, severity, message, relatedModeId? } (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 в PlanControllerIssueDuePlanWorker.promoteDraftNow: под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуя startPreparedAttempt).
  • Оптимистичный лок базы: CreateDraftUseCase снимает spacecraft_planning_state.chain_version на момент создания черновика и хранит его в planned_plan.chain_version DRAFT-строки. На promote assertDraftBaseUnchanged требует совпадения с текущей версией цепочки; любой ACCEPT/grace-сдвиг базы её инкрементит → 409 «база уехала, посмотрите дифф». Прежние версии остаются в БД (откат = новый черновик из старой версии).
  • Контроль слота/дрейфа ЗРВ (assertDraftSlotStillValid): окно черновика должно совпадать с реальной будущей ЗРВ — для 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 делает супершединг (chainVersion++); для fork-forward base_plan_id уже выставлен.
  • Контроль полноты перекрытия слота при replace (остаточный риск раздела 10).
  • Приёмка: успешный promote будущего черновика → ISSUING→…; конкурентный promote (база уехала) → 409; старая версия остаётся в БД (откат возможен).
  • Тесты: оптимистичный лок (база сменилась между draft и promote), супершединг цепочки.

Фаза 6 — Фронт (редактор)

Модель: Sonnet.

  • src/features/tgu-editor/editorApi.ts: заменить заглушку saveEditedPlan на вызовы draft/promote; src/api/missionPlanningApi.ts: clone/patch/delete/validate.
  • TguEditorTab: lazy-создание черновика на первую правку; адресация правок в DRAFT; на каждой правке — validate.
  • EditorToolbar: «Проверить» = validate (или скрыть), «Применить» = promote (гасить по гарду точки невозврата).
  • Выбор будущей ЗРВ для fork-forward (мини-панель/диалог); откат: список версий цепочки → «восстановить».
  • Приёмка: ручной прогон сценария (правка → проверка → применить); кнопки соответствуют состоянию; tsc/vitest зелёные.

Сводка по моделям

Фаза Тема Модель
1 Endpoint выдачи + гард Opus
2 Черновик-клон Sonnet + ревью Opus
3 Правки контента Sonnet
4 Валидация-заглушка Sonnet
5 Promote Opus
6 Фронт Sonnet