diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md deleted file mode 100644 index 600365d..0000000 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ /dev/null @@ -1,322 +0,0 @@ -# Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ - -> **Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 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 ≥ startTime−10мин` → отказ; `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 | diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index 456e91c..bb73e52 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -109,6 +109,27 @@ export async function fetchZrvWindows(spacecraftId: string): Promise { + const url = spacecraftId + ? `${BASE}/api/plans/drafts?spacecraftId=${encodeURIComponent(spacecraftId)}` + : `${BASE}/api/plans/drafts`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось получить черновики.", "http", response.status); + } + return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : []; +} + /** * Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат * (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index cacc962..ebe5c2c 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -20,7 +20,7 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers"; import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; -import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import type { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi"; import { validationToConflicts } from "./editorValidation"; import { @@ -124,6 +124,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); + // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению). + const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 }); const draft = history.draft; const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]); const mapRange = useMemo(() => { @@ -192,6 +194,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute), [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute] ); + // Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем. + const focusWorkOnMap = useCallback( + (workId: string) => { + const work = displayWorks.find((w) => w.id === workId); + const focus = workMapFocus(work, mapScene); + setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 })); + }, + [displayWorks, mapScene] + ); const contextLanes = useMemo( () => Array.from(contextSpacecraftIds).map((spacecraftId) => ({ @@ -264,8 +275,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, useEffect(() => { const controller = new AbortController(); void fetchRequestsForMap({ - endFrom: new Date(window.fromMs).toISOString(), - beginTo: new Date(window.toMs).toISOString() + endFrom: new Date(window.fromMs).toISOString().slice(0, 19), + beginTo: new Date(window.toMs).toISOString().slice(0, 19) }).then((result) => { if (!controller.signal.aborted) { setRequestItems(result.items); @@ -411,9 +422,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }; }, [rightMode, noradId, window.fromMs, window.toMs]); - // Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану. + // Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки), + // редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace). + // createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный. + const createdImplicitlyRef = useRef(false); useEffect(() => { - setDraftPlanId(undefined); + setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined); + createdImplicitlyRef.current = false; setServiceConflicts({}); }, [selectedPlan]); @@ -423,7 +438,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]); useEffect(() => () => { const stale = draftPlanIdRef.current; - if (stale) { + // Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем. + if (stale && createdImplicitlyRef.current) { // best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён). void abandonDraft(stale).catch(() => {}); } @@ -459,7 +475,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const ensureDraft = useCallback(async (): Promise => { if (draftPlanId) return draftPlanId; if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); + // Выбран уже существующий черновик — правим его напрямую, новый клон не создаём. + if (selectedPlan.status === "DRAFT") { + setDraftPlanId(selectedPlan.planId); + return selectedPlan.planId; + } const created = await createDraft(selectedPlan.planId, { mode: "replace" }); + createdImplicitlyRef.current = true; setDraftPlanId(created.draftPlanId); return created.draftPlanId; }, [draftPlanId, selectedPlan]); @@ -863,6 +885,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setNotice(undefined); }} onObjectSelect={handleMapObjectSelect} + focusPoint={mapFocus.point} + focusToken={mapFocus.token} + selectedObjectId={mapFocus.objectId} />
@@ -880,11 +905,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedWorkId(workId); setRightMode("inspect"); setNotice(undefined); + focusWorkOnMap(workId); }} onClearSelection={() => { if (rightMode === "inspect") { setSelectedWorkId(undefined); setRightMode("none"); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); } }} /> @@ -1168,11 +1195,34 @@ function buildEditorMapScene( layer: "planWorks", points: ring, status: "PLANNED", + modeId: mode.id, zIndex: zIndex++ }); } } + // Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса + // [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс. + for (const mode of planModes) { + if (mode.type !== "DROP" || mode.timeStart == null) continue; + const startMs = localDateTimeMs(mode.timeStart); + const endMs = startMs + mode.duration * 1000; + const pts = flightLineData + .map((p) => ({ t: parseUtc(p.time), lat: p.lat, lon: p.long })) + .filter((p) => p.t >= startMs && p.t <= endMs) + .sort((a, b) => a.t - b.t) + .map((p) => ({ lat: p.lat, lon: p.lon })); + if (pts.length < 2) continue; + lines.push({ + id: `drop:${mode.id}`, + layer: "tracks", + kind: "drop", + modeId: mode.id, + points: pts, + zIndex: zIndex++ + }); + } + for (const item of requestItems) { const rings = parseWktPolygon(item.geometry); for (const ring of rings) { @@ -1226,3 +1276,43 @@ function nextWorkId(ref: MutableRefObject): string { ref.current += 1; return `NW-${String(ref.current).padStart(4, "0")}`; } + +/** + * Объект карты, соответствующий включению, и точка для центрирования: + * - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура; + * - сброс → яркая линия сброса (drop) по modeId; точка — середина линии. + */ +function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } { + if (!work) return {}; + if (work.kind === "shoot") { + const polygon = + work.planModeId != null + ? scene.polygons.find((p) => p.modeId === work.planModeId) + : scene.polygons.find((p) => p.id.startsWith("surveyRoute:")); + const point: GeoPoint | undefined = + work.centerLat != null && work.centerLon != null + ? { lat: work.centerLat, lon: work.centerLon } + : polygon + ? polygonCentroid(polygon.points) + : undefined; + return { point, objectId: polygon?.id }; + } + + const line = + work.planModeId != null + ? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId) + : undefined; + const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined; + return { point, objectId: line?.id }; +} + +function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined { + if (points.length === 0) return undefined; + let lat = 0; + let lon = 0; + for (const p of points) { + lat += p.lat; + lon += p.lon; + } + return { lat: lat / points.length, lon: lon / points.length }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 89a07f3..2c58958 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -20,6 +20,11 @@ type Tgu2DMapViewProps = { onRequestsAtPoint?: (polygons: MapPolygon[]) => void; hoveredRequestPolygonId?: string; selectedRequestPolygonId?: string; + /** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */ + selectedObjectId?: string; + /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ + focusPoint?: GeoPoint; + focusToken?: number; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; @@ -68,6 +73,9 @@ export function Tgu2DMapView({ onRequestsAtPoint, hoveredRequestPolygonId, selectedRequestPolygonId, + selectedObjectId, + focusPoint, + focusToken, pickMode = false, onPickPoint, targets = [], @@ -139,6 +147,19 @@ export function Tgu2DMapView({ return () => resizeObserver.disconnect(); }, []); + // Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту + // на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken, + // поэтому повторный клик по тому же включению снова возвращает карту к объекту. + useEffect(() => { + if (!focusPoint || focusToken === undefined) return; + setView((current) => ({ + centerLon: focusPoint.lon, + centerLat: Math.max(-82, Math.min(82, focusPoint.lat)), + zoom: Math.max(current.zoom, 4.5) + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusToken]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -344,9 +365,14 @@ export function Tgu2DMapView({ const selectedPolygon = selectedPolygonRef.current; const selectedMarker = selectedMarkerRef.current; - const externalSelectedPolygon = useMemo( - () => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined, - [selectedRequestPolygonId, activePolygons] + const externalSelectedPolygon = useMemo(() => { + const id = selectedRequestPolygonId ?? selectedObjectId; + return id ? activePolygons.find((p) => p.id === id) : undefined; + }, [selectedRequestPolygonId, selectedObjectId, activePolygons]); + // Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons. + const externalSelectedLine = useMemo( + () => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined), + [selectedObjectId, scene.lines] ); const externalHoveredPolygon = useMemo( () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, @@ -396,6 +422,24 @@ export function Tgu2DMapView({ ); }); })} + {externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => { + const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize); + return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { + const visible = pts.some((p) => { + const x = p.x + dx; + return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64; + }); + if (!visible) return []; + return ( + `${p.x + dx},${p.y}`).join(" ")} + className="is-selected" + fill="none" + /> + ); + }); + })()} {overlayMarkers.map((marker) => { const pt = projection.project(marker.point); const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts index c8084f4..f428eaa 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts @@ -1,16 +1,23 @@ import type { MapProjection } from "../geometry/mapProjection"; import type { MapLine } from "../model/mapTypes"; +const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 }; +// Сброс — яркая толстая линия поверх трассы, на отрезке от начала до конца сеанса. +const DROP_STYLE = { stroke: "#ffcf3f", width: 5 }; + export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) { ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; - ctx.strokeStyle = "rgba(104, 195, 189, 0.76)"; - ctx.lineWidth = 1.2; const wrapThreshold = projection.worldSize / 2; - for (const line of lines) { + // Сбросы рисуем после трасс, чтобы их яркая линия легла поверх. + const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop")); + for (const line of ordered) { if (line.points.length < 2) continue; + const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE; + ctx.strokeStyle = style.stroke; + ctx.lineWidth = style.width; ctx.beginPath(); let prevX: number | undefined; for (const point of line.points) { @@ -27,4 +34,3 @@ export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], proj ctx.restore(); } - diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index 020bbec..b885ddd 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -32,6 +32,8 @@ export type MapPolygon = { spacecraftId?: string; requestId?: string; surveyType?: string; + /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ + modeId?: number; zIndex: number; }; @@ -42,6 +44,10 @@ export type MapLine = { spacecraftId?: string; passIndex?: number; zIndex: number; + /** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */ + kind?: "track" | "drop"; + /** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */ + modeId?: number; }; export type MapMarker = { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx index 81c7520..4b8e03b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx @@ -17,6 +17,8 @@ type CreateChainPlanDialogProps = { onClose: () => void; /** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */ onSent: () => void; + /** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */ + onCreated?: () => void; }; /** @@ -25,7 +27,7 @@ type CreateChainPlanDialogProps = { * показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки * («Отправить вручную» = promote) и последующей приёмки. */ -export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) { +export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, onCreated }: CreateChainPlanDialogProps) { const [zrvOptions, setZrvOptions] = useState([]); const [zrvLoading, setZrvLoading] = useState(true); const [zrvError, setZrvError] = useState(); @@ -78,6 +80,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: try { const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end); setResult(await createChainPlan(request)); + onCreated?.(); } catch (err) { setError( err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план." diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index ab0b4ed..caae391 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -13,14 +13,33 @@ const DECISION_GRACE_MINUTES = 30; type TguPlanDetailsProps = { plan?: TguPlanUi; + /** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */ + chainPaused?: boolean; decisionInFlight: boolean; notice?: string; error?: string; onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void; + /** Действия над черновиком (показываются только для status === "DRAFT"). */ + draftActionInFlight?: boolean; + onEditDraft?: (planId: string) => void; + onPromoteDraft?: (planId: string) => void; + onDeleteDraft?: (planId: string) => void; onClose: () => void; }; -export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) { +export function TguPlanDetails({ + plan, + chainPaused, + decisionInFlight, + notice, + error, + onDecision, + draftActionInFlight, + onEditDraft, + onPromoteDraft, + onDeleteDraft, + onClose +}: TguPlanDetailsProps) { if (!plan) { return (