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