Селектор «Целевая ЗРВ» в EditorToolbar: текущий слот (replace, по умолчанию)
либо будущая ЗРВ из проекции pcp-tgu-service (fetchSpacecraftPlans →
GET /api/plans/{spacecraftId}, фильтр futureZrvWindows: PLANNED строго в
будущем, кроме базы — та же проекция, что валидирует бэк). Выбор окна
переводит обзор редактора и заставляет ensureDraft слать mode=fork-forward
(naive-UTC окно + kpp); селектор блокируется после создания DRAFT.
49 KiB
Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ
Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 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 (контент)
POST /api/missions/{id}/clone→{ newMissionId }. Клон миссии в новыйmissionId(= новыйplanId). Два варианта наполнения:- replace: глубокий клон — все
Mode(SURVEY/DROP) с контурами. - fork-forward: клон только тех
Mode, чей интервал[timeStart, timeStart+duration)пересекается с переданным окном{ windowStart, windowStop }(окно задаёт оператор). Маршруты вне окна не переносятся.
- replace: глубокий клон — все
PATCH /api/missions/survey-modes/{modeId}— правка съёмки (перестроить маршрут: окно/крен → новый контур). Сейчас правки нет, есть только смена статуса.DELETE /api/missions/modes/{modeId}— удалить включение из черновика.POST /api/missions/{id}/validate→{ conflicts: [...] }— новая логика (раздел 6).- (опц.)
GET /api/missions/{id}/versionsили связка через tgu — список версий для отката.
Существующие
survey-route/drop-routeостаются, но адресуются на DRAFT-миссию, а не на живую. Авто-создание миссии на лету для DRAFT-сценария отключить/пересмотреть.
pcp-tgu-service (расписание/выдача)
POST /api/plans/{planId}/draft→ создаётDRAFT-план (base_plan_id), триггерит клон миссии, возвращает{ draftPlanId }. Параметрmode:replace(база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на первую правку.fork-forward(базаLAID_IN): окно{ windowStart, windowStop }= выбранная оператором будущая ЗРВ (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ.
POST /api/plans/{planId}/issue— боевой эндпоинт ручной выдачи (поднять изTestTguControllerвPlanController), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную».DELETE /api/plans/{draftPlanId}— отказ от черновика (с удалением клон-миссии). ✅ СДЕЛАНО — DeleteDraftUseCase (404 нет / 422 не DRAFT / 204 успех; удаление клон-миссии best-effort, строка удаляется первой) + PlanControllerDELETE /api/plans/{draftPlanId}; 4 unit-теста вDeleteDraftUseCaseTest. Фронт:abandonDraft+ кнопка «Отмена» в редакторе + авто-отмена незавершённого черновика при смене плана/уходе (best-effort, не оставляет осиротевший DRAFT).- Цепочка/откат: переиспользуем
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. Открытые решения (нужно закрыть до кода)
- GC-политика черновиков — TTL и кто чистит (scheduled worker по аналогии с
*UpdateWorker). - Видимость 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 →IssueDuePlanWorker.issuePlanNow; дублирующий endpoint удалён изTestTguController.- Гард
assertReissuableInSlot: активная выдача другого плана КА → 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 в 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).
- Вынести ручную выдачу из TestTguController в PlanController:
- Приёмка: будущий не-
LAID_INплан > порога → attempt создаётся, BPMN стартует;now ≥ startTime−10мин→ отказ;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в PlanController → CreateDraftUseCase: создаёт 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}/clonebody{ mode, windowStart?, windowStop? }→{ newMissionId }.replace— полный клонPlan+Mode;fork-forward— толькоMode, пересёкшиеся с окном[start,stop). Валидировать, что окноfork-forward= существующая будущая ЗРВ.pcp-tgu-service:POST /api/plans/{planId}/draftbody{ mode, windowStart?, windowStop? }→ создаётplanned_plan(DRAFT, base_plan_id), триггерит clone миссии, возвращает{ draftPlanId }. Атомарность: оба создания координируются; при сбое clone — компенсация (не оставлять осиротевшийplanned_plan).PlannedPlanStatus.DRAFT; PlanProjectionService иPlanQueryService—DRAFTне голова цепочки и не в проекции.- Миграция: проверить enum/CHECK-констрейнт статуса.
- Приёмка:
replace-клон копирует все режимы;fork-forward— только пересёкшиеся;DRAFTне виден как запланированный слот; сбой clone не оставляет осиротыша. - Тесты: предикат перекрытия (границы окна), фильтр
DRAFTв проекции/запросе.
Фаза 3 — Правки контента в черновик — ✅ СДЕЛАНО
Модель: Sonnet.
Реализовано:
PATCH /api/missions/survey-modes/{modeId}в MissionController → updateSurveyRoute: перестраивает контур существующей съёмки тем же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в MissionController → validateMission: проверяет существование миссии (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в PlanController → IssueDuePlanWorker.promoteDraftNow: под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуяstartPreparedAttempt).- Оптимистичный лок базы: CreateDraftUseCase снимает
spacecraft_planning_state.chain_versionна момент создания черновика и хранит его вplanned_plan.chain_versionDRAFT-строки. На 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: promotedraftPlanId→ выдача с оптимистичным локом базы (проверить, чтоP0/currentBasePlanIdне изменились с момента создания черновика; иначе409«план изменился, посмотрите дифф»). Вход в цепочку: при внешнемACCEPTсуществующий acceptPlan делает супершединг (chainVersion++); дляfork-forwardbase_plan_idуже выставлен.- Контроль полноты перекрытия слота при
replace(остаточный риск раздела 10). - Приёмка: успешный promote будущего черновика →
ISSUING→…; конкурентный promote (база уехала) →409; старая версия остаётся в БД (откат возможен). - Тесты: оптимистичный лок (база сменилась между
draftиpromote), супершединг цепочки.
Фаза 6 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote)
Модель: Sonnet.
Реализовано (ядро):
- API-слой: missionPlanningApi.ts —
updateSurveyRoute(PATCH),deleteMode(DELETE),validateMission(POST validate, типMissionValidation/ModeConflict); новый tguPlanApi.ts —createDraft/promoteDraft. Прямые вызовы через Vite-прокси:/api/pcp-mission→:7010, новый/api/pcp-tgu→:7011 (без BFF, как и было решено).- TguEditorTab: ленивое создание DRAFT (
ensureDraft, mode=replace) на первую сохранённую правку;saveSurveyRoute/saveDropRoute/deleteModeадресуются вdraftPlanId, а не в живой план; после каждой серверной правки — перечитывание включений DRAFT +validate; смена выбранного плана сбрасывает черновик; дедуп включения (локальная правка ⊕ перечитанная plan-origin копия поplanModeId).- Маппинг авторитетных конфликтов бэка на включения редактора: editorValidation.ts (по
planModeId) + 3 теста.- EditorToolbar: «Проверить» = серверный
validateпри наличии черновика (иначе локальное превью видимости); «Применить» =promoteDraft, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением.- Приёмка ядра:
tsc -b,vitest(42) иvite build— зелёные.fork-forward в UI — ✅ СДЕЛАНО:
- Селектор «Целевая ЗРВ» в EditorToolbar: «Текущий слот (правка)» (replace, по умолчанию) ⊕ будущие ЗРВ КА. Список — из проекции pcp-tgu-service (fetchSpacecraftPlans,
GET /api/plans/{spacecraftId}), отфильтрованной до PLANNED строго в будущем, кроме базы (editorForkForward.tsfutureZrvWindows+ 5 тестов). Это та же проекция, с которой бэк сверяет окно (assertWindowMatchesProjection).- TguEditorTab: выбор окна переводит обзор редактора на него;
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: 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 |