From ec67d9d83ddef8e5be4762a664e4c61d8d9328be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:53:24 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=A46=20=D1=8F=D0=B4=D1=80?= =?UTF-8?q?=D0=BE=20=E2=80=94=20draft/validate/promote=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20(replace),?= =?UTF-8?q?=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=20/api/pcp-tgu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 16 +- .../src/api/missionPlanningApi.ts | 76 +++++++- services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 102 ++++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 174 ++++++++++++++---- .../src/features/tgu-editor/editorApi.ts | 20 -- .../src/features/tgu-editor/editorDraft.ts | 6 + .../tgu-editor/editorValidation.test.ts | 49 +++++ .../features/tgu-editor/editorValidation.ts | 29 +++ services/pcp-tgu-ops-ui/vite.config.ts | 5 + 9 files changed, 418 insertions(+), 59 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 852d7b1..47bb06c 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -287,9 +287,21 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен). - **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки. -### Фаза 6 — Фронт (редактор) +### Фаза 6 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote) **Модель: Sonnet.** -- `src/features/tgu-editor/editorApi.ts`: заменить заглушку [saveEditedPlan](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts#L13) на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`. + +> **Реализовано (ядро):** +> - 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**: выбор будущей ЗРВ (мини-панель/диалог) и `createDraft(mode=fork-forward, windowStart/Stop/kpp)` для `LAID_IN`-базы. Сейчас `ensureDraft` всегда шлёт `replace`; для заложенной базы бэк ответит 422 — сообщение показывается оператору. +> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → 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` (мини-панель/диалог); откат: список версий цепочки → «восстановить». diff --git a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts index 81226bd..574a2f4 100644 --- a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts @@ -138,7 +138,79 @@ export async function saveDropRoute(planId: string, params: SaveDropRouteParams) return JSON.parse(text) as DropMode; } -async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): Promise { +/** + * Перестроение контура существующего режима съёмки по новым параметрам окна/крена + * (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения + * в плане-черновике: контур пересчитывается тем же алгоритмом, что и /survey-route/build. + */ +export async function updateSurveyRoute(modeId: number, params: BuildSurveyRouteParams): Promise { + return postSurveyRoute(`${BASE}/api/missions/survey-modes/${encodeURIComponent(modeId)}`, params, "PATCH"); +} + +/** + * Удаление включения (съёмки или сброса) из плана-черновика + * (pcp-mission-planing-service, DELETE /api/missions/modes/{modeId}). Для съёмки бэк снимает + * брони слотов и связи со сбросами, осиротевший сброс удаляется; для сброса — только его связи. + */ +export async function deleteMode(modeId: number): Promise { + const url = `${BASE}/api/missions/modes/${encodeURIComponent(modeId)}`; + let response: Response; + try { + response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Ошибка удаления включения.", "http", response.status); + } +} + +/** Уровень конфликта валидации: error блокирует «Применить», warning — информативный. */ +export type ConflictSeverity = "error" | "warning"; + +/** Один конфликт на затронутом включении (дифф по режимам). */ +export type ModeConflict = { + /** id режима (survey или drop), к которому относится конфликт. */ + modeId: number; + /** Код правила (TIME_OVERLAP / TARGET_VISIBILITY / COMMS_SESSION / DROP_ORDER / ONBOARD_LIMIT). */ + rule: string; + severity: ConflictSeverity; + message: string; + /** id второго участника парного правила (например, пересечения по времени), если есть. */ + relatedModeId?: number | null; +}; + +export type MissionValidation = { + missionId: string; + conflicts: ModeConflict[]; +}; + +/** + * Валидация миссии-черновика на конфликты/наложения включений + * (pcp-mission-planing-service, POST /api/missions/{id}/validate). MVP: бэк-заглушка возвращает + * корректный контракт с пустым списком конфликтов; контракт стабилен для UI. + */ +export async function validateMission(missionId: string): Promise { + const url = `${BASE}/api/missions/${encodeURIComponent(missionId)}/validate`; + let response: Response; + try { + response = await fetch(url, { method: "POST", 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 JSON.parse(text) as MissionValidation; +} + +async function postSurveyRoute( + url: string, + params: BuildSurveyRouteParams, + method: "POST" | "PATCH" = "POST" +): Promise { const body = { satelliteId: params.satelliteId, time: params.time, @@ -154,7 +226,7 @@ async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): Pro let response: Response; try { response = await fetch(url, { - method: "POST", + method, headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(body) }); diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts new file mode 100644 index 0000000..f28ba27 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -0,0 +1,102 @@ +import { TguApiError } from "../model/tguTypes"; + +/** + * Вызовы версионирования/выдачи плана в pcp-tgu-service напрямую через Vite-прокси + * (`/api/pcp-tgu` → :7011), как и pcp-mission напрямую на :7010. BFF не используется. + */ +const BASE = "/api/pcp-tgu"; + +/** Режим наполнения черновика; та же строка уходит в clone миссии. */ +export type DraftMode = "replace" | "fork-forward"; + +export type CreateDraftRequest = { + mode: DraftMode; + /** Начало окна целевой ЗРВ (UTC naive, ISO без зоны). Обязательно для fork-forward. */ + windowStart?: string; + /** Конец окна целевой ЗРВ. Обязательно для fork-forward. */ + windowStop?: string; + /** КПП целевой ЗРВ. Обязательно для fork-forward. */ + windowKppId?: string; +}; + +export type CreateDraftResponse = { + /** Идентификатор созданного черновика (= mission_id клон-миссии). */ + draftPlanId: string; +}; + +export type PromoteDraftResponse = { + processInstanceKey: number; + processDefinitionKey: number; + bpmnProcessId: string; + version: number; + planId: string; + attemptId: string; +}; + +/** + * Создаёт черновик плана (DRAFT) на основе базового плана + * (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает + * draftPlanId, в который адресуются правки (Ф3) и который применяется promote (Ф5). + */ +export async function createDraft(basePlanId: string, request: CreateDraftRequest): Promise { + return postJson( + `${BASE}/api/plans/${encodeURIComponent(basePlanId)}/draft`, + request, + "Не удалось создать черновик плана." + ); +} + +/** + * «Применить» черновик: промоутит DRAFT в живую цепочку и выдаёт его + * (pcp-tgu-service, POST /api/plans/{draftPlanId}/promote). Под оптимистичным локом базы и + * гардом точки невозврата: 409 — база уехала / уже идёт выдача; 422 — не DRAFT / слот сместился / + * заморожено; 404 — черновика нет. + */ +export async function promoteDraft(draftPlanId: string): Promise { + return postJson( + `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}/promote`, + undefined, + "Не удалось применить черновик плана." + ); +} + +async function postJson(url: string, body: unknown, fallbackMessage: string): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: body === undefined + ? { Accept: "application/json" } + : { Accept: "application/json", "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || fallbackMessage, "http", response.status); + } + if (!text.trim()) { + return undefined as T; + } + return JSON.parse(text) as T; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} 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 1530538..83b0b26 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 @@ -5,7 +5,15 @@ import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; import type { RequestMapItem } from "../../model/requestTypes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; -import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi"; +import { + buildSurveyRoute, + deleteMode, + saveDropRoute, + saveSurveyRoute, + validateMission, + type SurveyRoute +} from "../../api/missionPlanningApi"; +import { createDraft, promoteDraft } from "../../api/tguPlanApi"; import { parseUtc } from "../../utils/utcTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -13,7 +21,8 @@ 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 { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi"; +import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi"; +import { validationToConflicts } from "./editorValidation"; import { addDownlink, addShoot, @@ -107,6 +116,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); const [requestItems, setRequestItems] = useState([]); const [planModes, setPlanModes] = useState([]); + // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace). + // Пока null — редактор показывает живой план read-only; после создания все правки и валидация + // адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку. + const [draftPlanId, setDraftPlanId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); @@ -141,7 +154,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const modes: EditorMode[] = DEFAULT_EDITOR_MODES; // Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне. const planWorks = useMemo(() => planModesToWorks(planModes), [planModes]); - const displayWorks = useMemo(() => [...planWorks, ...draft.works], [planWorks, draft.works]); + // id режимов, уже представленных локальной (редактируемой) работой черновика — чтобы не дублировать + // только что сохранённое включение его plan-origin копией из перечитанных planModes. + const addedModeIds = useMemo( + () => new Set(draft.works.map((work) => work.planModeId).filter((id): id is number => id != null)), + [draft.works] + ); + const displayWorks = useMemo( + () => [ + ...planWorks.filter((work) => work.planModeId == null || !addedModeIds.has(work.planModeId)), + ...draft.works + ], + [planWorks, draft.works, addedModeIds] + ); const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const selectedWork = displayWorks.find((work) => work.id === selectedWorkId); @@ -194,7 +219,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, .sort((a, b) => a.startMs - b.startMs); }, [planModes, selectedZone]); - const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); + // «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку); + // локальные несохранённые твики без DRAFT применять нечего. + const dirty = Boolean(draftPlanId); const addedCount = draft.works.filter((work) => work.isNew).length; // Реальный состав плана берём из включений (plan modes), а не из черновика: // черновик сидируется заглушкой, когда у плана нет встроенных works. @@ -383,17 +410,24 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }; }, [rightMode, noradId, window.fromMs, window.toMs]); + // Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану. useEffect(() => { - if (!selectedPlan) return; - const planId = selectedPlan.planId; + setDraftPlanId(undefined); + setServiceConflicts({}); + }, [selectedPlan]); + + // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). + useEffect(() => { + const missionId = draftPlanId ?? selectedPlan?.planId; + if (!missionId) return; let cancelled = false; - void fetchPlanModes(planId).then((modes) => { + void fetchPlanModes(missionId).then((modes) => { if (!cancelled) setPlanModes(modes); }).catch(() => { // silently ignore — plan modes are supplementary display data }); return () => { cancelled = true; }; - }, [selectedPlan]); + }, [selectedPlan, draftPlanId]); const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => { setHistory((current) => mutate(current, transform)); @@ -406,6 +440,34 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, changeDraft((current) => updateWork(current, workId, patch)); }; + // Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT (replace) + // и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. + const ensureDraft = useCallback(async (): Promise => { + if (draftPlanId) return draftPlanId; + if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); + const created = await createDraft(selectedPlan.planId, { mode: "replace" }); + setDraftPlanId(created.draftPlanId); + return created.draftPlanId; + }, [draftPlanId, selectedPlan]); + + // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId. + // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. + const revalidate = useCallback(async (missionId: string, works: EditorWork[]) => { + try { + const validation = await validateMission(missionId); + setServiceConflicts(validationToConflicts(validation, works)); + } catch { + // валидация вспомогательна — молча игнорируем сбой + } + }, []); + + // После каждой серверной правки перечитываем включения DRAFT (источник истины) и ревалидируем. + const refreshDraftModes = useCallback(async (missionId: string) => { + const modes = await fetchPlanModes(missionId); + setPlanModes(modes); + void revalidate(missionId, planModesToWorks(modes)); + }, [revalidate]); + const addShootWork = async () => { if (!target || !selectedShootPass || !modeId) return; @@ -413,13 +475,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. const numericNorad = noradId ? Number(noradId) : NaN; + // Маршрут, сохранённый в БД черновика (несёт surveyModeId для последующего delete/patch). + let savedRoute: SurveyRoute | undefined = surveyRoute; if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) { - const planId = selectedPlan.planId; const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); setRouteLoading(true); setRouteError(undefined); try { - const saved = await saveSurveyRoute(planId, { + // Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план. + const draftId = await ensureDraft(); + savedRoute = await saveSurveyRoute(draftId, { satelliteId: numericNorad, time: new Date(routeWindow.startMs).toISOString().slice(0, 19), durationSeconds: routeDurationSeconds, @@ -431,7 +496,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) }); - setSurveyRoute(saved); + setSurveyRoute(savedRoute); + void refreshDraftModes(draftId); } catch (error) { setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); return; @@ -454,11 +520,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, label: mode?.label ?? "Съёмка", modeId, target, - revolution: surveyRoute?.revolution ?? selectedShootPass.revolution, - rollDeg: surveyRoute?.roll ?? routeRoll, - captureAngleDeg: surveyRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, - centerLat: surveyRoute?.lat, - centerLon: surveyRoute?.longitude + // id сохранённого режима съёмки — чтобы удаление/правка адресовались в БД черновика. + planModeId: savedRoute?.surveyModeId, + revolution: savedRoute?.revolution ?? selectedShootPass.revolution, + rollDeg: savedRoute?.roll ?? routeRoll, + captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, + centerLat: savedRoute?.lat, + centerLon: savedRoute?.longitude }) ); setSelectedWorkId(id); @@ -492,14 +560,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, return; } - const planId = selectedPlan.planId; const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); setDropSaving(true); setDropError(undefined); + let savedDropId: number | undefined; try { - await saveDropRoute(planId, { + // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. + const draftId = await ensureDraft(); + const savedDrop = await saveDropRoute(draftId, { satelliteId: numericNorad, station: String(selectedZone.stationId), revolution: selectedZone.revolution, @@ -511,6 +581,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) }); + savedDropId = savedDrop.id; + // Перечитываем включения DRAFT (источник истины) и ревалидируем. + void refreshDraftModes(draftId); } catch (error) { setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); return; @@ -518,8 +591,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setDropSaving(false); } - // Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне. - void fetchPlanModes(planId).then(setPlanModes).catch(() => {}); const stationNm = stationNameById(selectedZone.stationId); const id = nextWorkId(idCounterRef); changeDraft((current) => @@ -528,7 +599,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, startMs: zoneStartMsVal, endMs: zoneStopMsVal, label: `Сброс · ${stationNm}`, - station: { id: String(selectedZone.stationId), name: stationNm } + station: { id: String(selectedZone.stationId), name: stationNm }, + planModeId: savedDropId }) ); setSelectedWorkId(id); @@ -537,17 +609,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedDropSurveyIds(new Set()); }; - const runCheck = () => { + // «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное + // превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было). + const runCheck = async () => { setChecking(true); - setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations })); - setChecking(false); - setNotice("Проверка видимости выполнена для текущего черновика."); + setApplyError(undefined); + try { + if (draftPlanId) { + const validation = await validateMission(draftPlanId); + setServiceConflicts(validationToConflicts(validation, planWorks)); + setNotice("Проверка выполнена на сервере (валидация черновика)."); + } else { + setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations })); + setNotice("Проверка видимости выполнена для текущего черновика."); + } + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось выполнить проверку."); + } finally { + setChecking(false); + } }; + // «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под + // оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка). const applyDraft = async () => { - const planId = selectedPlan?.planId ?? draft.planId; - if (!planId) { - setApplyError("Нельзя применить черновик: план не выбран."); + if (!draftPlanId) { + setApplyError("Нет правок для применения — черновик ещё не создан."); setNotice(undefined); return; } @@ -556,8 +643,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setApplyError(undefined); setNotice(undefined); try { - const result = await saveEditedPlan({ planId, draft }); - setNotice(result.message); + const result = await promoteDraft(draftPlanId); + setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`); + setDraftPlanId(undefined); + setServiceConflicts({}); setHistory((current) => ({ ...current, past: [], future: [] })); } catch (error) { setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана."); @@ -566,6 +655,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; + // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, + // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые + // (planModeId нет) — только локально. + const handleDeleteWork = async (workId: string) => { + const work = draft.works.find((item) => item.id === workId); + if (work?.planModeId != null && draftPlanId) { + try { + await deleteMode(work.planModeId); + await refreshDraftModes(draftPlanId); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось удалить включение."); + return; + } + } + changeDraft((current) => deleteWork(current, workId)); + setSelectedWorkId(undefined); + setRightMode("none"); + }; + const zoom = (factor: number) => { setWindow((current) => { const center = (current.fromMs + current.toMs) / 2; @@ -756,11 +864,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, conflicts={conflicts[selectedWork.id] ?? []} readOnly={selectedWork.origin === "plan"} onUpdate={onChangeWork} - onDelete={(workId) => { - changeDraft((current) => deleteWork(current, workId)); - setSelectedWorkId(undefined); - setRightMode("none"); - }} + onDelete={(workId) => { void handleDeleteWork(workId); }} onDuplicate={(workId) => { const id = nextWorkId(idCounterRef); changeDraft((current) => duplicateWork(current, workId, id)); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts index bf184f5..6a1cb94 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts @@ -1,23 +1,3 @@ -import type { Draft } from "./model/editorTypes"; - -export type SaveEditedPlanRequest = { - planId: string; - draft: Draft; -}; - -export type SaveEditedPlanResult = { - saved: false; - message: string; -}; - -export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise { - // TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service. - return { - saved: false, - message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend." - }; -} - export type PlanSurveyMode = { id: number; planId: number; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 54179fe..525b65f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -22,6 +22,8 @@ export type AddShootInput = { label: string; modeId?: string; target: EditorTarget; + /** id сохранённого режима съёмки в БД черновика (если съёмка персистнута). */ + planModeId?: number; /** Параметры построенного маршрута (если съёмка строилась с превью). */ revolution?: number; rollDeg?: number; @@ -36,6 +38,8 @@ export type AddDownlinkInput = { endMs: number; label: string; station: EditorGroundStation; + /** id сохранённого режима сброса в БД черновика (если сброс персистнут). */ + planModeId?: number; }; export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { @@ -151,6 +155,7 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft { targetName: input.target.name, targetLat: input.target.lat, targetLon: input.target.lon, + planModeId: input.planModeId, revolution: input.revolution, rollDeg: input.rollDeg, captureAngleDeg: input.captureAngleDeg, @@ -171,6 +176,7 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft { startMs: input.startMs, endMs: input.endMs, stationId: input.station.id, + planModeId: input.planModeId, origin: "added", isNew: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts new file mode 100644 index 0000000..be34e76 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { MissionValidation } from "../../api/missionPlanningApi"; +import { validationToConflicts } from "./editorValidation"; +import type { EditorWork } from "./model/editorTypes"; + +function shoot(id: string, planModeId?: number): EditorWork { + return { + id, + kind: "shoot", + label: "Съёмка", + startMs: 0, + endMs: 1000, + origin: "plan", + planModeId + }; +} + +describe("validationToConflicts", () => { + it("maps backend conflicts onto editor work ids by planModeId", () => { + const works = [shoot("survey-7", 7), shoot("survey-9", 9)]; + const validation: MissionValidation = { + missionId: "m1", + conflicts: [ + { modeId: 7, rule: "TIME_OVERLAP", severity: "error", message: "Пересечение по времени", relatedModeId: 9 }, + { modeId: 7, rule: "TARGET_VISIBILITY", severity: "warning", message: "Вне зоны видимости" } + ] + }; + + const map = validationToConflicts(validation, works); + + expect(map["survey-7"]).toEqual(["Пересечение по времени", "Вне зоны видимости"]); + expect(map["survey-9"]).toBeUndefined(); + }); + + it("drops conflicts whose modeId has no matching work", () => { + const works = [shoot("survey-7", 7)]; + const validation: MissionValidation = { + missionId: "m1", + conflicts: [{ modeId: 42, rule: "TIME_OVERLAP", severity: "error", message: "x" }] + }; + + expect(validationToConflicts(validation, works)).toEqual({}); + }); + + it("returns an empty map for the stub (no conflicts)", () => { + const works = [shoot("survey-7", 7)]; + expect(validationToConflicts({ missionId: "m1", conflicts: [] }, works)).toEqual({}); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts new file mode 100644 index 0000000..a5e7e5e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts @@ -0,0 +1,29 @@ +import type { MissionValidation } from "../../api/missionPlanningApi"; +import type { EditorConflictMap } from "./editorConflicts"; +import type { EditorWork } from "./model/editorTypes"; + +/** + * Проецирует авторитетные конфликты бэкенда (валидация миссии-черновика, keyed по numeric modeId) + * на карту конфликтов редактора (keyed по workId). Включения редактора несут `planModeId` — + * идентификатор сохранённого режима в БД; по нему и сопоставляем. Конфликты на режимах, которых + * нет среди работ редактора (например, ещё не подгружены), молча отбрасываются. + * + * MVP: бэк-валидатор — заглушка и возвращает пустой список, поэтому сейчас результат пуст. Маппинг + * написан вперёд: когда правила появятся, UI начнёт подсвечивать конкретные включения без доработок. + */ +export function validationToConflicts(validation: MissionValidation, works: EditorWork[]): EditorConflictMap { + const workIdByModeId = new Map(); + for (const work of works) { + if (work.planModeId != null) { + workIdByModeId.set(work.planModeId, work.id); + } + } + + const result: EditorConflictMap = {}; + for (const conflict of validation.conflicts) { + const workId = workIdByModeId.get(conflict.modeId); + if (!workId) continue; + result[workId] = [...(result[workId] ?? []), conflict.message]; + } + return result; +} diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts index 7d9d868..c70f01b 100644 --- a/services/pcp-tgu-ops-ui/vite.config.ts +++ b/services/pcp-tgu-ops-ui/vite.config.ts @@ -22,6 +22,11 @@ export default defineConfig({ changeOrigin: true, rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "") }, + "/api/pcp-tgu": { + target: "http://localhost:7011", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, "") + }, "/api/stations": { target: "http://localhost:7009", changeOrigin: true