pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ

Бэкенд (pcp-tgu-service):
- REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ
  взводится, цепочка встаёт на паузу (новый флаг
  SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические
  негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу.
- Пауза централизована в PlanProjectionService.project(): для отклонённой
  цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг
  снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался).
- Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT.
- Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT,
  повторное создание окна -> 409.
- ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с),
  устойчиво к усечению ISO на фронте до целых секунд.

Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана,
панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков.

Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 00:04:47 +03:00
parent 3638afdb85
commit 5049395911
34 changed files with 1062 additions and 458 deletions
@@ -20,7 +20,7 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
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 type { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
import { validationToConflicts } from "./editorValidation";
import {
@@ -124,6 +124,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [passFrom, setPassFrom] = useState(1);
const [passTo, setPassTo] = useState(999);
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
// Команда карте центрироваться/подсветить объект выбранного включения (клик по включению).
const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 });
const draft = history.draft;
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
const mapRange = useMemo<PassRange>(() => {
@@ -192,6 +194,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
);
// Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем.
const focusWorkOnMap = useCallback(
(workId: string) => {
const work = displayWorks.find((w) => w.id === workId);
const focus = workMapFocus(work, mapScene);
setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 }));
},
[displayWorks, mapScene]
);
const contextLanes = useMemo(
() =>
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
@@ -264,8 +275,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
useEffect(() => {
const controller = new AbortController();
void fetchRequestsForMap({
endFrom: new Date(window.fromMs).toISOString(),
beginTo: new Date(window.toMs).toISOString()
endFrom: new Date(window.fromMs).toISOString().slice(0, 19),
beginTo: new Date(window.toMs).toISOString().slice(0, 19)
}).then((result) => {
if (!controller.signal.aborted) {
setRequestItems(result.items);
@@ -411,9 +422,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
};
}, [rightMode, noradId, window.fromMs, window.toMs]);
// Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану.
// Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки),
// редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace).
// createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный.
const createdImplicitlyRef = useRef(false);
useEffect(() => {
setDraftPlanId(undefined);
setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined);
createdImplicitlyRef.current = false;
setServiceConflicts({});
}, [selectedPlan]);
@@ -423,7 +438,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
useEffect(() => () => {
const stale = draftPlanIdRef.current;
if (stale) {
// Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем.
if (stale && createdImplicitlyRef.current) {
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
void abandonDraft(stale).catch(() => {});
}
@@ -459,7 +475,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
// Выбран уже существующий черновик — правим его напрямую, новый клон не создаём.
if (selectedPlan.status === "DRAFT") {
setDraftPlanId(selectedPlan.planId);
return selectedPlan.planId;
}
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
createdImplicitlyRef.current = true;
setDraftPlanId(created.draftPlanId);
return created.draftPlanId;
}, [draftPlanId, selectedPlan]);
@@ -863,6 +885,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setNotice(undefined);
}}
onObjectSelect={handleMapObjectSelect}
focusPoint={mapFocus.point}
focusToken={mapFocus.token}
selectedObjectId={mapFocus.objectId}
/>
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
</div>
@@ -880,11 +905,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setSelectedWorkId(workId);
setRightMode("inspect");
setNotice(undefined);
focusWorkOnMap(workId);
}}
onClearSelection={() => {
if (rightMode === "inspect") {
setSelectedWorkId(undefined);
setRightMode("none");
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
}
}}
/>
@@ -1168,11 +1195,34 @@ function buildEditorMapScene(
layer: "planWorks",
points: ring,
status: "PLANNED",
modeId: mode.id,
zIndex: zIndex++
});
}
}
// Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса
// [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс.
for (const mode of planModes) {
if (mode.type !== "DROP" || mode.timeStart == null) continue;
const startMs = localDateTimeMs(mode.timeStart);
const endMs = startMs + mode.duration * 1000;
const pts = flightLineData
.map((p) => ({ t: parseUtc(p.time), lat: p.lat, lon: p.long }))
.filter((p) => p.t >= startMs && p.t <= endMs)
.sort((a, b) => a.t - b.t)
.map((p) => ({ lat: p.lat, lon: p.lon }));
if (pts.length < 2) continue;
lines.push({
id: `drop:${mode.id}`,
layer: "tracks",
kind: "drop",
modeId: mode.id,
points: pts,
zIndex: zIndex++
});
}
for (const item of requestItems) {
const rings = parseWktPolygon(item.geometry);
for (const ring of rings) {
@@ -1226,3 +1276,43 @@ function nextWorkId(ref: MutableRefObject<number>): string {
ref.current += 1;
return `NW-${String(ref.current).padStart(4, "0")}`;
}
/**
* Объект карты, соответствующий включению, и точка для центрирования:
* - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура;
* - сброс → яркая линия сброса (drop) по modeId; точка — середина линии.
*/
function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } {
if (!work) return {};
if (work.kind === "shoot") {
const polygon =
work.planModeId != null
? scene.polygons.find((p) => p.modeId === work.planModeId)
: scene.polygons.find((p) => p.id.startsWith("surveyRoute:"));
const point: GeoPoint | undefined =
work.centerLat != null && work.centerLon != null
? { lat: work.centerLat, lon: work.centerLon }
: polygon
? polygonCentroid(polygon.points)
: undefined;
return { point, objectId: polygon?.id };
}
const line =
work.planModeId != null
? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId)
: undefined;
const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined;
return { point, objectId: line?.id };
}
function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined {
if (points.length === 0) return undefined;
let lat = 0;
let lon = 0;
for (const p of points) {
lat += p.lat;
lon += p.lon;
}
return { lat: lat / points.length, lon: lon / points.length };
}