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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user