From 057452f19d587f5d78ef08aa148397b76a64b16e 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 14:12:21 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-ops-ui:=20fork-forward=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B1=D1=83=D0=B4?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=B9=20=D0=97=D0=A0=D0=92=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B5=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Селектор «Целевая ЗРВ» в EditorToolbar: текущий слот (replace, по умолчанию) либо будущая ЗРВ из проекции pcp-tgu-service (fetchSpacecraftPlans → GET /api/plans/{spacecraftId}, фильтр futureZrvWindows: PLANNED строго в будущем, кроме базы — та же проекция, что валидирует бэк). Выбор окна переводит обзор редактора и заставляет ensureDraft слать mode=fork-forward (naive-UTC окно + kpp); селектор блокируется после создания DRAFT. --- ...TASK_tgu-plan-versioning-manual-reissue.md | 6 +- services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 30 ++++++++ .../src/features/tgu-editor/EditorToolbar.tsx | 29 ++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 61 +++++++++++++++-- .../tgu-editor/editorForkForward.test.ts | 68 +++++++++++++++++++ .../features/tgu-editor/editorForkForward.ts | 59 ++++++++++++++++ .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 30 ++++++++ 7 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 19c822f..600365d 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -297,8 +297,12 @@ P2 = DRAFT (base_plan_id=P0, окно=W) > - [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` — зелёные. +> > **Вынесено в отдельные подзадачи (не входит в ядро):** -> - **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`. diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index 7c512b2..9d6a0fe 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -33,6 +33,36 @@ export type PromoteDraftResponse = { attemptId: string; }; +/** План КА из проекции pcp-tgu-service (persisted ∪ будущая проекция). Времена — ISO с `Z`. */ +export type TguPlanResponse = { + planId: string; + spacecraftId: string; + startTime: string; + endTime: string; + kppId: string; + status: string; +}; + +/** + * Цепочка планов одного КА из pcp-tgu-service (GET /api/plans/{spacecraftId}): persisted планы плюс + * будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет + * выбранное окно ровно с этой же проекцией (assertWindowMatchesProjection). + */ +export async function fetchSpacecraftPlans(spacecraftId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}`; + 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[]) : []; +} + /** * Создаёт черновик плана (DRAFT) на основе базового плана * (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 3debbf1..cc67e62 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -1,5 +1,7 @@ import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes"; import { formatDuration } from "./editorPresentation"; +import type { ZrvWindow } from "./editorForkForward"; +import { formatUtcDateTime } from "../../utils/utcTime"; type EditorToolbarProps = { selectedSpacecraftId?: string; @@ -13,6 +15,10 @@ type EditorToolbarProps = { dirty: boolean; applying: boolean; canceling: boolean; + futureWindows: ZrvWindow[]; + targetWindowId?: string; + targetLocked: boolean; + onSelectTargetWindow: (planId: string | undefined) => void; windowLabel: string; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; @@ -37,6 +43,10 @@ export function EditorToolbar({ dirty, applying, canceling, + futureWindows, + targetWindowId, + targetLocked, + onSelectTargetWindow, windowLabel, rightMode, onSetRightMode, @@ -83,6 +93,25 @@ export function EditorToolbar({ + {(futureWindows.length > 0 || targetWindowId) && ( + + )} +
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 a6bfe9d..72898ed 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 @@ -13,7 +13,8 @@ import { validateMission, type SurveyRoute } from "../../api/missionPlanningApi"; -import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi"; +import { abandonDraft, createDraft, fetchSpacecraftPlans, promoteDraft } from "../../api/tguPlanApi"; +import { futureZrvWindows, toCreateDraftRequest, type ZrvWindow } from "./editorForkForward"; import { parseUtc } from "../../utils/utcTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -121,6 +122,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // Пока null — редактор показывает живой план read-only; после создания все правки и валидация // адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку. const [draftPlanId, setDraftPlanId] = useState(); + // Будущие ЗРВ КА (проекция PLANNED) и выбранная цель fork-forward. Пусто/undefined — правка + // текущего слота (replace); выбор окна заставляет ensureDraft создать черновик на следующей ЗРВ. + const [futureWindows, setFutureWindows] = useState([]); + const [targetWindowId, setTargetWindowId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); @@ -223,6 +228,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку); // локальные несохранённые твики без DRAFT применять нечего. const dirty = Boolean(draftPlanId); + const targetWindow = useMemo( + () => futureWindows.find((window) => window.planId === targetWindowId), + [futureWindows, targetWindowId] + ); const addedCount = draft.works.filter((work) => work.isNew).length; // Реальный состав плана берём из включений (plan modes), а не из черновика: // черновик сидируется заглушкой, когда у плана нет встроенных works. @@ -247,11 +256,33 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setNotice(undefined); setApplyError(undefined); setPlanModes([]); + setTargetWindowId(undefined); setPassFrom(1); setPassTo(999); setFlightLineData([]); }, [appliedRange, selectedPlan, selectedSpacecraftId]); + // Будущие ЗРВ-кандидаты для fork-forward: проекция КА из pcp-tgu-service (та же, с которой бэк + // сверяет выбранное окно). Грузим при выборе плана; невозвратный план оператор перевыдаёт на одну + // из этих ЗРВ. Сбой непрозрачен — список просто остаётся пустым (доступен только replace). + useEffect(() => { + if (!selectedPlan || !selectedSpacecraftId) { + setFutureWindows([]); + return; + } + let cancelled = false; + void fetchSpacecraftPlans(selectedSpacecraftId) + .then((plans) => { + if (!cancelled) setFutureWindows(futureZrvWindows(plans, selectedPlan.planId, Date.now())); + }) + .catch(() => { + if (!cancelled) setFutureWindows([]); + }); + return () => { + cancelled = true; + }; + }, [selectedPlan, selectedSpacecraftId]); + useEffect(() => { if (!noradId) return; let cancelled = false; @@ -453,15 +484,31 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, changeDraft((current) => updateWork(current, workId, patch)); }; - // Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT (replace) - // и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. + // Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT и возвращает его id. + // Без выбранной целевой ЗРВ — replace (тот же слот); с выбранной — fork-forward на следующую ЗРВ + // (для невозвратного плана). Дальше все правки/валидация/promote адресуются именно сюда. const ensureDraft = useCallback(async (): Promise => { if (draftPlanId) return draftPlanId; if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); - const created = await createDraft(selectedPlan.planId, { mode: "replace" }); + const created = await createDraft(selectedPlan.planId, toCreateDraftRequest(targetWindow)); setDraftPlanId(created.draftPlanId); return created.draftPlanId; - }, [draftPlanId, selectedPlan]); + }, [draftPlanId, selectedPlan, targetWindow]); + + // Выбор целевой ЗРВ для fork-forward (до создания черновика): фиксирует окно и переводит + // обзор редактора на него, чтобы оператор строил включения там, где будет жить новый план. + const selectTargetWindow = useCallback( + (planId: string | undefined) => { + setTargetWindowId(planId); + const window = planId ? futureWindows.find((item) => item.planId === planId) : undefined; + if (window) { + setWindow({ fromMs: window.startMs - 3 * HOUR_MS, toMs: window.endMs + 3 * HOUR_MS }); + } else if (selectedPlan) { + setWindow(defaultEditorWindow(selectedPlan, appliedRange)); + } + }, + [futureWindows, selectedPlan, appliedRange] + ); // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId. // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. @@ -781,6 +828,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, dirty={dirty} applying={applying} canceling={canceling} + futureWindows={futureWindows} + targetWindowId={targetWindowId} + targetLocked={dirty} + onSelectTargetWindow={selectTargetWindow} windowLabel={formatDuration(window.toMs - window.fromMs)} rightMode={rightMode} onSetRightMode={(mode) => { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts new file mode 100644 index 0000000..8525623 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { futureZrvWindows, toCreateDraftRequest } from "./editorForkForward"; +import { parseUtc } from "../../utils/utcTime"; +import type { TguPlanResponse } from "../../api/tguPlanApi"; + +const NOW = parseUtc("2026-06-03T00:00:00Z"); + +function plan(over: Partial): TguPlanResponse { + return { + planId: "p", + spacecraftId: "sc-1", + startTime: "2026-06-03T05:00:00Z", + endTime: "2026-06-03T05:10:00Z", + kppId: "KPP-1", + status: "PLANNED", + ...over + }; +} + +describe("futureZrvWindows", () => { + it("оставляет только будущие PLANNED-окна, кроме базового, по возрастанию начала", () => { + const plans = [ + plan({ planId: "base", startTime: "2026-06-03T05:00:00Z" }), + plan({ planId: "future-late", startTime: "2026-06-03T09:00:00Z", endTime: "2026-06-03T09:10:00Z" }), + plan({ planId: "future-early", startTime: "2026-06-03T07:00:00Z", endTime: "2026-06-03T07:10:00Z" }), + plan({ planId: "past", startTime: "2026-06-02T23:00:00Z", endTime: "2026-06-02T23:10:00Z" }), + plan({ planId: "issued", status: "ISSUING", startTime: "2026-06-03T08:00:00Z" }) + ]; + + const windows = futureZrvWindows(plans, "base", NOW); + + expect(windows.map((w) => w.planId)).toEqual(["future-early", "future-late"]); + }); + + it("приводит окно к naive-UTC без зоны для CreateDraftRequest", () => { + const windows = futureZrvWindows( + [plan({ planId: "future", startTime: "2026-06-03T07:00:00Z", endTime: "2026-06-03T07:10:00Z" })], + "base", + NOW + ); + + expect(windows).toHaveLength(1); + expect(windows[0].windowStart).toBe("2026-06-03T07:00:00"); + expect(windows[0].windowStop).toBe("2026-06-03T07:10:00"); + expect(windows[0].kppId).toBe("KPP-1"); + }); +}); + +describe("toCreateDraftRequest", () => { + it("без окна — replace в том же слоте", () => { + expect(toCreateDraftRequest(undefined)).toEqual({ mode: "replace" }); + }); + + it("с окном — fork-forward с naive-границами и КПП", () => { + const [window] = futureZrvWindows( + [plan({ planId: "future", startTime: "2026-06-03T07:00:00Z", endTime: "2026-06-03T07:10:00Z" })], + "base", + NOW + ); + + expect(toCreateDraftRequest(window)).toEqual({ + mode: "fork-forward", + windowStart: "2026-06-03T07:00:00", + windowStop: "2026-06-03T07:10:00", + windowKppId: "KPP-1" + }); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts new file mode 100644 index 0000000..d3fae74 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts @@ -0,0 +1,59 @@ +import { parseUtc } from "../../utils/utcTime"; +import type { CreateDraftRequest, TguPlanResponse } from "../../api/tguPlanApi"; + +/** + * Окно-кандидат будущей ЗРВ для fork-forward: план из проекции (статус PLANNED), лежащий строго + * в будущем и не являющийся базовым. Из такого окна оператор перевыдаёт невозвратный план на + * следующую ЗРВ; бэк сверяет выбор с той же проекцией (assertWindowMatchesProjection). + */ +export type ZrvWindow = { + /** Детерминированный planId проекции (для value в селекте). */ + planId: string; + startMs: number; + endMs: number; + /** Naive UTC (без зоны) для CreateDraftRequest — бэк парсит LocalDateTime. */ + windowStart: string; + windowStop: string; + kppId: string; +}; + +/** + * Будущие ЗРВ-кандидаты из проекции КА: только PLANNED, строго позже `nowMs`, кроме базового плана, + * по возрастанию начала. Naive-окна (windowStart/Stop без зоны) совпадают со значениями проекции, + * с которыми бэк сверяет выбор. + */ +export function futureZrvWindows(plans: TguPlanResponse[], basePlanId: string, nowMs: number): ZrvWindow[] { + return plans + .filter((plan) => plan.status === "PLANNED" && plan.planId !== basePlanId) + .map((plan) => { + const startMs = parseUtc(plan.startTime); + const endMs = parseUtc(plan.endTime); + return { + planId: plan.planId, + startMs, + endMs, + windowStart: toNaiveUtc(startMs), + windowStop: toNaiveUtc(endMs), + kppId: plan.kppId + }; + }) + .filter((window) => window.startMs > nowMs) + .sort((a, b) => a.startMs - b.startMs); +} + +/** Тело запроса createDraft из выбора целевой ЗРВ: окно → fork-forward, иначе тот же слот (replace). */ +export function toCreateDraftRequest(window: ZrvWindow | undefined): CreateDraftRequest { + if (!window) { + return { mode: "replace" }; + } + return { + mode: "fork-forward", + windowStart: window.windowStart, + windowStop: window.windowStop, + windowKppId: window.kppId + }; +} + +function toNaiveUtc(ms: number): string { + return new Date(ms).toISOString().slice(0, 19); +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 0b8a24a..680ec9a 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -66,6 +66,36 @@ min-width: 1rem; } +.tgu-editor-target-zrv { + display: inline-flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} + +.tgu-editor-target-zrv__label { + color: var(--text-dim); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tgu-select { + height: 1.95rem; + max-width: 14rem; + border: 1px solid var(--line); + border-radius: 7px; + padding: 0 0.5rem; + background: #101214; + color: var(--text); + font-size: 0.78rem; +} + +.tgu-select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .tgu-editor-type-dot { width: 0.62rem; height: 0.62rem;