pcp: создание плана новой цепочки из вкладки «Планы» (база/без истории, окно из двух ЗРВ)

Оператор берёт за основу прошлый план (предыстория) или начинает цепочку с нуля,
набирает окно из двух реальных ЗРВ и создаёт план-кандидат; сразу показывается прогноз
будущей цепочки от него. Рабочим (якорем) план становится после ручной отправки (promote)
и приёмки — отдельного механизма якорения не вводим.

pcp-tgu-service:
- сырые ЗРВ: PlanCalculationService.rawWindows + PlanProjectionService.rawZrvWindows,
  GET /api/plans/{spacecraftId}/zrv (исходные окна снапшота, не cooked-проекция);
- CreateChainPlanUseCase + POST /api/plans/new-chain: опциональная база (клон включений
  в окно) либо пустая миссия без истории; прогноз цепочки через projectAfterWindow;
- ZrvWindowMatcher (старт+КПП одной зоны, конец — другой); assertDraftSlotStillValid
  переведён на него — единая проверка для replace/fork-forward и окна из двух ЗРВ;
- тесты: CreateChainPlanUseCaseTest (8), ZrvWindowMatcherTest (7), правки promote-тестов.

pcp-mission-planing-service:
- POST /api/missions/blank — пустая миссия для сценария «без истории» (+ 2 теста).

pcp-tgu-ops-ui:
- API fetchZrvWindows/createChainPlan; диалог «Создать план» во вкладке «Планы»
  (выбор базы, стартовой и конечной ЗРВ) с показом спрогнозированной цепочки и ручной
  отправкой; подсветка ЗРВ выбранного КА на таймлайне;
- «Целевая ЗРВ» убрана из редактора (он остаётся под replace-правку); удалён ставший
  мёртвым editorForkForward; createChainWindows + тесты.
This commit is contained in:
Дмитрий Соловьев
2026-06-03 16:25:46 +03:00
parent 057452f19d
commit 9c2d8516bc
26 changed files with 1316 additions and 240 deletions
@@ -13,8 +13,7 @@ import {
validateMission,
type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, fetchSpacecraftPlans, promoteDraft } from "../../api/tguPlanApi";
import { futureZrvWindows, toCreateDraftRequest, type ZrvWindow } from "./editorForkForward";
import { abandonDraft, 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";
@@ -122,10 +121,6 @@ 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[]>([]);
@@ -228,10 +223,6 @@ 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.
@@ -256,33 +247,11 @@ 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;
@@ -484,31 +453,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
changeDraft((current) => updateWork(current, workId, patch));
};
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT и возвращает его id.
// Без выбранной целевой ЗРВ — replace (тот же слот); с выбранной — fork-forward на следующую ЗРВ
// (для невозвратного плана). Дальше все правки/валидация/promote адресуются именно сюда.
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
const created = await createDraft(selectedPlan.planId, toCreateDraftRequest(targetWindow));
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
setDraftPlanId(created.draftPlanId);
return created.draftPlanId;
}, [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]
);
}, [draftPlanId, selectedPlan]);
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
@@ -828,10 +782,6 @@ 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) => {