pcp-tgu-ops-ui: fork-forward в редакторе — выбор будущей ЗРВ для перевыдачи невозвратного плана

Селектор «Целевая ЗРВ» в EditorToolbar: текущий слот (replace, по умолчанию)
либо будущая ЗРВ из проекции pcp-tgu-service (fetchSpacecraftPlans →
GET /api/plans/{spacecraftId}, фильтр futureZrvWindows: PLANNED строго в
будущем, кроме базы — та же проекция, что валидирует бэк). Выбор окна
переводит обзор редактора и заставляет ensureDraft слать mode=fork-forward
(naive-UTC окно + kpp); селектор блокируется после создания DRAFT.
This commit is contained in:
Дмитрий Соловьев
2026-06-03 14:12:21 +03:00
parent 2a841fc7b3
commit 057452f19d
7 changed files with 277 additions and 6 deletions
@@ -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<TguPlanResponse[]> {
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). Триггерит клон миссии; возвращает
@@ -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({
</button>
</div>
{(futureWindows.length > 0 || targetWindowId) && (
<label className="tgu-editor-target-zrv" title="Целевая ЗРВ для перевыдачи невозвратного плана">
<span className="tgu-editor-target-zrv__label">Целевая ЗРВ</span>
<select
className="tgu-select"
value={targetWindowId ?? ""}
disabled={targetLocked}
onChange={(event) => onSelectTargetWindow(event.target.value || undefined)}
>
<option value="">Текущий слот (правка)</option>
{futureWindows.map((window) => (
<option key={window.planId} value={window.planId}>
{formatUtcDateTime(window.startMs)} · {window.kppId}
</option>
))}
</select>
</label>
)}
<div className="tgu-editor-toolbar__spacer" />
<div className="tgu-editor-window-control" aria-label="Окно редактора">
@@ -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<string>();
// Будущие ЗРВ КА (проекция PLANNED) и выбранная цель fork-forward. Пусто/undefined — правка
// текущего слота (replace); выбор окна заставляет ensureDraft создать черновик на следующей ЗРВ.
const [futureWindows, setFutureWindows] = useState<ZrvWindow[]>([]);
const [targetWindowId, setTargetWindowId] = useState<string>();
const [passFrom, setPassFrom] = useState(1);
const [passTo, setPassTo] = useState(999);
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
@@ -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<string> => {
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) => {
@@ -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>): 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"
});
});
});
@@ -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);
}
@@ -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;