From 2bdc9ad64f5d7d1de99a4e059c185ba35721a278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 1 Jun 2026 13:57:13 +0300 Subject: [PATCH 01/79] =?UTF-8?q?=D0=9A=D0=BE=D1=80=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B8=D1=82=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-repo/application-local.yaml | 6 +-- config-repo/pcp-mission-planing-service.yaml | 2 +- config-repo/pcp-tgu-service.yaml | 2 +- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 43 ++++++++++++++----- .../tgu-map-2d/geometry/spatialIndex.ts | 31 +++++++++++-- services/pcp-tgu-ops-ui/vite.config.ts | 1 + 6 files changed, 66 insertions(+), 19 deletions(-) diff --git a/config-repo/application-local.yaml b/config-repo/application-local.yaml index 5fc6b17..ccd2cc3 100644 --- a/config-repo/application-local.yaml +++ b/config-repo/application-local.yaml @@ -3,10 +3,8 @@ pcp: infra: postgres: -# host: ${PCP_POSTGRES_HOST:localhost} -# port: ${PCP_POSTGRES_PORT:5432} - host: ${PCP_POSTGRES_HOST:192.168.100.160} - port: ${PCP_POSTGRES_PORT:35400} + host: ${PCP_POSTGRES_HOST:localhost} + port: ${PCP_POSTGRES_PORT:5432} kafka: host: ${PCP_KAFKA_HOST:localhost} port: ${PCP_KAFKA_PORT:29092} diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index b4e3866..935d86b 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -75,7 +75,7 @@ spring: camunda: client: mode: self-managed - grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500} + grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500} auth: method: none prefer-rest-over-grpc: false diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index c462796..6c3cc58 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -94,7 +94,7 @@ server: camunda: client: - grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500} + grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500} --- spring: diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 37096a0..b9908e8 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -296,16 +296,25 @@ export function Tgu2DMapView({ >
2 · окно видимости
- {passes.length === 0 ? ( + {loading ? ( +
Запрос параметров видимости…
+ ) : error ? ( +
{error}
+ ) : passes.length === 0 ? (
В окне планирования проходов над целью нет.
) : (
@@ -182,8 +234,11 @@ export function ShootPanel({ type="button" onClick={() => onSelectPass(pass)} > - {formatDate(pass.startMs)} {formatTime(pass.startMs)} - {formatDuration(pass.endMs - pass.startMs)} + + {formatDate(pass.startMs)} {formatTime(pass.startMs)} + {pass.revolution != null && ` · виток ${pass.revolution}`} + + {formatVisibilityParams(pass)} ); })} @@ -205,6 +260,39 @@ export function ShootPanel({ )} + {target && selectedPass && ( +
+
4 · маршрут съёмки
+
+ + +
+ {routeLoading ? ( +
Построение маршрута…
+ ) : routeError ? ( +
{routeError}
+ ) : route ? ( +
+ Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° +
+ центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте +
+ ) : null} +
+ )} + @@ -254,6 +342,223 @@ export function DownlinkPanel({ stationOptions, selected, onSelect, onAdd, onClo ); } +export function DropPanel({ + zones, + loading, + error, + selectedZone, + onSelectZone, + stationName, + zoneStartMs, + zoneStopMs, + candidates, + selectedSurveyIds, + onToggleSurvey, + saving, + saveError, + onAdd, + onClose +}: DropPanelProps) { + const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving; + + return ( +
+ +
+ Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны. +
+ + {loading &&
Загрузка зон радиовидимости…
} + {error &&
{error}
} + {!loading && !error && zones.length === 0 && ( +
Нет зон радиовидимости в текущем окне.
+ )} + + {zones.length > 0 && ( +
+ {zones.map((zone) => { + const startMs = zoneStartMs(zone); + const stopMs = zoneStopMs(zone); + const isSelected = + !!selectedZone && + selectedZone.stationId === zone.stationId && + selectedZone.revolution === zone.revolution && + selectedZone.onStart.time === zone.onStart.time; + return ( + + ); + })} +
+ )} + + {selectedZone && ( +
+
Съёмки для сброса
+ {candidates.length === 0 ? ( +
Нет съёмок, завершающихся до начала выбранной зоны.
+ ) : ( +
+ {candidates.map((survey) => { + const checked = selectedSurveyIds.has(survey.id); + return ( + + ); + })} +
+ )} +
+ )} + + {saveError &&
{saveError}
} + + +
+ ); +} + +type MapInfoPanelProps = { + selection: Tgu2DMapSelection; + requestItems: RequestMapItem[]; + onClose: () => void; +}; + +export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelProps) { + const title = mapInfoTitle(selection); + + const requestItem = + selection.type === "polygon" && selection.layer === "requests" && selection.requestId + ? requestItems.find((r) => r.id === selection.requestId) + : undefined; + + return ( +
+ + + {selection.type === "marker" && selection.layer === "stations" && ( +
+
{selection.name}
+
+
Номер
+
{selection.stationNumber ?? "—"}
+
Широта
+
{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}
+
Долгота
+
{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}
+
Высота, м
+
{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}
+
Угол места мин, °
+
{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}
+
Угол места макс, °
+
{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}
+
+
+ )} + + {selection.type === "polygon" && selection.layer === "requests" && ( + requestItem ? ( +
+
+
ID
+
{requestItem.id}
+
Название
+
{requestItem.name || "—"}
+
Статус
+
{requestItem.status}
+
Тип съёмки
+
{surveyTypeLabel(requestItem.surveyType)}
+
Начало
+
{formatIso(requestItem.beginDateTime)}
+
Конец
+
{formatIso(requestItem.endDateTime)}
+
Важность
+
{requestItem.importance}
+
Покрытие
+
{requestItem.coveragePercent != null ? `${requestItem.coveragePercent.toFixed(1)} %` : "—"}
+
КПП
+
{requestItem.kpp?.join(", ") || "—"}
+
Приоритет передачи
+
{requestItem.highPriorityTransmit ? "Да" : "Нет"}
+
+
+ ) : ( +
{selection.name || selection.id}
+ ) + )} + + {selection.type === "polygon" && selection.layer === "planWorks" && ( +
+
{selection.name}
+ {selection.status && ( + + + {statusStyle(selection.status).label} + + )} +
+ )} +
+ ); +} + +function mapInfoTitle(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") { + return selection.layer === "stations" ? "Станция" : "Маркер КА"; + } + switch (selection.layer) { + case "requests": return "Заявка на съёмку"; + case "planWorks": return "Работа плана"; + case "swath": return "Полоса обзора"; + case "tracks": return "Трасса КА"; + default: return "Объект карты"; + } +} + +function formatVisibilityParams(pass: EditorVisibilityWindow): string { + const parts: string[] = [formatDuration(pass.endMs - pass.startMs)]; + if (pass.rollDeg != null) parts.push(`крен ${pass.rollDeg.toFixed(1)}°`); + if (pass.sunAngleDeg != null) parts.push(`☉ ${pass.sunAngleDeg.toFixed(1)}°`); + if (pass.range != null) { + const km = pass.range > 100000 ? pass.range / 1000 : pass.range; + parts.push(`${km.toFixed(0)} км`); + } + return parts.join(" · "); +} + +function surveyTypeLabel(surveyType: string): string { + switch (surveyType) { + case "OPTICS": return "Оптическая"; + case "RSA": return "Радиолокационная (РСА)"; + case "COMBINED": return "Комбинированная"; + default: return surveyType; + } +} + +function formatIso(iso: string): string { + return new Date(iso).toLocaleString("ru-RU"); +} + function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { return (
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index e776a79..9feb5b1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -13,7 +13,7 @@ type EditorToolbarProps = { dirty: boolean; applying: boolean; windowLabel: string; - rightMode: "none" | "inspect" | "shoot" | "downlink"; + rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; onUndo: () => void; onRedo: () => void; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx index eeb061f..bdc29ce 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/MapPassSelector.tsx @@ -122,19 +122,6 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo />
- {/* Labels below handles */} -
-
- {fromRev} - {multiDay && `${utcDM(fromPass.startMs)} `}{utcHM(fromPass.startMs)} -
- {toIndex !== fromIndex && ( -
- {toRev} - {multiDay && `${utcDM(toPass.startMs)} `}{utcHM(toPass.startMs)} -
- )} -
); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index c518e0b..4be10f9 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -1,13 +1,18 @@ import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react"; import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; import type { RequestMapItem } from "../../model/requestTypes"; -import { stationPasses, targetPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; +import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; +import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; +import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; 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 { fetchPlanModes, saveEditedPlan, type PlanMode } from "./editorApi"; +import { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi"; import { addDownlink, addShoot, @@ -21,7 +26,7 @@ import { } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; -import { DownlinkPanel, type DownlinkSelection, type DownlinkStationOption, ShootPanel, WorkInspector } from "./EditorPanels"; +import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels"; import { MapPassSelector } from "./MapPassSelector"; import { EditorRail } from "./EditorRail"; import { EditorTimeline } from "./EditorTimeline"; @@ -39,9 +44,17 @@ import type { TguEditorTabProps } from "./model/editorTypes"; -type EditorRightMode = "none" | "inspect" | "shoot" | "downlink"; +type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; const HOUR_MS = 60 * 60 * 1000; +/** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ +const SHOOT_WINDOW_MS = 6 * 60 * 1000; +/** + * Угол захвата (полуширина полосы) по умолчанию, градусы. + * Пока задаётся на фронте так же, как в текущем построении маршрутов; в дальнейшем + * это значение будет определять бэкенд по профилю наблюдения КА. + */ +const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { tracks: true, swath: true, @@ -55,7 +68,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const idCounterRef = useRef(0); const targetCounterRef = useRef(0); const centerRef = useRef(null); - const [mapRatio, setMapRatio] = useState(0.5); + const [mapRatio, setMapRatio] = useState(0.72); const [history, setHistory] = useState(() => ({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], @@ -67,7 +80,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [target, setTarget] = useState(); const [selectedShootPass, setSelectedShootPass] = useState(); const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); - const [selectedDownlink, setSelectedDownlink] = useState(); + const [rvaZones, setRvaZones] = useState([]); + const [rvaLoading, setRvaLoading] = useState(false); + const [rvaError, setRvaError] = useState(); + const [selectedZone, setSelectedZone] = useState(); + const [selectedDropSurveyIds, setSelectedDropSurveyIds] = useState>(new Set()); + const [dropSaving, setDropSaving] = useState(false); + const [dropError, setDropError] = useState(); + const [mapInfoSelection, setMapInfoSelection] = useState(); + const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); @@ -112,14 +133,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const selectedWork = draft.works.find((work) => work.id === selectedWorkId); const activeLabel = platformLabel(platforms, selectedSpacecraftId); - const shootPasses = useMemo( - () => target ? targetPasses({ spacecraftId: selectedSpacecraftId }, target, { fromMs: window.fromMs, toMs: window.toMs }) : [], - [selectedSpacecraftId, target, window.fromMs, window.toMs] - ); - const stationOptions = useMemo( - () => buildStationOptions(stations, selectedSpacecraftId, window, selectedPlan), - [selectedPlan, selectedSpacecraftId, stations, window] - ); + const [shootPasses, setShootPasses] = useState([]); + const [shootPassesLoading, setShootPassesLoading] = useState(false); + const [shootPassesError, setShootPassesError] = useState(); + const [routeAnchor, setRouteAnchor] = useState("CENTERED"); + const [surveyRoute, setSurveyRoute] = useState(); + const [routeLoading, setRouteLoading] = useState(false); + const [routeError, setRouteError] = useState(); const visibilityTracks = useMemo( () => buildVisibilityTracks(target, shootPasses, selectedWork, stations, selectedSpacecraftId, window), [selectedSpacecraftId, selectedWork, shootPasses, stations, target, window] @@ -127,8 +147,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1)); const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1)); const mapScene = useMemo( - () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo), - [selectedSpacecraftId, flightLineData, orbitPasses, stations, requestItems, planModes, effectiveFrom, effectiveTo] + () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute), + [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute] ); const shootTargets = useMemo( () => @@ -146,6 +166,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, })), [contextSpacecraftIds, platforms] ); + // Кандидаты на сброс: маршруты съёмки плана, заканчивающиеся строго до начала выбранной зоны. + const dropCandidates = useMemo(() => { + if (!selectedZone) return []; + const zoneStart = localDateTimeMs(selectedZone.onStart.time); + return planModes + .filter((m): m is PlanSurveyMode => m.type === "SURVEY" && m.timeStart != null) + .map((m) => { + const startMs = localDateTimeMs(m.timeStart as string); + return { + id: m.id, + label: `Съёмка витка ${m.revolution}`, + startMs, + endMs: startMs + m.duration * 1000 + }; + }) + .filter((c) => c.endMs < zoneStart) + .sort((a, b) => a.startMs - b.startMs); + }, [planModes, selectedZone]); + const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); const addedCount = draft.works.filter((work) => work.isNew).length; @@ -156,7 +195,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRightMode("none"); setTarget(undefined); setSelectedShootPass(undefined); - setSelectedDownlink(undefined); + setSurveyRoute(undefined); + setRouteError(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + setRvaZones([]); + setMapInfoSelection(undefined); setServiceConflicts({}); setNotice(undefined); setApplyError(undefined); @@ -190,6 +235,125 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, return () => controller.abort(); }, [window.fromMs, window.toMs]); + useEffect(() => { + let cancelled = false; + void fetchStations() + .then((items) => { if (!cancelled) setStationDtos(items); }) + .catch(() => { if (!cancelled) setStationDtos([]); }); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "shoot" || !target || !Number.isFinite(numericNorad)) { + setShootPasses([]); + setShootPassesError(noradId ? undefined : target ? "Не задан NORAD-идентификатор КА." : undefined); + setShootPassesLoading(false); + return; + } + + let cancelled = false; + setShootPassesLoading(true); + setShootPassesError(undefined); + void fetchPointVisibility( + numericNorad, + { id: target.id, lat: target.lat, lon: target.lon }, + window.fromMs, + window.toMs + ) + .then((params) => { + if (cancelled) return; + setShootPasses( + params.map(visibilityWindowFromParam).sort((a, b) => a.startMs - b.startMs) + ); + }) + .catch((error) => { + if (cancelled) return; + setShootPasses([]); + setShootPassesError(error instanceof Error ? error.message : "Не удалось получить параметры видимости."); + }) + .finally(() => { + if (!cancelled) setShootPassesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [rightMode, target, noradId, window.fromMs, window.toMs]); + + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) { + setSurveyRoute(undefined); + setRouteError(undefined); + setRouteLoading(false); + return; + } + + const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; + const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + + let cancelled = false; + setRouteLoading(true); + setRouteError(undefined); + void buildSurveyRoute({ + satelliteId: numericNorad, + time: new Date(momentMs).toISOString().slice(0, 19), + durationSeconds, + roll: selectedShootPass.rollDeg ?? 0, + captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, + revolution: selectedShootPass.revolution ?? 0, + anchor: routeAnchor + }) + .then((route) => { + if (!cancelled) setSurveyRoute(route); + }) + .catch((error) => { + if (cancelled) return; + setSurveyRoute(undefined); + setRouteError(error instanceof Error ? error.message : "Не удалось построить маршрут съёмки."); + }) + .finally(() => { + if (!cancelled) setRouteLoading(false); + }); + return () => { + cancelled = true; + }; + }, [rightMode, target, selectedShootPass, noradId, routeAnchor]); + + // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс». + useEffect(() => { + const numericNorad = noradId ? Number(noradId) : NaN; + if (rightMode !== "downlink" || !Number.isFinite(numericNorad)) { + setRvaZones([]); + setRvaError(rightMode === "downlink" && !noradId ? "Не задан NORAD-идентификатор КА." : undefined); + setRvaLoading(false); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + return; + } + + let cancelled = false; + setRvaLoading(true); + setRvaError(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs) + .then((zones) => { + if (!cancelled) setRvaZones(zones); + }) + .catch((error) => { + if (cancelled) return; + setRvaZones([]); + setRvaError(error instanceof Error ? error.message : "Не удалось получить зоны радиовидимости."); + }) + .finally(() => { + if (!cancelled) setRvaLoading(false); + }); + return () => { + cancelled = true; + }; + }, [rightMode, noradId, window.fromMs, window.toMs]); + useEffect(() => { if (!selectedPlan) return; const planId = selectedPlan.planId; @@ -213,9 +377,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, changeDraft((current) => updateWork(current, workId, patch)); }; - const addShootWork = () => { + const addShootWork = async () => { if (!target || !selectedShootPass || !modeId) return; + // Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id + // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут + // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. + const numericNorad = noradId ? Number(noradId) : NaN; + if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) { + const planId = selectedPlan.planId; + const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; + const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + setRouteLoading(true); + setRouteError(undefined); + try { + const saved = await saveSurveyRoute(planId, { + satelliteId: numericNorad, + time: new Date(momentMs).toISOString().slice(0, 19), + durationSeconds: routeDurationSeconds, + roll: selectedShootPass.rollDeg ?? 0, + captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, + revolution: selectedShootPass.revolution ?? 0, + anchor: routeAnchor, + // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. + planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), + planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) + }); + setSurveyRoute(saved); + } catch (error) { + setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); + return; + } finally { + setRouteLoading(false); + } + } + const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000); const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2); const mode = modes.find((item) => item.id === modeId); @@ -237,25 +433,63 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedShootPass(undefined); }; - const addDownlinkWork = () => { - if (!selectedDownlink) return; + const stationNameById = useCallback( + (stationId: number) => + stationDtos.find((s) => s.number === stationId)?.name ?? `Станция ${stationId}`, + [stationDtos] + ); - const durationMs = Math.min(selectedDownlink.pass.endMs - selectedDownlink.pass.startMs, 12 * 60 * 1000); - const startMs = selectedDownlink.pass.startMs + Math.floor((selectedDownlink.pass.endMs - selectedDownlink.pass.startMs - durationMs) / 2); + const addDropWork = async () => { + if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return; + const numericNorad = noradId ? Number(noradId) : NaN; + if (!Number.isFinite(numericNorad)) { + setDropError("Не задан NORAD-идентификатор КА."); + return; + } + + const planId = selectedPlan.planId; + const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); + const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); + + setDropSaving(true); + setDropError(undefined); + try { + await saveDropRoute(planId, { + satelliteId: numericNorad, + station: String(selectedZone.stationId), + revolution: selectedZone.revolution, + // Зона — LocalDateTime без зоны (как вернула баллистика). + zoneStart: selectedZone.onStart.time.slice(0, 19), + zoneStop: selectedZone.onStop.time.slice(0, 19), + surveyModeIds: [...selectedDropSurveyIds], + // Окно выбранного плана — для автосоздания миссии/плана с корректным интервалом. + planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), + planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) + }); + } catch (error) { + setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); + return; + } finally { + setDropSaving(false); + } + + // Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне. + void fetchPlanModes(planId).then(setPlanModes).catch(() => {}); + const stationNm = stationNameById(selectedZone.stationId); const id = nextWorkId(idCounterRef); - changeDraft((current) => addDownlink(current, { id, - startMs, - endMs: startMs + durationMs, - label: "Сброс на НКПОР", - station: selectedDownlink.station + startMs: zoneStartMsVal, + endMs: zoneStopMsVal, + label: `Сброс · ${stationNm}`, + station: { id: String(selectedZone.stationId), name: stationNm } }) ); setSelectedWorkId(id); setRightMode("inspect"); - setSelectedDownlink(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); }; const runCheck = () => { @@ -302,6 +536,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); }; + const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + if (rightMode === "shoot") return; + if (!selection) { + if (rightMode === "mapInfo") { + setRightMode("none"); + setMapInfoSelection(undefined); + } + return; + } + setMapInfoSelection(selection); + setRightMode("mapInfo"); + setSelectedWorkId(undefined); + }, [rightMode]); + const handleResizerDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY; @@ -349,6 +597,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onSetRightMode={(mode) => { setRightMode(mode); setSelectedWorkId(undefined); + setMapInfoSelection(undefined); setNotice(undefined); }} onUndo={() => setHistory((current) => undo(current))} @@ -426,7 +675,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setModeId(DEFAULT_EDITOR_MODES[0].id); setNotice(undefined); }} - onObjectSelect={() => undefined} + onObjectSelect={handleMapObjectSelect} />
@@ -484,9 +733,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, )} {rightMode === "downlink" && ( - { + setSelectedZone(zone); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + }} + stationName={stationNameById} + zoneStartMs={(zone) => localDateTimeMs(zone.onStart.time)} + zoneStopMs={(zone) => localDateTimeMs(zone.onStop.time)} + candidates={dropCandidates} + selectedSurveyIds={selectedDropSurveyIds} + onToggleSurvey={(id) => + setSelectedDropSurveyIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }) + } + saving={dropSaving} + saveError={dropError} + onAdd={addDropWork} onClose={() => { setRightMode("none"); - setSelectedDownlink(undefined); + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + setDropError(undefined); + }} + /> + )} + {rightMode === "mapInfo" && mapInfoSelection && ( + { + setRightMode("none"); + setMapInfoSelection(undefined); }} /> )} @@ -520,6 +809,21 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, ); } +function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { + const timeMs = Date.parse(param.time); + const half = SHOOT_WINDOW_MS / 2; + return { + startMs: timeMs - half, + endMs: timeMs + half, + quality: 0, + revolution: param.revolution, + rollDeg: param.gamma, + sunAngleDeg: param.sunAngle, + range: param.range, + revSign: param.revSign + }; +} + function defaultEditorWindow( selectedPlan: TguEditorTabProps["selectedPlan"], appliedRange: TguEditorTabProps["appliedRange"] @@ -542,30 +846,13 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }]; } -function buildStationOptions( - stations: EditorGroundStation[], - spacecraftId: string | undefined, - window: EditorTimelineWindow, - selectedPlan: TguEditorTabProps["selectedPlan"] -): DownlinkStationOption[] { - return stations.map((station) => { - const computedPasses = - station.lat !== undefined && station.lon !== undefined - ? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs }) - : []; - const fallbackPass = selectedPlan && selectedPlan.kppId === station.id - ? [{ - startMs: Math.max(window.fromMs, selectedPlan.startMs), - endMs: Math.min(window.toMs, selectedPlan.endMs), - quality: 0 - }] - : []; - - return { - station, - passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs) - }; - }); +/** + * LocalDateTime без зоны от бэка трактуем как UTC (как и поле `time` в ballisticsApi): + * зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале. + */ +function localDateTimeMs(s: string): number { + const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s); + return Date.parse(hasZone ? s : `${s}Z`); } function buildVisibilityTracks( @@ -609,11 +896,12 @@ function buildEditorMapScene( spacecraftId: string | undefined, flightLineData: FlightLinePoint[], orbitPasses: OrbitPassInfo[], - stations: EditorGroundStation[], + stationDtos: StationDto[], requestItems: RequestMapItem[], planModes: PlanMode[], fromIndex: number, - toIndex: number + toIndex: number, + surveyRoute?: SurveyRoute ): Tgu2DMapScene { const visibleRevolutions = new Set( orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!) @@ -689,16 +977,26 @@ function buildEditorMapScene( } } - const markers = stations.flatMap((station) => { - if (station.lat === undefined || station.lon === undefined) return []; - return [{ - id: station.id, - layer: "stations" as const, - point: { lat: station.lat, lon: station.lon }, - label: station.name, - zIndex: 10 - }]; - }); + // Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана + // отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без + // дополнительной настройки слоёв. + if (surveyRoute?.contourWkt) { + const rings = parseWktPolygon(surveyRoute.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `surveyRoute:${zIndex}`, + name: `Маршрут съёмки (виток ${surveyRoute.revolution})`, + kind: "planWork", + layer: "planWorks", + points: ring, + status: "ISSUING", + zIndex: zIndex++ + }); + } + } + + const markers = buildStationMarkers(stationDtos); return { polygons, lines, markers }; } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts index af6c0a5..c1bfa97 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts @@ -64,6 +64,16 @@ export type EditorVisibilityWindow = { startMs: number; endMs: number; quality: number; + /** Номер витка (если окно получено из баллистики). */ + revolution?: number; + /** Крен, град. */ + rollDeg?: number; + /** Угол Солнца, град. */ + sunAngleDeg?: number; + /** Наклонная дальность. */ + range?: number; + /** Направление витка. */ + revSign?: "ASC" | "DESC"; }; export type EditorVisibilityTrack = { diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index ab4094a..0361f45 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -252,7 +252,7 @@ .map-pass-selector__header { display: flex; - align-items: center; + align-items: baseline; gap: 0.45rem; padding: 0.4rem 0.65rem; } diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts index 044cdfe..7d9d868 100644 --- a/services/pcp-tgu-ops-ui/vite.config.ts +++ b/services/pcp-tgu-ops-ui/vite.config.ts @@ -12,6 +12,20 @@ export default defineConfig({ changeOrigin: true, rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, "") }, + "/api/pcp-ballistics": { + target: "http://localhost:7003", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-ballistics/, "") + }, + "/api/pcp-mission": { + target: "http://localhost:7010", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "") + }, + "/api/stations": { + target: "http://localhost:7009", + changeOrigin: true + }, "/api": { target: "http://localhost:7008", changeOrigin: true From 0128c5569c312c4b134266af9a2b86d44e765c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 1 Jun 2026 22:17:50 +0300 Subject: [PATCH 05/79] =?UTF-8?q?2D-=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20(?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B8,=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BA=D0=B8)=20=D0=B8=20=D0=B2=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте, создание заявки рисованием полигона на карте с формой параметров (Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest, RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView добавлен изолированный режим рисования полигона. 2D-карта: панели информации о станции и о заявках, слой станций (stationsApi), поддержка выбора объектов через спатиальный индекс. Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя, по умолчанию включены работы плана, станции и заявки. --- services/pcp-tgu-ops-ui/src/api/requestApi.ts | 42 +- .../pcp-tgu-ops-ui/src/api/stationsApi.ts | 18 + .../src/features/tgu-map-2d/Tgu2DMapLayers.ts | 29 +- .../tgu-map-2d/Tgu2DMapRequestsPanel.tsx | 115 +++++ .../features/tgu-map-2d/Tgu2DMapSidebar.tsx | 16 +- .../tgu-map-2d/Tgu2DMapStationPanel.tsx | 43 ++ .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 88 +++- .../features/tgu-map-2d/Tgu2DMapToolbar.tsx | 2 +- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 158 ++++++- .../tgu-map-2d/geometry/spatialIndex.ts | 22 + .../tgu-map-2d/model/mapLayerTypes.ts | 23 +- .../tgu-map-2d/model/mapSelectionTypes.ts | 6 + .../src/features/tgu-map-2d/model/mapTypes.ts | 4 + .../tgu-map-2d/tgu2DMapInterval.test.ts | 8 +- .../features/tgu-planning/TguPlanningPage.tsx | 16 +- .../src/features/tgu-planning/TguSidebar.tsx | 5 + .../src/features/tgu-planning/TguTimeline.tsx | 22 +- .../src/features/tgu-planning/TguToolbar.tsx | 20 +- .../tgu-requests/RequestCreatePanel.tsx | 355 +++++++++++++++ .../tgu-requests/RequestDetailsPanel.tsx | 142 ++++++ .../features/tgu-requests/TguRequestsTab.tsx | 420 ++++++++++++++++++ .../tgu-requests/requestGeometry.test.ts | 27 ++ .../features/tgu-requests/requestGeometry.ts | 27 ++ .../pcp-tgu-ops-ui/src/model/requestTypes.ts | 41 ++ .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 2 +- .../src/styles/tgu-planning.css | 296 +++++++++++- 26 files changed, 1851 insertions(+), 96 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/stationsApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts diff --git a/services/pcp-tgu-ops-ui/src/api/requestApi.ts b/services/pcp-tgu-ops-ui/src/api/requestApi.ts index c864f01..7d3e8d4 100644 --- a/services/pcp-tgu-ops-ui/src/api/requestApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/requestApi.ts @@ -1,4 +1,4 @@ -import type { RequestFilter, RequestMapItem } from "../model/requestTypes"; +import type { CreateRequestPayload, CreateRequestResult, RequestFilter, RequestMapItem } from "../model/requestTypes"; const BASE = "/api/pcp-request/v1"; const PAGE_SIZE = 500; @@ -60,3 +60,43 @@ export async function fetchRequestsForMap( truncated: first.totalPages > MAX_PAGES }; } + +type ErrorDetail = { field?: string; message?: string }; +type ErrorBody = { code?: string; message?: string; details?: ErrorDetail[] }; + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as ErrorBody; + const details = body.details?.map((d) => (d.field ? `${d.field}: ${d.message}` : d.message)).filter(Boolean); + const detailText = details && details.length > 0 ? ` (${details.join("; ")})` : ""; + if (body.message) return `${body.message}${detailText}`; + } catch { + // тело ответа не JSON — используем fallback + } + return `${fallback}: ${response.status}`; +} + +export async function createRequest(payload: CreateRequestPayload): Promise { + const response = await fetch(`${BASE}/requests`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Не удалось создать заявку")); + } + + return response.json() as Promise; +} + +export async function deleteRequest(id: string): Promise { + const response = await fetch(`${BASE}/requests/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { Accept: "application/json" } + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Не удалось удалить заявку")); + } +} diff --git a/services/pcp-tgu-ops-ui/src/api/stationsApi.ts b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts new file mode 100644 index 0000000..aede019 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts @@ -0,0 +1,18 @@ +export type StationDto = { + id: string | null; + number: number; + name: string; + position: { lat: number; long: number; height: number }; + elevationMin: number; + elevationMax: number; +}; + +export async function fetchStations(): Promise { + const response = await fetch("/api/stations", { + headers: { Accept: "application/json" } + }); + if (!response.ok) { + throw new Error(`Ошибка загрузки станций: ${response.status}`); + } + return response.json() as Promise; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts index 55634a2..53eec8a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts @@ -1,8 +1,9 @@ +import type { StationDto } from "../../api/stationsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { parseWktPolygon } from "./geometry/wktParser"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; +import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; export type Tgu2DMapLayerAvailability = Partial>; @@ -12,17 +13,19 @@ export type Tgu2DMapSceneInput = { platforms: TguPlatformUi[]; hiddenSpacecraftIds?: ReadonlySet; requestItems?: RequestMapItem[]; + stationItems?: StationDto[]; }; const UNAVAILABLE = "Данные слоя недоступны в текущем API."; export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene { const requestPolygons = buildRequestPolygons(input.requestItems ?? []); + const stationMarkers = buildStationMarkers(input.stationItems ?? []); return { polygons: requestPolygons, lines: [], - markers: [] + markers: stationMarkers }; } @@ -50,7 +53,25 @@ function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] { return polygons; } -export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability { +export function buildStationMarkers(items: StationDto[]): MapMarker[] { + return items.map((item, index) => ({ + id: item.id ?? `station:${index}`, + layer: "stations" as const, + point: { lon: item.position.long, lat: item.position.lat }, + label: item.name, + zIndex: index, + stationNumber: item.number, + altitude: item.position.height, + elevationMin: item.elevationMin, + elevationMax: item.elevationMax + })); +} + +export function getLayerAvailability( + layers: Tgu2DMapLayersState, + scene: Tgu2DMapScene, + stationsFetchFailed?: boolean +): Tgu2DMapLayerAvailability { const availability: Tgu2DMapLayerAvailability = {}; if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) { availability.tracks = UNAVAILABLE; @@ -61,7 +82,7 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) { availability.planWorks = UNAVAILABLE; } - if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) { + if (layers.stations && stationsFetchFailed) { availability.stations = UNAVAILABLE; } if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx new file mode 100644 index 0000000..706c160 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx @@ -0,0 +1,115 @@ +import type { RequestMapItem } from "../../model/requestTypes"; +import type { MapPolygon } from "./model/mapTypes"; + +export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem }; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onClose: () => void; +}; + +export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) { + const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined; + + return ( + + ); +} + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString("ru-RU"); +} + +function formatDateRange(begin: string, end: string): string { + const b = new Date(begin); + const e = new Date(end); + const sameDay = + b.getFullYear() === e.getFullYear() && + b.getMonth() === e.getMonth() && + b.getDate() === e.getDate(); + const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" }; + const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" }; + const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`; + const endStr = sameDay + ? b.toLocaleTimeString("ru-RU", timeOpts) + : `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`; + return `${beginStr} – ${endStr}`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx index 9d85e1a..0ae04cc 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx @@ -70,7 +70,7 @@ export function Tgu2DMapSidebar({ return ( ); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx new file mode 100644 index 0000000..fab4765 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx @@ -0,0 +1,43 @@ +import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; + +type StationSelection = Extract & { layer: "stations" }; + +type Props = { + selection: StationSelection; + onClose: () => void; +}; + +export function Tgu2DMapStationPanel({ selection, onClose }: Props) { + return ( + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index 8082522..fb6e699 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -1,13 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; +import type { MapPolygon } from "./model/mapTypes"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; +import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; +import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; @@ -56,6 +60,18 @@ export function Tgu2DMapTab({ const [selectedObject, setSelectedObject] = useState(); const [hiddenIds, setHiddenIds] = useState>(new Set()); const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); + const [stations, setStations] = useState([]); + const [stationsFetchFailed, setStationsFetchFailed] = useState(false); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>(); + + useEffect(() => { + fetchStations() + .then((items) => { setStations(items); setStationsFetchFailed(false); }) + .catch(() => setStationsFetchFailed(true)); + }, []); const mapInterval = getTgu2DMapIntervalState(range, selectedPlan); const mapInvalidRange = invalidRange || mapInterval.tooLarge; @@ -146,11 +162,15 @@ export function Tgu2DMapTab({ selectedPlan, platforms, hiddenSpacecraftIds: hiddenIds, - requestItems: layers.requests ? requestsState.items : [] + requestItems: layers.requests ? requestsState.items : [], + stationItems: layers.stations ? stations : [] }), - [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests] + [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations] + ); + const layerAvailability = useMemo( + () => getLayerAvailability(layers, scene, stationsFetchFailed), + [layers, scene, stationsFetchFailed] ); - const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]); const overlayMessage = invalidRange ? "Некорректный интервал: from должен быть меньше to." @@ -162,6 +182,49 @@ export function Tgu2DMapTab({ setLayers((current) => ({ ...current, [key]: !current[key] })); }; + const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + if (selection?.type === "marker" && selection.layer === "stations") { + setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" }); + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setSelectedObject(undefined); + } else { + setStationPanel(undefined); + setSelectedObject(selection); + } + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (polygons.length === 0) { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setStationPanel(undefined); + }, + [requestsState.items] + ); + + const closeRequestsPanel = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + }, []); + + const closeStationPanel = useCallback(() => { + setStationPanel(undefined); + }, []); + return (
-
+
0 || stationPanel != null) ? " has-requests-panel" : ""}`}> + {requestsAtPoint.length > 0 && ( + + )} + {stationPanel != null && ( + + )}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx index d56fae7..9057610 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx @@ -18,7 +18,7 @@ export function Tgu2DMapToolbar({ return (
-
Карта 2D
+
Карта
{satelliteLabel} · {intervalLabel}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index b9908e8..c142f12 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -7,9 +7,9 @@ import { drawSwath } from "./canvas/drawSwath"; import { drawTiles } from "./canvas/drawTiles"; import { drawTracks } from "./canvas/drawTracks"; import { createMapProjection } from "./geometry/mapProjection"; -import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; +import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; +import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; type Tgu2DMapViewProps = { @@ -17,12 +17,18 @@ type Tgu2DMapViewProps = { layers: Tgu2DMapLayersState; redrawToken: number; onObjectSelect: (selection?: Tgu2DMapSelection) => void; + onRequestsAtPoint?: (polygons: MapPolygon[]) => void; + hoveredRequestPolygonId?: string; + selectedRequestPolygonId?: string; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; pickTarget?: Tgu2DMapTarget; selectedTargetId?: string; onTargetSelect?: (id: string) => void; + drawMode?: boolean; + draftPolygon?: GeoPoint[]; + onDrawAddPoint?: (point: GeoPoint) => void; }; export type Tgu2DMapTarget = { @@ -59,17 +65,26 @@ export function Tgu2DMapView({ layers, redrawToken, onObjectSelect, + onRequestsAtPoint, + hoveredRequestPolygonId, + selectedRequestPolygonId, pickMode = false, onPickPoint, targets = [], pickTarget, selectedTargetId, - onTargetSelect + onTargetSelect, + drawMode = false, + draftPolygon = [], + onDrawAddPoint }: Tgu2DMapViewProps) { const wrapperRef = useRef(null); const canvasRef = useRef(null); const dragRef = useRef(undefined); + const hasDraggedRef = useRef(false); const hoverRef = useRef(undefined); + const hoverMarkerRef = useRef(undefined); + const selectedMarkerRef = useRef(undefined); const [size, setSize] = useState({ width: 1000, height: 560 }); const [view, setView] = useState(INITIAL_VIEW); const [tileVersion, setTileVersion] = useState(0); @@ -81,6 +96,10 @@ export function Tgu2DMapView({ [scene.polygons, layers] ); const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]); + const activeMarkers = useMemo( + () => scene.markers.filter((marker) => layers[marker.layer]), + [scene.markers, layers] + ); const projection = useMemo(() => createMapProjection(view, size), [view, size]); const visiblePolygons = useMemo(() => { // When the canvas is wider than the world tile, the entire globe is visible — @@ -170,11 +189,15 @@ export function Tgu2DMapView({ const updateHover = useCallback( (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); - const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); - if (polygon?.id === hoverRef.current?.id) return; + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); + if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return; + + hoverMarkerRef.current = marker; hoverRef.current = polygon; - if (!polygon) { + + if (!marker && !polygon) { setTooltip(undefined); return; } @@ -182,15 +205,16 @@ export function Tgu2DMapView({ setTooltip({ x: screenPoint.x + 12, y: screenPoint.y + 12, - title: polygon.name, - subtitle: layerTitle(polygon.layer) + title: marker ? marker.label : polygon!.name, + subtitle: marker ? "Станция" : layerTitle(polygon!.layer) }); }, - [polygonIndex, projection, screenPointFromEvent] + [activeMarkers, polygonIndex, projection, screenPointFromEvent] ); const onPointerDown = (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); + hasDraggedRef.current = false; dragRef.current = { startX: screenPoint.x, startY: screenPoint.y, @@ -199,6 +223,8 @@ export function Tgu2DMapView({ wrapperRef.current?.setPointerCapture(event.pointerId); }; + const DRAG_THRESHOLD = 4; + const onPointerMove = (event: React.PointerEvent) => { const drag = dragRef.current; if (!drag) { @@ -209,6 +235,9 @@ export function Tgu2DMapView({ const screenPoint = screenPointFromEvent(event); const dx = screenPoint.x - drag.startX; const dy = screenPoint.y - drag.startY; + if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { + hasDraggedRef.current = true; + } const dragProjection = createMapProjection( { centerLon: drag.centerAtStart.lon, @@ -231,15 +260,53 @@ export function Tgu2DMapView({ }; const onClick = (event: React.MouseEvent) => { + if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); + if (drawMode) { + onDrawAddPoint?.(projection.unproject(screenPoint)); + return; + } if (pickMode) { onPickPoint?.(projection.unproject(screenPoint)); onObjectSelect(undefined); return; } - const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + if (marker) { + selectedMarkerRef.current = marker; + selectedPolygonRef.current = undefined; + onRequestsAtPoint?.([]); + onObjectSelect({ + type: "marker", + id: marker.id, + name: marker.label, + layer: marker.layer, + stationNumber: marker.stationNumber, + lat: marker.point.lat, + lon: marker.point.lon, + altitude: marker.altitude, + elevationMin: marker.elevationMin, + elevationMax: marker.elevationMax + }); + return; + } + + const geoPoint = projection.unproject(screenPoint); + const polygon = hitTestPolygons(polygonIndex, geoPoint); + + if (polygon?.layer === "requests" && onRequestsAtPoint) { + const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests"); + onRequestsAtPoint(allRequests); + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + onObjectSelect(undefined); + return; + } + + onRequestsAtPoint?.([]); selectedPolygonRef.current = polygon; + selectedMarkerRef.current = undefined; if (!polygon) { onObjectSelect(undefined); @@ -276,11 +343,21 @@ export function Tgu2DMapView({ }; const selectedPolygon = selectedPolygonRef.current; - const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]); + const selectedMarker = selectedMarkerRef.current; + const externalSelectedPolygon = useMemo( + () => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined, + [selectedRequestPolygonId, activePolygons] + ); + const externalHoveredPolygon = useMemo( + () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, + [hoveredRequestPolygonId, activePolygons] + ); + const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]); + const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); return (
{ dragRef.current = undefined; hoverRef.current = undefined; + hoverMarkerRef.current = undefined; setTooltip(undefined); }} onWheel={onWheel} @@ -299,7 +377,10 @@ export function Tgu2DMapView({ {overlayPolygons.flatMap((polygon) => { const raw = polygon.points.map((point) => projection.project(point)); const pts = unwrapX(raw, projection.worldSize); - const cls = polygon.id === selectedPolygon?.id ? "is-selected" : "is-hovered"; + const cls = + polygon.id === selectedPolygon?.id || polygon.id === externalSelectedPolygon?.id + ? "is-selected" + : "is-hovered"; return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { const visible = pts.some((p) => { const x = p.x + dx; @@ -315,6 +396,11 @@ export function Tgu2DMapView({ ); }); })} + {overlayMarkers.map((marker) => { + const pt = projection.project(marker.point); + const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; + return ; + })} {targets.map((t) => { const screen = projection.project(t); const isSelected = t.id === selectedTargetId; @@ -344,6 +430,22 @@ export function Tgu2DMapView({ ); })()} + {draftPolygon.length > 0 && (() => { + const pts = unwrapX(draftPolygon.map((point) => projection.project(point)), projection.worldSize); + const path = pts.map((p) => `${p.x},${p.y}`).join(" "); + return ( + + {pts.length >= 2 && ( + pts.length >= 3 + ? + : + )} + {pts.map((p, i) => ( + + ))} + + ); + })()} {tooltip && (
@@ -388,6 +490,36 @@ function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: nu return out; } +function hitTestMarkers( + markers: MapMarker[], + screenPoint: ScreenPoint, + projection: ReturnType, + hitRadius = 10 +): MapMarker | undefined { + let best: MapMarker | undefined; + let bestDist = hitRadius; + for (const marker of markers) { + const pt = projection.project(marker.point); + const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2); + if (dist <= bestDist) { + bestDist = dist; + best = marker; + } + } + return best; +} + +function uniqueMarkers(markers: Array): MapMarker[] { + const result: MapMarker[] = []; + const ids = new Set(); + for (const marker of markers) { + if (!marker || ids.has(marker.id)) continue; + ids.add(marker.id); + result.push(marker); + } + return result; +} + function uniquePolygons(polygons: Array): MapPolygon[] { const result: MapPolygon[] = []; const ids = new Set(); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts index 44ca8eb..6cb11e0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -20,6 +20,28 @@ export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatia }; } +export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] { + const hits: MapPolygon[] = []; + + for (const entry of index.entries) { + const inBbox = + bboxContainsPoint(entry.bbox, point) || + bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) || + bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat }); + if (!inBbox) continue; + + const normalized = unwrapLon(entry.polygon.points); + const hit = + pointInPolygon(point, normalized) || + pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) || + pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized); + + if (hit) hits.push(entry.polygon); + } + + return hits.sort((a, b) => b.zIndex - a.zIndex); +} + export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined { let selected: MapPolygon | undefined; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts index 54ba229..502cb80 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -9,31 +9,16 @@ export type Tgu2DMapLayerDefinition = { }; export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ - { - key: "tracks", - label: "Трасса КА", - description: "Проекция движения КА на поверхность." - }, - { - key: "swath", - label: "Полоса обзора", - description: "Массовые полосы обзора через canvas." - }, { key: "planWorks", label: "Работы плана", - description: "Полигоны работ выбранного плана." + description: "Маршруты съёмки из выбранного плана." }, { key: "stations", label: "Станции", description: "Наземные станции приёма." }, - { - key: "spacecraftMarkers", - label: "Маркеры КА", - description: "Текущие или расчётные позиции КА." - }, { key: "requests", label: "Заявки на съёмку", @@ -42,11 +27,11 @@ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ ]; export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { - tracks: true, - swath: true, + tracks: false, + swath: false, planWorks: true, stations: true, - spacecraftMarkers: true, + spacecraftMarkers: false, requests: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts index 98829d1..bc67087 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -18,5 +18,11 @@ export type Tgu2DMapSelection = name: string; layer: "stations" | "spacecraftMarkers"; spacecraftId?: string; + stationNumber?: number; + lat?: number; + lon?: number; + altitude?: number; + elevationMin?: number; + elevationMax?: number; }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index a4c6c85..020bbec 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -50,6 +50,10 @@ export type MapMarker = { point: GeoPoint; label: string; zIndex: number; + stationNumber?: number; + altitude?: number; + elevationMin?: number; + elevationMax?: number; }; export type Tgu2DMapScene = { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 5c676ae..ec5cec8 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -45,13 +45,13 @@ describe("tgu2DMapInterval", () => { }); }); - it("enables all requested 2D map layers by default", () => { + it("enables plan, stations and requests layers by default", () => { expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({ - tracks: true, - swath: true, + tracks: false, + swath: false, planWorks: true, stations: true, - spacecraftMarkers: true, + spacecraftMarkers: false, requests: true }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 4d0edf8..f3db83f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -4,6 +4,7 @@ import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; +import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; import { TguPlanDetails } from "./TguPlanDetails"; import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; @@ -114,14 +115,6 @@ export function TguPlanningPage() { setDecisionNotice(undefined); }; - const refresh = () => { - if (invalidRange) { - setPageError("Некорректный интервал: from должен быть меньше to."); - return; - } - void loadData(appliedRange); - }; - const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => { setDecisionInFlight(true); setDecisionError(undefined); @@ -156,7 +149,6 @@ export function TguPlanningPage() { onToChange={setToValue} onApply={applyRange} onQuickRange={quickRange} - onRefresh={refresh} /> } > @@ -205,6 +197,12 @@ export function TguPlanningPage() { selectedPlan={selectedPlan} platforms={platforms} /> + ) : activeTab === "requests" ? ( + ) : ( Нет КА по текущему поиску.
}
+ +
+ +
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx index e23ef5e..1f4271b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; import { buildSequentialLinks, @@ -30,9 +30,11 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec const height = Math.max(260, timelineHeight(rows.length)); const nowMs = Date.now(); - useEffect(() => { + useLayoutEffect(() => { if (!containerRef.current) return; + setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width)); + const observer = new ResizeObserver(([entry]) => { setContainerWidth(Math.floor(entry.contentRect.width)); }); @@ -55,17 +57,14 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec ? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth) : undefined; - if (invalidRange) { - return
Некорректный интервал: from должен быть меньше to.
; - } - - if (rows.length === 0 || segments.length === 0) { - return
Нет планов в выбранном интервале.
; - } - return (
- + {invalidRange ? ( +
Некорректный интервал: from должен быть меньше to.
+ ) : rows.length === 0 || segments.length === 0 ? ( +
Нет планов в выбранном интервале.
+ ) : ( + @@ -175,6 +174,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec ); })} + )}
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx index 111cf31..ab77150 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx @@ -1,4 +1,3 @@ -import { TguLegend } from "./TguLegend"; import type { TguActiveTab } from "../../model/timelineTypes"; type TguToolbarProps = { @@ -12,7 +11,6 @@ type TguToolbarProps = { onToChange: (value: string) => void; onApply: () => void; onQuickRange: (hours: number) => void; - onRefresh: () => void; }; export function TguToolbar({ @@ -25,8 +23,7 @@ export function TguToolbar({ onFromChange, onToChange, onApply, - onQuickRange, - onRefresh + onQuickRange }: TguToolbarProps) { return (
@@ -56,7 +53,7 @@ export function TguToolbar({ aria-selected={activeTab === "map"} onClick={() => onTabChange("map")} > - Карта 2D + Карта +
- {error &&
{error}
} ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx new file mode 100644 index 0000000..d1957d6 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx @@ -0,0 +1,355 @@ +import { useState } from "react"; +import type { + OpticsParams, + OpticsResultType, + Polarisation, + RsaParams, + RsaResultType +} from "../../model/requestTypes"; + +export type RequestFormSubmit = { + name: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +type Props = { + vertexCount: number; + drawing: boolean; + defaultBegin: string; + defaultEnd: string; + submitting: boolean; + error?: string; + onUndoPoint: () => void; + onClear: () => void; + onFinishDrawing: () => void; + onRestartDrawing: () => void; + onSubmit: (form: RequestFormSubmit) => void; + onCancel: () => void; +}; + +const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [ + { value: "PANCHROMATIC", label: "Панхроматическая" }, + { value: "MULTISPECTRAL", label: "Мультиспектральная" }, + { value: "PANSHARPENING", label: "Паншарпенинг" } +]; + +const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [ + { value: "SPOTLIGHT", label: "Spotlight" }, + { value: "STRIPMAP", label: "Stripmap" }, + { value: "SCANSAR", label: "ScanSAR" } +]; + +const POLARISATIONS: { value: Polarisation; label: string }[] = [ + { value: "HH", label: "HH" }, + { value: "VV", label: "VV" } +]; + +export function RequestCreatePanel({ + vertexCount, + drawing, + defaultBegin, + defaultEnd, + submitting, + error, + onUndoPoint, + onClear, + onFinishDrawing, + onRestartDrawing, + onSubmit, + onCancel +}: Props) { + const [name, setName] = useState(""); + const [importance, setImportance] = useState("1"); + const [begin, setBegin] = useState(defaultBegin); + const [end, setEnd] = useState(defaultEnd); + const [kpp, setKpp] = useState(""); + const [highPriority, setHighPriority] = useState(false); + + const [opticsEnabled, setOpticsEnabled] = useState(true); + const [opticsResultType, setOpticsResultType] = useState("PANCHROMATIC"); + const [opticsResolution, setOpticsResolution] = useState("1"); + const [sunAngleMin, setSunAngleMin] = useState("10"); + const [sunAngleMax, setSunAngleMax] = useState("90"); + const [clouds, setClouds] = useState("100"); + + const [rsaEnabled, setRsaEnabled] = useState(false); + const [rsaResultType, setRsaResultType] = useState("STRIPMAP"); + const [interferometry, setInterferometry] = useState(false); + const [polarisation, setPolarisation] = useState("HH"); + const [rsaResolution, setRsaResolution] = useState("1"); + + const importanceNum = Number(importance); + const beginMs = new Date(begin).getTime(); + const endMs = new Date(end).getTime(); + + const kppList = parseKpp(kpp); + const kppValid = kppList !== null; + + const geometryReady = vertexCount >= 3 && !drawing; + + const validationError = buildValidationError({ + geometryReady, + drawing, + vertexCount, + name, + importance, + importanceNum, + beginMs, + endMs, + kppValid, + opticsEnabled, + opticsResolution, + rsaEnabled, + rsaResolution + }); + + const handleSubmit = () => { + if (validationError || !kppList) return; + const optics: OpticsParams | undefined = opticsEnabled + ? { + resultType: opticsResultType, + resolution: Number(opticsResolution), + sunAngleMin: Number(sunAngleMin), + sunAngleMax: Number(sunAngleMax), + clouds: Number(clouds) + } + : undefined; + const rsa: RsaParams | undefined = rsaEnabled + ? { + resultType: rsaResultType, + interferometry, + polarisation, + resolution: Number(rsaResolution) + } + : undefined; + + onSubmit({ + name: name.trim(), + importance: importanceNum, + beginDateTime: begin, + endDateTime: end, + kpp: kppList, + highPriorityTransmit: highPriority, + optics, + rsa + }); + }; + + return ( + + ); +} + +function parseKpp(raw: string): number[] | null { + const trimmed = raw.trim(); + if (trimmed === "") return []; + const parts = trimmed.split(/[,\s]+/).filter(Boolean); + const result: number[] = []; + for (const part of parts) { + const n = Number(part); + if (!Number.isInteger(n) || n < 0) return null; + result.push(n); + } + return result; +} + +function buildValidationError(input: { + geometryReady: boolean; + drawing: boolean; + vertexCount: number; + name: string; + importance: string; + importanceNum: number; + beginMs: number; + endMs: number; + kppValid: boolean; + opticsEnabled: boolean; + opticsResolution: string; + rsaEnabled: boolean; + rsaResolution: string; +}): string | null { + if (input.drawing) return "Завершите рисование полигона (≥3 вершин)."; + if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин."; + if (input.name.trim() === "") return "Укажите название заявки."; + if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) { + return "Важность должна быть числом ≥ 0."; + } + if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) { + return "Начало должно быть раньше конца."; + } + if (!input.kppValid) return "КПП — список целых чисел через запятую."; + if (!input.opticsEnabled && !input.rsaEnabled) { + return "Включите хотя бы один блок съёмки: Оптика или РСА."; + } + if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) { + return "Разрешение оптики должно быть ≥ 0.1 м."; + } + if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) { + return "Разрешение РСА должно быть ≥ 0.1 м."; + } + return null; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx new file mode 100644 index 0000000..1f19094 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -0,0 +1,142 @@ +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + deleting: boolean; + deleteError?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onDelete: (requestId: string) => void; + onClose: () => void; +}; + +export function RequestDetailsPanel({ + requests, + selectedPolygonId, + deleting, + deleteError, + onHover, + onSelect, + onDelete, + onClose +}: Props) { + const selectedEntry = selectedPolygonId + ? requests.find((r) => r.polygon.id === selectedPolygonId) + : requests.length === 1 + ? requests[0] + : undefined; + + return ( + + ); +} + +function formatDateTime(value: string): string { + return new Date(value).toLocaleString("ru-RU"); +} + +function surveyTypeLabel(surveyType: string): string { + switch (surveyType) { + case "OPTICS": + return "Оптическая"; + case "RSA": + return "Радиолокационная (РСА)"; + case "COMBINED": + return "Комбинированная"; + default: + return surveyType; + } +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx new file mode 100644 index 0000000..261814e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createRequest, deleteRequest, fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; +import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; +import { RequestDetailsPanel } from "./RequestDetailsPanel"; +import { polygonToWkt } from "./requestGeometry"; + +type TguRequestsTabProps = { + range: TimelineRange; + invalidRange: boolean; + platforms: TguPlatformUi[]; +}; + +type RequestsLoadState = { + fromValue: string; + toValue: string; + items: RequestMapItem[]; + loading: boolean; + loadedCount: number; + totalItems: number; + truncated: boolean; + error?: string; + loaded: boolean; +}; + +type PanelMode = "none" | "details" | "create"; + +function buildInitialLoadState(range: TimelineRange): RequestsLoadState { + return { + fromValue: formatDateTimeLocal(range.fromMs), + toValue: formatDateTimeLocal(range.toMs), + items: [], + loading: false, + loadedCount: 0, + totalItems: 0, + truncated: false, + loaded: false + }; +} + +export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTabProps) { + const [requestsState, setRequestsState] = useState(() => buildInitialLoadState(range)); + const [stations, setStations] = useState([]); + const [showStations, setShowStations] = useState(true); + const [redrawToken, setRedrawToken] = useState(0); + + const [panelMode, setPanelMode] = useState("none"); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + + const [draftPolygon, setDraftPolygon] = useState([]); + const [drawing, setDrawing] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(); + + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(); + const [notice, setNotice] = useState(); + + useEffect(() => { + fetchStations() + .then((items) => setStations(items)) + .catch(() => setStations([])); + }, []); + + const loadRequests = useCallback(async (fromValue: string, toValue: string) => { + const fromMs = new Date(fromValue).getTime(); + const toMs = new Date(toValue).getTime(); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + + setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); + try { + const result = await fetchRequestsForMap( + { endFrom: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() }, + (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })) + ); + setRequestsState((current) => ({ + ...current, + items: result.items, + loadedCount: result.items.length, + totalItems: result.totalItems, + truncated: result.truncated, + loading: false, + loaded: true + })); + } catch (error) { + setRequestsState((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : "Не удалось загрузить заявки." + })); + } + }, []); + + useEffect(() => { + setRequestsState(buildInitialLoadState(range)); + }, [range]); + + const applyRange = useCallback(() => { + void loadRequests(requestsState.fromValue, requestsState.toValue); + }, [loadRequests, requestsState.fromValue, requestsState.toValue]); + + const scene = useMemo( + () => + buildTgu2DMapScene({ + range, + platforms, + requestItems: requestsState.items, + stationItems: showStations ? stations : [] + }), + [range, platforms, requestsState.items, showStations, stations] + ); + + const layers = useMemo( + () => ({ tracks: false, swath: false, planWorks: false, stations: showStations, spacecraftMarkers: false, requests: true }), + [showStations] + ); + + const closeAll = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (panelMode === "create") return; + if (polygons.length === 0) { + closeAll(); + setPanelMode("none"); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(joined.length === 1 ? joined[0].polygon.id : undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + setPanelMode(joined.length > 0 ? "details" : "none"); + }, + [panelMode, requestsState.items, closeAll] + ); + + // Детали заявок открываются через onRequestsAtPoint. onObjectSelect карта + // вызывает с undefined одновременно с onRequestsAtPoint, поэтому здесь no-op, + // чтобы не затирать только что открытую панель (станции/пустой клик уже + // закрывают панель через onRequestsAtPoint([])). + const handleObjectSelect = useCallback((_selection?: Tgu2DMapSelection) => {}, []); + + const startCreate = useCallback(() => { + closeAll(); + setDraftPolygon([]); + setDrawing(true); + setCreateError(undefined); + setNotice(undefined); + setPanelMode("create"); + }, [closeAll]); + + const cancelCreate = useCallback(() => { + setDraftPolygon([]); + setDrawing(false); + setCreateError(undefined); + setPanelMode("none"); + }, []); + + const handleDrawAddPoint = useCallback( + (point: GeoPoint) => { + if (panelMode !== "create" || !drawing) return; + setDraftPolygon((current) => [...current, point]); + }, + [panelMode, drawing] + ); + + const submitCreate = useCallback( + async (form: RequestFormSubmit) => { + let geometry: string; + try { + geometry = polygonToWkt(draftPolygon); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Некорректная геометрия."); + return; + } + + setCreating(true); + setCreateError(undefined); + try { + const id = crypto.randomUUID(); + await createRequest({ + id, + name: form.name, + geometry, + importance: form.importance, + beginDateTime: new Date(form.beginDateTime).toISOString(), + endDateTime: new Date(form.endDateTime).toISOString(), + kpp: form.kpp, + highPriorityTransmit: form.highPriorityTransmit, + optics: form.optics, + rsa: form.rsa + }); + setDraftPolygon([]); + setDrawing(false); + setPanelMode("none"); + setNotice(`Заявка «${form.name}» создана.`); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Не удалось создать заявку."); + } finally { + setCreating(false); + } + }, + [draftPolygon, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const handleDelete = useCallback( + async (requestId: string) => { + setDeleting(true); + setDeleteError(undefined); + try { + await deleteRequest(requestId); + closeAll(); + setPanelMode("none"); + setNotice("Заявка удалена."); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : "Не удалось удалить заявку."); + } finally { + setDeleting(false); + } + }, + [closeAll, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const fromMs = new Date(requestsState.fromValue).getTime(); + const toMs = new Date(requestsState.toValue).getTime(); + const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs; + + const hasPanel = panelMode !== "none"; + + return ( +
+
+
+
Заявки
+
+ {requestsState.loaded ? `Загружено: ${requestsState.items.length}` : "Заявки не загружены"} +
+
+
+ + +
+ + +
+ +
+ + +
+ {invalidRange &&
Некорректный интервал: from должен быть меньше to.
} + {panelMode === "create" && drawing && ( +
+ Кликайте по карте, чтобы добавить вершины полигона. Нажмите «Завершить» (≥3 вершин). +
+ )} + +
+ + {panelMode === "details" && ( + { + closeAll(); + setPanelMode("none"); + }} + /> + )} + + {panelMode === "create" && ( + setDraftPolygon((cur) => cur.slice(0, -1))} + onClear={() => setDraftPolygon([])} + onFinishDrawing={() => setDrawing(false)} + onRestartDrawing={() => { + setDraftPolygon([]); + setDrawing(true); + }} + onSubmit={submitCreate} + onCancel={cancelCreate} + /> + )} +
+
+ ); +} + +function formatDateTimeLocal(ms: number): string { + const date = new Date(ms); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts new file mode 100644 index 0000000..4469fab --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { polygonToWkt } from "./requestGeometry"; + +describe("polygonToWkt", () => { + it("формирует WKT POLYGON с замыканием кольца", () => { + const wkt = polygonToWkt([ + { lon: 10, lat: 20 }, + { lon: 30, lat: 20 }, + { lon: 30, lat: 40 } + ]); + expect(wkt).toBe("POLYGON((10 20, 30 20, 30 40, 10 20))"); + }); + + it("не дублирует замыкающую точку, если кольцо уже замкнуто", () => { + const wkt = polygonToWkt([ + { lon: 0, lat: 0 }, + { lon: 5, lat: 0 }, + { lon: 5, lat: 5 }, + { lon: 0, lat: 0 } + ]); + expect(wkt).toBe("POLYGON((0 0, 5 0, 5 5, 0 0))"); + }); + + it("бросает ошибку при менее чем 3 вершинах", () => { + expect(() => polygonToWkt([{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }])).toThrow(); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts new file mode 100644 index 0000000..f85be6a --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts @@ -0,0 +1,27 @@ +import type { GeoPoint } from "../tgu-map-2d/model/mapTypes"; + +/** + * Преобразует набор вершин полигона в WKT POLYGON. + * Порядок координат WKT — "lon lat". Кольцо замыкается первой вершиной, + * если оно ещё не замкнуто. Требуется минимум 3 вершины. + */ +export function polygonToWkt(points: GeoPoint[]): string { + if (points.length < 3) { + throw new Error("Полигон должен содержать минимум 3 вершины."); + } + + const ring = [...points]; + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first.lon !== last.lon || first.lat !== last.lat) { + ring.push(first); + } + + const coords = ring.map((p) => `${formatCoord(p.lon)} ${formatCoord(p.lat)}`).join(", "); + return `POLYGON((${coords}))`; +} + +function formatCoord(value: number): string { + // ограничиваем разрядность, убираем хвостовые нули + return Number(value.toFixed(6)).toString(); +} diff --git a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts index 6c46c12..fb916c4 100644 --- a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts @@ -63,3 +63,44 @@ export type RequestMapItem = { createdAt: string; updatedAt: string; }; + +export type OpticsResultType = "PANCHROMATIC" | "MULTISPECTRAL" | "PANSHARPENING"; +export type RsaResultType = "SPOTLIGHT" | "STRIPMAP" | "SCANSAR"; +export type Polarisation = "HH" | "VV"; + +export type OpticsParams = { + resultType: OpticsResultType; + resolution: number; + sunAngleMin: number; + sunAngleMax: number; + clouds: number; +}; + +export type RsaParams = { + resultType: RsaResultType; + interferometry: boolean; + polarisation: Polarisation; + resolution: number; +}; + +export type CreateRequestPayload = { + id: string; + name: string; + geometry: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +export type CreateRequestResult = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + importance: number; + message?: string; +}; diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts index cfc1137..8e19390 100644 --- a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts @@ -22,7 +22,7 @@ export type TimelineRange = { toMs: number; }; -export type TguActiveTab = "timeline" | "map" | "editor"; +export type TguActiveTab = "timeline" | "map" | "editor" | "requests"; export type TimelineRow = { spacecraftId: string; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 0e1999f..40e9ae7 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -16,7 +16,7 @@ .tgu-toolbar__row { display: flex; - align-items: end; + align-items: center; gap: 0.75rem; min-height: 4.4rem; padding: 0.75rem 1rem 0.65rem; @@ -53,8 +53,9 @@ } .tgu-field { - display: grid; - gap: 0.25rem; + display: flex; + align-items: center; + gap: 0.45rem; color: var(--text-muted); font-size: 0.72rem; } @@ -114,7 +115,6 @@ .tgu-tabs { display: inline-flex; gap: 0.15rem; - align-self: center; border: 1px solid var(--line); border-radius: 7px; padding: 0.18rem; @@ -219,6 +219,17 @@ border-right: 1px solid var(--line); } +.tgu-sidebar__legend { + border-top: 1px solid var(--line); + margin-top: auto; +} + +.tgu-sidebar__legend .tgu-legend { + flex-direction: column; + align-items: flex-start; + padding: 0.75rem 1rem; +} + .tgu-panel-heading { display: flex; justify-content: space-between; @@ -344,7 +355,6 @@ .tgu-timeline__svg { display: block; width: 100%; - min-height: 26rem; border: 1px solid var(--line); border-radius: 8px; background: #151819; @@ -832,6 +842,18 @@ stroke-width: 2; } +.tgu-2d-map__overlay circle { + fill: rgba(104, 195, 189, 0.12); + stroke: rgba(130, 222, 216, 0.9); + stroke-width: 2; +} + +.tgu-2d-map__overlay circle.is-selected { + fill: rgba(240, 173, 46, 0.15); + stroke: #f0ad2e; + stroke-width: 2.5; +} + .tgu-2d-map__tooltip { position: absolute; z-index: 8; @@ -998,6 +1020,7 @@ } .tgu-map-requests-filter .tgu-field { + display: grid; grid-template-columns: 2rem minmax(0, 1fr); align-items: center; } @@ -1059,3 +1082,266 @@ background: rgba(192, 128, 255, 0.25); border-color: rgba(192, 128, 255, 0.85); } + +/* ── Requests-at-point panel ───────────────────────────── */ + +.tgu-map-content.has-requests-panel { + grid-template-columns: 19rem minmax(0, 1fr) 20rem; +} + +.tgu-map-requests-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-left: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-requests-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + overflow-y: auto; + background: var(--line-soft); +} + +.tgu-map-request-row { + display: flex; + gap: 0.65rem; + align-items: flex-start; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: var(--bg-panel); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.tgu-map-request-row:hover { + background: var(--bg-hover); +} + +.tgu-map-request-row.is-selected { + background: rgba(240, 173, 46, 0.1); + outline: 1px solid rgba(240, 173, 46, 0.4); + outline-offset: -1px; +} + +.tgu-map-request-row__type { + flex-shrink: 0; + align-self: center; + width: 1rem; + height: 0.4rem; + border-radius: 3px; + border: 1.5px dashed; +} + +.tgu-map-request-row__body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.tgu-map-request-row__name { + overflow: hidden; + color: var(--text); + font-size: 0.82rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-map-request-row__meta { + color: var(--text-dim); + font-size: 0.72rem; + line-height: 1.4; +} + +.tgu-map-request-details { + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + border-top: 1px solid var(--line); + padding: 0.75rem 0.9rem; +} + +.tgu-map-request-details .tgu-map-panel__label { + margin-bottom: 0.1rem; +} + +.tgu-map-request-details__grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.8rem; + margin: 0; +} + +.tgu-map-request-details__grid dt { + color: var(--text-dim); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.5; +} + +.tgu-map-request-details__grid dd { + overflow-wrap: anywhere; + margin: 0; + color: var(--text); + font-size: 0.8rem; + line-height: 1.5; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-requests-panel { + grid-template-columns: 1fr; + } + + .tgu-map-requests-panel { + max-height: 18rem; + border-left: 0; + border-top: 1px solid var(--line); + } +} + +/* ── Вкладка «Заявки» ───────────────────────────────────── */ + +.tgu-map-content.has-side-panel { + grid-template-columns: 19rem minmax(0, 1fr) 24rem; +} + +.tgu-map-overlay--hint { + border-color: rgba(104, 195, 189, 0.5); + color: #bdeee9; +} + +/* Черновой полигон при рисовании заявки */ +.tgu-2d-map__overlay .tgu-2d-map__draft-fill { + fill: rgba(240, 173, 46, 0.16); + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-line { + fill: none; + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-vertex { + fill: #f0ad2e; + stroke: #1a1c1e; + stroke-width: 1.5; +} + +/* Панель-форма создания заявки */ +.tgu-requests-form__body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.2rem; + overflow-y: auto; +} + +.tgu-requests-form__section { + display: grid; + gap: 0.5rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-requests-field { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 0.5rem; + align-items: center; + font-size: 0.8rem; +} + +.tgu-requests-field > span { + color: var(--text-dim); + font-size: 0.74rem; +} + +.tgu-requests-field input, +.tgu-requests-field select { + width: 100%; + min-height: 1.9rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.25rem 0.45rem; + background: #101214; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-field input:focus, +.tgu-requests-field select:focus { + outline: none; + border-color: rgba(104, 195, 189, 0.6); +} + +.tgu-requests-checkbox { + display: flex; + gap: 0.5rem; + align-items: center; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-checkbox input { + accent-color: var(--accent); +} + +.tgu-requests-form__block-toggle { + font-weight: 650; +} + +.tgu-requests-form__block { + display: grid; + gap: 0.5rem; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line-soft); + border-radius: 7px; + background: #111416; +} + +.tgu-requests-form__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-requests-form__hint { + color: var(--text-dim); + font-size: 0.75rem; + line-height: 1.4; +} + +.tgu-requests-form__geo-status { + color: var(--text); + font-size: 0.78rem; + font-weight: 650; +} + +.tgu-requests-form__footer { + padding: 0.75rem 0.9rem; + border-top: 1px solid var(--line); +} + +.tgu-requests-form__footer .tgu-button { + width: 100%; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-side-panel { + grid-template-columns: 1fr; + } +} From edd24d93ba7cc03a9b4e54a8fd37cfb66bbc800d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 1 Jun 2026 22:19:21 +0300 Subject: [PATCH 06/79] =?UTF-8?q?=D0=98=D0=B3=D0=BD=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D1=80=D0=BD?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B9=20node=5Fmodules=20(=D0=BA=D1=8D=D1=88?= =?UTF-8?q?=20Vite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 322804c..39d4a73 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ out/ /deploy/local-bundle/pcp-local-bundle-*.tar.gz ### Node / Vite ### +/node_modules/ /services/pcp-tgu-ops-ui/node_modules/ /services/pcp-tgu-ops-ui/dist/ /services/pcp-tgu-ops-ui/.cache/ From 96f698112b63b3317b897ad21585d8285b70f069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 11:04:38 +0300 Subject: [PATCH 07/79] =?UTF-8?q?=D0=97=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2:=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BC=D0=BE=D0=BD=D0=BE=D1=82=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20pcp-tgu-service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Цепочка планов теперь строит соседние планы со строго возрастающими стартом и концом, поэтому ни один план не может лежать целиком внутри другого. Нахлёст «на одну ЗРВ» считается по времени, а не по плоскому индексу кандидата — это убирает вложенность при нескольких станциях, видящих один пролёт одновременно (кейс Emissio). Подчистка устаревших PLANNED-планов при rebuild переведена на условие end_time > now, чтобы гасить и уже стартовавшие вложенные планы. --- .../repository/PlannedPlanRepository.kt | 6 +- .../service/PlanCalculationService.kt | 125 +++++++++++++----- .../service/RebuildPlanScheduleUseCase.kt | 4 +- .../service/PlanCalculationServiceTest.kt | 76 +++++++++-- .../service/RebuildPlanScheduleUseCaseTest.kt | 17 ++- 5 files changed, 179 insertions(+), 49 deletions(-) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt index c2a284f..c93c6df 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -24,11 +24,11 @@ interface PlannedPlanRepository : JpaRepository { /** Возвращает все планы в порядке старта. */ fun findAllByOrderByStartTime(): List - /** Возвращает будущие planned-планы КА. */ - fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + /** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */ + fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( spacecraftId: String, status: PlannedPlanStatus, - startTime: LocalDateTime + endTime: LocalDateTime ): List /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index d086947..f738466 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -9,32 +9,41 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import java.time.Duration import java.time.LocalDateTime +/** + * Строит цепочку планов по окнам видимости КА. + * + * Главный инвариант цепочки: у соседних планов строго возрастают и старт, и конец + * (`start[i] < start[i+1]` и `end[i] < end[i+1]`). Отсюда математически следует, что + * ни один план не может лежать целиком внутри другого. Перекрытие сохраняется: следующий + * план стартует на ЗРВ, попадающей внутрь предыдущего плана. + * + * Окна разных станций не сливаются — старт плана привязан к конкретной ЗРВ один к одному. + */ @Service class PlanCalculationService( private val planningProperties: PlanningProperties ) { - /** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */ - private val overlapShiftByWindows: Int = 1 - /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) - return calculatePlansFromCandidates( + return buildChain( spacecraftId = spacecraftPoints.spacecraftId, candidates = candidates, - initialStartIndex = 0 + fromStartIndex = 0, + initialLowerEndBound = null ) } /** - * Строит часть цепочки после уже известного плана. + * Строит часть цепочки после уже известного (принятого) плана. * * Если предыдущий план удалось сопоставить с текущими окнами наблюдения, - * следующий план начинается с нахлестом на одну ЗРВ. - * Если сопоставление не удалось, используется безопасный fallback без нахлеста. + * следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось, + * используется безопасный fallback без нахлеста. В обоих случаях конец первого нового + * плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой. */ fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ @@ -53,11 +62,12 @@ class PlanCalculationService( ?: -1 /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ - val initialStartIndex: Int = if ( + val fromStartIndex: Int = if ( previousStartIndex >= 0 && previousEndIndex > previousStartIndex ) { resolveNextStartIndex( + candidates = candidates, currentStartIndex = previousStartIndex, endIndex = previousEndIndex ) @@ -65,14 +75,15 @@ class PlanCalculationService( candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime } } - if (initialStartIndex == -1) { + if (fromStartIndex !in candidates.indices) { return emptyList() } - return calculatePlansFromCandidates( + return buildChain( spacecraftId = spacecraftPoints.spacecraftId, candidates = candidates, - initialStartIndex = initialStartIndex + fromStartIndex = fromStartIndex, + initialLowerEndBound = previousPlan.endTime ) } @@ -96,7 +107,8 @@ class PlanCalculationService( /** Индекс лучшего кандидата окончания для ближайшей закладки. */ val endIndex: Int = findEndCandidateIndexOrNull( candidates = candidates, - startIndex = nearestStartIndex + startIndex = nearestStartIndex, + lowerEndBound = null ) ?: return null return candidates[nearestStartIndex].toCalculatedPlan( @@ -105,37 +117,49 @@ class PlanCalculationService( ) } - /** Преобразует отсортированных кандидатов в последовательную цепочку планов. */ - private fun calculatePlansFromCandidates( + /** + * Строит последовательную цепочку планов, начиная с `fromStartIndex`. + * + * Поддерживает инвариант строгого роста старта и конца: следующий план обязан + * стартовать строго позже и заканчиваться строго позже предыдущего, поэтому ни один + * план не оказывается внутри другого. + */ + private fun buildChain( spacecraftId: String, candidates: List, - initialStartIndex: Int + fromStartIndex: Int, + initialLowerEndBound: LocalDateTime? ): List { - if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) { + if (candidates.isEmpty() || fromStartIndex !in candidates.indices || fromStartIndex >= candidates.lastIndex) { return emptyList() } /** Результирующая цепочка планов. */ val resultPlans: MutableList = mutableListOf() /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */ - var currentStartIndex: Int = initialStartIndex + var currentStartIndex: Int = fromStartIndex + /** Конец последнего добавленного плана — нижняя граница для конца следующего. */ + var lowerEndBound: LocalDateTime? = initialLowerEndBound while (currentStartIndex < candidates.lastIndex) { /** Текущий кандидат старта плана. */ val currentCandidate: StartCandidate = candidates[currentStartIndex] - /** Индекс найденного кандидата окончания плана. */ + /** Индекс найденного кандидата окончания плана со строго растущим концом. */ val endIndex: Int = findEndCandidateIndexOrNull( candidates = candidates, - startIndex = currentStartIndex + startIndex = currentStartIndex, + lowerEndBound = lowerEndBound ) ?: break resultPlans += currentCandidate.toCalculatedPlan( spacecraftId = spacecraftId, endTime = candidates[endIndex].endTime ) + lowerEndBound = candidates[endIndex].endTime /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */ val nextStartIndex: Int = resolveNextStartIndex( + candidates = candidates, currentStartIndex = currentStartIndex, endIndex = endIndex ) @@ -162,7 +186,7 @@ class PlanCalculationService( ) } } - .sortedBy { candidate -> candidate.startTime } + .sortedWith(compareBy({ candidate -> candidate.startTime }, { candidate -> candidate.endTime })) /** Возвращает целевую длительность плана из настроек. */ private fun targetDuration(): Duration = @@ -173,31 +197,62 @@ class PlanCalculationService( Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) /** - * Возвращает безопасный индекс старта следующего плана. + * Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ. * - * Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего. - * Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат, + * Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ, + * которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта. + * Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и + * ту же ЗРВ видят несколько станций одновременно. + * + * Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста), * чтобы не зациклиться и не повторить тот же самый план. */ - private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int = - maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows) + private fun resolveNextStartIndex( + candidates: List, + currentStartIndex: Int, + endIndex: Int + ): Int { + /** Время старта текущего плана. */ + val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime + /** Время старта конечной ЗРВ текущего плана. */ + val endStartTime: LocalDateTime = candidates[endIndex].startTime + + /** Самая поздняя ЗРВ строго внутри текущего плана — даёт одно окно нахлеста. */ + val overlappingIndex: Int = candidates.indices.lastOrNull { index -> + candidates[index].startTime > currentStartTime && + candidates[index].startTime < endStartTime + } ?: -1 + + if (overlappingIndex > currentStartIndex) { + return overlappingIndex + } + + /** Ближайший более поздний старт, если нахлест невозможен. */ + return candidates.indexOfFirst { candidate -> candidate.startTime > currentStartTime } + } /** * Находит лучший конечный кандидат для плана, начинающегося с `startIndex`. * - * Сначала сервис старается попасть в целевое окно `target ± delta`. - * Если это невозможно, он всё равно обязан построить план и берёт - * любой доступный конец, наиболее близкий к целевой длительности. + * Рассматриваются только концы строго позже старта плана и строго позже конца предыдущего + * плана (`lowerEndBound`) — это гарантирует строгий рост конца цепочки и отсутствие вложенности. + * Сначала сервис старается попасть в целевое окно `target ± delta`; если это невозможно, + * берётся любой допустимый конец, наиболее близкий к целевой длительности. */ private fun findEndCandidateIndexOrNull( candidates: List, - startIndex: Int + startIndex: Int, + lowerEndBound: LocalDateTime? ): Int? { /** Кандидат, с которого начинается рассматриваемый план. */ val startCandidate: StartCandidate = candidates[startIndex] - /** Все доступные кандидаты окончания после выбранного старта. */ + /** Допустимые кандидаты окончания: позже старта, позже предыдущего конца. */ val allEndCandidates: List> = candidates.withIndex() - .filter { indexedCandidate -> indexedCandidate.index > startIndex } + .filter { indexedCandidate -> + indexedCandidate.index > startIndex && + indexedCandidate.value.endTime > startCandidate.startTime && + (lowerEndBound == null || indexedCandidate.value.endTime > lowerEndBound) + } if (allEndCandidates.isEmpty()) { return null @@ -239,8 +294,8 @@ class PlanCalculationService( /** * Выбирает лучший конец по минимальному отклонению от целевой длительности. * - * При равной близости предпочтение отдаётся более длинному плану, - * чтобы не занижать результат без необходимости. + * При равной близости предпочтение отдаётся более длинному плану, а затем более раннему концу, + * чтобы выбор был детерминированным. */ private fun selectPreferredEndCandidate( candidates: List>, @@ -257,6 +312,8 @@ class PlanCalculationService( ) }.thenByDescending { candidate -> Duration.between(startTime, candidate.value.endTime) + }.thenBy { candidate -> + candidate.value.endTime } ) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt index dc6bfa0..b63953e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt @@ -87,10 +87,10 @@ class RebuildPlanScheduleUseCase( calculatedBusinessKeys: Set, now: LocalDateTime ) { - plannedPlanRepository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + plannedPlanRepository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( spacecraftId = spacecraftId, status = PlannedPlanStatus.PLANNED, - startTime = now + endTime = now ) .filter { plan -> plan.businessKey() !in calculatedBusinessKeys } .forEach { plan -> diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index 5bbba48..5ffa69d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -1,9 +1,11 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.domain.InsertionPoint import space.nstart.pcp_tgu_service.domain.ObservationWindow import space.nstart.pcp_tgu_service.domain.SpacecraftPoints @@ -29,16 +31,16 @@ class PlanCalculationServiceTest { ) ) - assertEquals(4, plans.size) + assertChainInvariant(plans) + assertEquals(3, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) assertEquals(start.plusHours(16), plans[1].startTime) - assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plans[1].endTime) + assertEquals(start.plusHours(40).plusMinutes(10), plans[1].endTime) assertEquals("KPP-3", plans[1].kppId) assertEquals(start.plusHours(32), plans[2].startTime) assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime) - assertEquals(start.plusHours(40), plans[3].startTime) - assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime) + assertEquals("KPP-6", plans[2].kppId) } @Test @@ -59,14 +61,15 @@ class PlanCalculationServiceTest { val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first()) - assertEquals(3, plansAfterFirst.size) + assertChainInvariant(plansAfterFirst) + assertEquals(2, plansAfterFirst.size) assertEquals(start.plusHours(16), plansAfterFirst[0].startTime) - assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plansAfterFirst[0].endTime) + assertEquals(start.plusHours(40).plusMinutes(10), plansAfterFirst[0].endTime) assertEquals("KPP-3", plansAfterFirst[0].kppId) assertEquals(start.plusHours(32), plansAfterFirst[1].startTime) assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime) - assertEquals(start.plusHours(40), plansAfterFirst[2].startTime) - assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[2].endTime) + // Хвост не должен заканчиваться раньше принятого базового плана. + assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime }) } @Test @@ -96,6 +99,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) @@ -135,6 +139,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusMinutes(55), plans[1].startTime) @@ -153,6 +158,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime) @@ -174,6 +180,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime) } @@ -192,12 +199,65 @@ class PlanCalculationServiceTest { val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray())) + assertChainInvariant(plans) assertTrue(plans.isNotEmpty()) assertTrue(plans.size <= simultaneousWindows.size) assertEquals(start, plans.first().startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime) } + @Test + fun `calculatePlans never nests plans when several stations see the same passes (Emissio case)`() { + // Прод-конфигурация: цель 3 часа, допуск 1 час. + val service = createService(targetMinutes = 180L, deltaMinutes = 60L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + // Пролёты примерно раз в час, каждый виден тремя станциями почти одновременно + // (это и есть корень вложенности у Emissio): одинаковые/близкие старты, дублирующиеся ЗРВ. + val stations = listOf("ST-A", "ST-B", "ST-C") + val windows = buildList { + for (passIndex in 0..8) { + stations.forEachIndexed { stationOffset, stationId -> + add( + window( + pointId = "$stationId-$passIndex", + startTime = start.plusHours(passIndex.toLong()).plusMinutes(stationOffset.toLong()) + ) + ) + } + } + } + + val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray())) + + assertChainInvariant(plans) + assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}") + } + + /** + * Проверяет главный инвариант цепочки: старт и конец строго возрастают, и ни один план + * не лежит целиком внутри другого. + */ + private fun assertChainInvariant(plans: List) { + for (index in 1 until plans.size) { + assertTrue( + plans[index].startTime > plans[index - 1].startTime, + "start must strictly increase at index $index: $plans" + ) + assertTrue( + plans[index].endTime > plans[index - 1].endTime, + "end must strictly increase at index $index: $plans" + ) + } + for (outer in plans.indices) { + for (inner in plans.indices) { + if (outer == inner) continue + val contains = plans[outer].startTime <= plans[inner].startTime && + plans[inner].endTime <= plans[outer].endTime + assertFalse(contains, "plan $inner must not be nested in plan $outer: $plans") + } + } + } + private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService = PlanCalculationService( PlanningProperties( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt index 3b3beea..11ebc40 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt @@ -27,6 +27,17 @@ class RebuildPlanScheduleUseCaseTest { fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { val spacecraftId = "25544" val planStore = linkedMapOf() + // Устаревший PLANNED-план, который уже стартовал, но ещё не завершился — + // должен быть погашен по новому условию end_time > now (а не start_time > now). + val stalePlanId = UUID.randomUUID() + planStore[stalePlanId] = PlannedPlanEntity( + planId = stalePlanId, + spacecraftId = spacecraftId, + startTime = LocalDateTime.now().minusHours(1), + endTime = LocalDateTime.now().plusHours(1), + kppId = "KPP-STALE", + status = PlannedPlanStatus.PLANNED + ) val plannedRepository = plannedRepository(planStore) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) val stateRepository = stateRepository(state) @@ -58,6 +69,8 @@ class RebuildPlanScheduleUseCaseTest { service.rebuild(spacecraftId) val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId + // Устаревший активный план без места в новой цепочке погашен уже на первом rebuild. + assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.getValue(stalePlanId).status) `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) service.rebuild(spacecraftId) @@ -140,13 +153,13 @@ class RebuildPlanScheduleUseCaseTest { store[plan.planId] = plan plan } - `when`(repository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) + `when`(repository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) .thenAnswer { invocation -> store.values .filter { plan -> plan.spacecraftId == invocation.arguments[0] && plan.status == invocation.arguments[1] && - plan.startTime > invocation.arguments[2] as LocalDateTime + plan.endTime > invocation.arguments[2] as LocalDateTime } .sortedBy { plan -> plan.startTime } } From bd59204fcf56578d1643f00e1ea13272f1124dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 11:04:45 +0300 Subject: [PATCH 08/79] =?UTF-8?q?=D0=9D=D0=B5=D0=B7=D0=B0=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B8=D1=87=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=B0:=20UI=20=D0=B7=D0=B0=D1=8F=D0=B2?= =?UTF-8?q?=D0=BE=D0=BA/=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pcp-tgu-ops-ui: вкладка «Заявки», редактор планирования, утилиты uuid и e2e-verify скрипты, обновление зависимостей - pcp-ballistics-service, pcp-mission-planing-service: текущие правки --- .../controller/SatelliteController.kt | 2 +- .../PcpMissionPlaningServiceApplication.kt | 6 + services/pcp-tgu-ops-ui/e2e-verify/editor.mjs | 60 +++++ .../pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs | 79 ++++++ .../pcp-tgu-ops-ui/e2e-verify/requests.mjs | 225 ++++++++++++++++++ services/pcp-tgu-ops-ui/package-lock.json | 45 ++++ services/pcp-tgu-ops-ui/package.json | 7 +- .../features/tgu-planning/TguPlanningPage.tsx | 68 ++++-- .../tgu-requests/RequestDetailsPanel.tsx | 57 +++-- .../features/tgu-requests/TguRequestsTab.tsx | 3 +- .../src/styles/tgu-planning.css | 21 ++ .../pcp-tgu-ops-ui/src/utils/uuid.test.ts | 20 ++ services/pcp-tgu-ops-ui/src/utils/uuid.ts | 35 +++ 13 files changed, 588 insertions(+), 40 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/e2e-verify/editor.mjs create mode 100644 services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs create mode 100644 services/pcp-tgu-ops-ui/e2e-verify/requests.mjs create mode 100644 services/pcp-tgu-ops-ui/src/utils/uuid.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/utils/uuid.ts diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt index be2be88..cf99c62 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt @@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService import space.nstart.pcp.pcp_request_service.service.SatelliteService -import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import java.time.LocalDateTime diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt index 22aaa4e..c439a39 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt @@ -2,10 +2,16 @@ package space.nstart.pcp.pcp_mission_planing_service import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.context.annotation.Import import org.springframework.kafka.annotation.EnableKafka +import space.nstart.pcp.pcp_types_lib.configuration.GlobalExceptionHandler @EnableKafka @SpringBootApplication +// GlobalExceptionHandler лежит в пакете pcp-types-lib вне базового component-scan этого сервиса, +// поэтому регистрируем @ControllerAdvice явно — иначе CustomErrorException/CustomValidationException +// отдаются дефолтным WebFlux-хендлером как голый 500 без тела с сообщением. +@Import(GlobalExceptionHandler::class) class PcpMissionPlaningServiceApplication fun main(args: Array) { diff --git a/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs new file mode 100644 index 0000000..e5a1271 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs @@ -0,0 +1,60 @@ +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const p = await (await b.newContext({ viewport: { width: 1600, height: 900 } })).newPage(); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(800); + + // выбрать КА 56756 и открыть редактор + await p.getByText("NORAD 56756", { exact: false }).first().click(); + await p.waitForTimeout(500); + await p.getByRole("tab", { name: "Редактор", exact: true }).click(); + await p.waitForTimeout(700); + + // контролы редактора + const shoot = p.getByRole("button", { name: "+ Съёмка", exact: true }); + const drop = p.getByRole("button", { name: "+ Сброс", exact: true }); + (await shoot.count()) > 0 && (await drop.count()) > 0 + ? pass("editor:controls", "+ Съёмка / + Сброс присутствуют") + : fail("editor:controls"); + + // режим «Сброс» -> ЗРВ реальных станций + await drop.click(); + const rva = await p.waitForResponse((r) => /\/api\/satellites\/\d+\/rva/.test(r.url()), { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(1000); + rva && rva.status() === 200 + ? pass("editor:drop-fetches-rva", `GET rva ${rva.status()}`) + : fail("editor:drop-fetches-rva", rva ? `status ${rva.status()}` : "не вызван"); + + const panelText = (await p.locator("aside, [class*='panel']").allInnerTexts().catch(() => [])).join(" ").replace(/\n+/g, " "); + const stationNames = ["Шпицберген", "Отрадное", "Байконур", "Ханты-Мансийск"]; + const found = stationNames.filter((s) => panelText.includes(s)); + found.length >= 2 + ? pass("editor:drop-real-stations", `зоны по станциям: ${found.join(", ")}`) + : fail("editor:drop-real-stations", `найдено станций: ${JSON.stringify(found)}`); + // зоны содержат виток + время (UTC-окно) + /виток\s*\d+/i.test(panelText) && /\d{2}:\d{2}[–-]\d{2}:\d{2}/.test(panelText) + ? pass("editor:drop-zone-format", "виток + временное окно показаны") + : fail("editor:drop-zone-format", panelText.slice(0, 200)); + + consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); +} catch (e) { + fail("SCRIPT", e.message); + console.log(e.stack); +} finally { + const npass = results.filter((r) => r[0] === "PASS").length; + const nfail = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== EDITOR SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); + await b.close(); + process.exit(nfail > 0 ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs new file mode 100644 index 0000000..49fd206 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs @@ -0,0 +1,79 @@ +// Проверка keep-alive: состояние вкладок не сбрасывается при переключении. +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); +const p = await ctx.newPage(); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); + +const tab = (name) => p.getByRole("tab", { name, exact: true }); +// только видимая (активная) вкладка — карта присутствует и в Заявках (рисование полигона) +const mapToggle = (label) => p.locator(".tgu-map-toolbar__toggles button:visible", { hasText: label }); +const isActive = async (loc) => ((await loc.getAttribute("class")) || "").includes("is-active"); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(600); + + // --- Карта: меняем UI-состояние (выключаем слой «Станции») --- + await tab("Карта").click(); + await p.waitForTimeout(500); + const stationsToggle = mapToggle("Станции"); + (await isActive(stationsToggle)) ? pass("map:stations-on-initially") : fail("map:stations-on-initially"); + await stationsToggle.click(); + await p.waitForTimeout(150); + (await isActive(stationsToggle)) === false + ? pass("map:stations-toggled-off") + : fail("map:stations-toggled-off"); + + // --- Заявки: открываем форму создания (внутреннее состояние panelMode) --- + await tab("Заявки").click(); + await p.waitForTimeout(400); + await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click(); + await p.waitForTimeout(250); + (await p.locator(".tgu-requests-form").count()) > 0 + ? pass("requests:create-form-open") + : fail("requests:create-form-open"); + + // --- уходим на Планы и возвращаемся --- + await tab("Планы").click(); + await p.waitForTimeout(300); + + // Карта: слой «Станции» по-прежнему выключен? + await tab("Карта").click(); + await p.waitForTimeout(400); + (await isActive(mapToggle("Станции"))) === false + ? pass("map:state-preserved", "слой «Станции» остался выключен") + : fail("map:state-preserved", "состояние слоёв сбросилось"); + + // Заявки: форма создания всё ещё открыта? + await tab("Заявки").click(); + await p.waitForTimeout(400); + (await p.locator(".tgu-requests-form").count()) > 0 + ? pass("requests:state-preserved", "форма создания осталась открытой") + : fail("requests:state-preserved", "форма закрылась (состояние сброшено)"); + + // Скрытые вкладки не должны размонтироваться: в DOM присутствуют сразу несколько
+ await tab("Планы").click(); + await p.waitForTimeout(200); + const mains = await p.locator(".tgu-app-shell main").count(); + mains >= 3 + ? pass("keep-alive:tabs-mounted", `
в DOM: ${mains}`) + : fail("keep-alive:tabs-mounted", `ожидалось >=3, найдено ${mains}`); + +} catch (e) { + fail("EXCEPTION", e.message); +} finally { + if (consoleErrors.length) console.log("CONSOLE_ERRORS", JSON.stringify(consoleErrors, null, 2)); + const failed = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== ${results.length - failed}/${results.length} PASS, ${failed} FAIL ===`); + await b.close(); + process.exit(failed ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs new file mode 100644 index 0000000..2652dd8 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs @@ -0,0 +1,225 @@ +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); +const p = await ctx.newPage(); + +const api = []; +p.on("request", (r) => { if (r.url().includes("/api/")) api.push({ m: r.method(), u: r.url() }); }); +const apiResp = []; +p.on("response", (r) => { if (r.url().includes("/api/")) apiResp.push({ m: r.request().method(), u: r.url(), s: r.status() }); }); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); +// авто-принятие window.confirm (для удаления) +p.on("dialog", (d) => d.accept()); + +const tab = (name) => p.getByRole("tab", { name, exact: true }); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(800); + + // ---- A: 4 вкладки ---- + for (const t of ["Планы", "Карта", "Редактор", "Заявки"]) { + (await tab(t).count()) > 0 ? pass(`tab:${t}`) : fail(`tab:${t}`, "не найдена"); + } + + // ---- B: Карта — дефолтные слои ---- + await tab("Карта").click(); + await p.waitForTimeout(600); + const toggles = p.locator(".tgu-map-toolbar__toggles button"); + const n = await toggles.count(); + const labels = []; + for (let i = 0; i < n; i++) { + const el = toggles.nth(i); + labels.push({ t: (await el.innerText()).trim(), active: (await el.getAttribute("class") || "").includes("is-active") }); + } + console.log("LAYER_TOGGLES", JSON.stringify(labels)); + const wanted = ["Работы плана", "Станции", "Заявки на съёмку"]; + const got = labels.map((l) => l.t); + JSON.stringify(got) === JSON.stringify(wanted) + ? pass("map:layers-picker", "ровно planWorks/stations/requests") + : fail("map:layers-picker", `got=${JSON.stringify(got)}`); + labels.every((l) => l.active) + ? pass("map:layers-default-on", "все 3 активны") + : fail("map:layers-default-on", JSON.stringify(labels)); + const forbidden = got.some((t) => /трек|track|полос|swath|маркер|spacecraft/i.test(t)); + forbidden ? fail("map:no-tracks-swath-markers", "найден лишний слой") : pass("map:no-tracks-swath-markers"); + + // ---- C: Заявки — загрузка ---- + await tab("Заявки").click(); + await p.waitForTimeout(400); + const before = apiResp.length; + await p.getByRole("button", { name: "Загрузить", exact: true }).click(); + await p.waitForResponse((r) => r.url().includes("/v1/requests/map"), { timeout: 15000 }).catch(() => {}); + await p.waitForTimeout(800); + const mapCall = apiResp.find((r) => r.u.includes("/v1/requests/map")); + mapCall && mapCall.s === 200 ? pass("requests:load", `GET map ${mapCall.s}`) : fail("requests:load", JSON.stringify(mapCall)); + const subtitle = await p.locator(".tgu-map-toolbar__subtitle").first().innerText().catch(() => ""); + console.log("REQUESTS_SUBTITLE", subtitle); + + // ---- D: создание + валидации ---- + await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click(); + await p.waitForTimeout(300); + const form = p.locator(".tgu-requests-form"); + const submitBtn = form.getByRole("button", { name: "Создать заявку", exact: true }); + const alert = form.locator(".tgu-alert--danger"); + + // d1: до рисования — кнопка disabled, подсказка про полигон + (await submitBtn.isDisabled()) ? pass("create:disabled-before-draw") : fail("create:disabled-before-draw"); + console.log("ALERT_INITIAL", await alert.innerText().catch(() => "(нет)")); + + // d2: рисуем полигон 3 кликами по карте + // :visible — keep-alive держит смонтированными карты сразу нескольких вкладок + const map = p.locator(".tgu-2d-map:visible"); + const box = await map.boundingBox(); + const pts = [ + { x: box.width * 0.45, y: box.height * 0.40 }, + { x: box.width * 0.60, y: box.height * 0.40 }, + { x: box.width * 0.52, y: box.height * 0.55 } + ]; + for (const pt of pts) { await map.click({ position: pt }); await p.waitForTimeout(150); } + const vtext = await form.locator(".tgu-requests-form__geo-status").innerText().catch(() => ""); + console.log("VERTEX_TEXT", vtext); + /Вершин:\s*3/.test(vtext) ? pass("create:3-vertices") : fail("create:3-vertices", vtext); + await form.getByRole("button", { name: "Завершить", exact: true }).click(); + await p.waitForTimeout(300); + + // d3: имя пустое -> валидация + let a = await alert.innerText().catch(() => ""); + /название/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:empty-name", a) : fail("validate:empty-name", a); + + // заполняем имя + await form.getByPlaceholder("Название задания").fill("E2E-VERIFY"); + await p.waitForTimeout(200); + + // d4: оба блока выключены + const opticsCb = form.locator("label.tgu-requests-form__block-toggle", { hasText: "Оптическая" }).getByRole("checkbox"); + await opticsCb.uncheck(); + await p.waitForTimeout(200); + a = await alert.innerText().catch(() => ""); + /хотя бы один блок/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:no-optics-no-rsa", a) : fail("validate:no-optics-no-rsa", a); + await opticsCb.check(); + await p.waitForTimeout(200); + + // d5: from >= to + const begin = form.locator('input[type="datetime-local"]').nth(0); + const end = form.locator('input[type="datetime-local"]').nth(1); + const beginVal = await begin.inputValue(); + await end.fill("2000-01-01T00:00"); + await p.waitForTimeout(200); + a = await alert.innerText().catch(() => ""); + /раньше конца/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:from-after-to", a) : fail("validate:from-after-to", a); + // восстановить корректный конец + await end.fill("2100-01-01T00:00"); + await p.waitForTimeout(200); + + // d6: submit -> POST 201 + новая заявка + (await submitBtn.isEnabled()) ? pass("create:enabled-when-valid") : fail("create:enabled-when-valid"); + const postBefore = apiResp.length; + await submitBtn.click(); + const postResp = await p.waitForResponse((r) => r.url().includes("/v1/requests") && r.request().method() === "POST", { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(1000); + if (postResp && (postResp.status() === 201 || postResp.status() === 200)) { + pass("create:POST", `status ${postResp.status()}`); + // проверим тело запроса: WKT lon lat + id + const reqBody = postResp.request().postDataJSON?.() || JSON.parse(postResp.request().postData() || "{}"); + const wktOk = /POLYGON\s*\(\(/.test(reqBody.geometry || ""); + const idOk = /^[0-9a-f-]{36}$/i.test(reqBody.id || ""); + wktOk && idOk ? pass("create:payload", `wkt+uuid ok`) : fail("create:payload", JSON.stringify({ id: reqBody.id, geo: (reqBody.geometry||"").slice(0,30) })); + console.log("CREATE_WKT", (reqBody.geometry || "").slice(0, 80)); + } else { + fail("create:POST", postResp ? `status ${postResp.status()}` : "нет ответа"); + } + const noticeTxt = await p.locator(".tgu-alert--success").innerText().catch(() => ""); + /создана/i.test(noticeTxt) ? pass("create:notice", noticeTxt) : fail("create:notice", noticeTxt || "(нет)"); + + // ---- E: детали + удаление ---- + // клик по нарисованному полигону -> панель деталей. Свипаем точки внутри треугольника, + // т.к. точное соответствие пиксель↔гео зависит от проекции. + const delBtn = p.getByRole("button", { name: /Удалить заявку/, exact: false }); + const rows = p.locator(".tgu-map-request-row"); + const detailsHeading = p.locator("aside .tgu-panel-heading span", { hasText: "Заявки в точке" }); + const tip = p.locator(".tgu-2d-map__tooltip"); + // hover-свип: ищем пиксель, где появляется тултип нашей заявки (надёжно к проекции/виду) + const mbox = await map.boundingBox(); + let target = null; + outer: + for (let gx = 0.40; gx <= 0.65; gx += 0.02) { + for (let gy = 0.35; gy <= 0.58; gy += 0.02) { + await p.mouse.move(mbox.x + mbox.width * gx, mbox.y + mbox.height * gy); + await p.waitForTimeout(25); + if ((await tip.count()) > 0 && /E2E-VERIFY/.test(await tip.innerText().catch(() => ""))) { + target = { x: mbox.width * gx, y: mbox.height * gy }; + break outer; + } + } + } + let opened = false; + if (target) { + await map.click({ position: target }); + await p.waitForTimeout(400); + if ((await delBtn.count()) > 0 || (await detailsHeading.count()) > 0) opened = true; + } + if (opened) { + // если в точке несколько заявок — выбрать строку, чтобы появилась кнопка удаления + if ((await delBtn.count()) === 0 && (await rows.count()) > 0) { + await rows.first().click(); + await p.waitForTimeout(300); + } + pass("details:open", `панель деталей открылась (строк в точке: ${await rows.count() || 1})`); + + const initialDel = p.getByRole("button", { name: "Удалить заявку", exact: true }); + const confirmTitle = p.locator(".tgu-requests-confirm__title", { hasText: "Удалить заявку?" }); + const yesBtn = p.getByRole("button", { name: "Да, удалить", exact: true }); + const noBtn = p.getByRole("button", { name: "Нет, не удалять", exact: true }); + + // шаг 1: клик «Удалить заявку» -> появляется панель подтверждения с Да/Нет + await initialDel.first().click(); + await p.waitForTimeout(250); + (await confirmTitle.count()) > 0 && (await yesBtn.count()) > 0 && (await noBtn.count()) > 0 + ? pass("delete:confirm-panel", "панель «Удалить заявку?» с «Да, удалить»/«Нет, не удалять»") + : fail("delete:confirm-panel", `title=${await confirmTitle.count()} yes=${await yesBtn.count()} no=${await noBtn.count()}`); + + // «Нет, не удалять» — не шлёт DELETE и закрывает подтверждение + const delCallsBefore = apiResp.filter((r) => r.m === "DELETE").length; + await noBtn.click(); + await p.waitForTimeout(300); + (await confirmTitle.count()) === 0 && apiResp.filter((r) => r.m === "DELETE").length === delCallsBefore + ? pass("delete:cancel", "«Нет, не удалять» отменяет без DELETE") + : fail("delete:cancel"); + + // «Да, удалить» -> DELETE + await initialDel.first().click(); + await p.waitForTimeout(200); + await yesBtn.click(); + const delResp = await p.waitForResponse((r) => r.url().includes("/v1/requests/") && r.request().method() === "DELETE", { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(800); + delResp && (delResp.status() === 200 || delResp.status() === 204) + ? pass("delete:DELETE", `status ${delResp.status()}`) + : fail("delete:DELETE", delResp ? `status ${delResp.status()}` : "нет DELETE-запроса"); + } else { + fail("details:open", "панель деталей не открылась (не попал по полигону)"); + } + + // ---- console errors ---- + consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); + +} catch (e) { + fail("SCRIPT", e.message); + console.log(e.stack); +} finally { + console.log("\n=== API CALLS (последние 25) ==="); + for (const r of apiResp.slice(-25)) console.log(r.s, r.m, r.u.replace("http://localhost:5174", "")); + const npass = results.filter((r) => r[0] === "PASS").length; + const nfail = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); + await b.close(); + process.exit(nfail > 0 ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/package-lock.json b/services/pcp-tgu-ops-ui/package-lock.json index 62a92dc..c0cd25c 100644 --- a/services/pcp-tgu-ops-ui/package-lock.json +++ b/services/pcp-tgu-ops-ui/package-lock.json @@ -18,6 +18,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "jsdom": "^27.2.0", + "playwright": "^1.60.0", "vitest": "^4.0.14" } }, @@ -1937,6 +1938,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/services/pcp-tgu-ops-ui/package.json b/services/pcp-tgu-ops-ui/package.json index 88418f0..8dde952 100644 --- a/services/pcp-tgu-ops-ui/package.json +++ b/services/pcp-tgu-ops-ui/package.json @@ -11,15 +11,16 @@ }, "dependencies": { "@vitejs/plugin-react": "^5.1.1", - "vite": "^7.2.4", - "typescript": "^5.9.3", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "typescript": "^5.9.3", + "vite": "^7.2.4" }, "devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "jsdom": "^27.2.0", + "playwright": "^1.60.0", "vitest": "^4.0.14" } } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index f3db83f..cd446e0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -28,6 +28,11 @@ export function TguPlanningPage() { const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); const [activeTab, setActiveTab] = useState("timeline"); + // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии + // и дальше держим смонтированной (скрытой через display:none), чтобы её + // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось + // при переключении между вкладками. + const [visitedTabs, setVisitedTabs] = useState>(() => new Set(["timeline"])); const [decisionInFlight, setDecisionInFlight] = useState(false); const [decisionNotice, setDecisionNotice] = useState(); const [decisionError, setDecisionError] = useState(); @@ -80,6 +85,15 @@ export function TguPlanningPage() { void loadData(appliedRange); }, [appliedRange, loadData]); + useEffect(() => { + setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab))); + }, [activeTab]); + + // display:contents для активной вкладки сохраняет grid-раскладку shell + // (внутренний
остаётся прямым grid-элементом), display:none скрывает остальные. + const tabStyle = (tab: TguActiveTab) => + ({ display: activeTab === tab ? "contents" : "none" }) as const; + const platforms = useMemo( () => buildPlatformsForInterval(rawPlatforms, plans, appliedRange), [rawPlatforms, plans, appliedRange] @@ -152,7 +166,7 @@ export function TguPlanningPage() { /> } > - {activeTab === "timeline" ? ( +
setSelectedPlanId(undefined)} />
- ) : activeTab === "map" ? ( - - ) : activeTab === "requests" ? ( - - ) : ( - +
+ + {visitedTabs.has("map") && ( +
+ +
+ )} + + {visitedTabs.has("requests") && ( +
+ +
+ )} + + {visitedTabs.has("editor") && ( +
+ +
)} ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx index 1f19094..d50252a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; type Props = { @@ -27,6 +28,13 @@ export function RequestDetailsPanel({ ? requests[0] : undefined; + // Второй шаг подтверждения удаления. Сбрасываем при смене выбранной заявки, + // чтобы открытый запрос на удаление не «переезжал» на другую заявку. + const [confirmingDelete, setConfirmingDelete] = useState(false); + useEffect(() => { + setConfirmingDelete(false); + }, [selectedEntry?.item.id]); + return ( diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx index 261814e..62b15de 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -8,6 +8,7 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; +import { generateUuid } from "../../utils/uuid"; import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; import { RequestDetailsPanel } from "./RequestDetailsPanel"; import { polygonToWkt } from "./requestGeometry"; @@ -195,7 +196,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa setCreating(true); setCreateError(undefined); try { - const id = crypto.randomUUID(); + const id = generateUuid(); await createRequest({ id, name: form.name, diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 40e9ae7..1152dd2 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -1319,6 +1319,27 @@ gap: 0.35rem; } +.tgu-requests-confirm { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 0.6rem; + border: 1px solid rgba(229, 115, 115, 0.45); + border-radius: 6px; + background: rgba(229, 115, 115, 0.08); +} + +.tgu-requests-confirm__title { + font-weight: 600; + color: var(--text); +} + +.tgu-requests-confirm__subtitle { + color: var(--text-dim); + font-size: 0.8rem; + word-break: break-word; +} + .tgu-requests-form__hint { color: var(--text-dim); font-size: 0.75rem; diff --git a/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts new file mode 100644 index 0000000..a4d4f4c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { generateUuid, uuidV4Fallback } from "./uuid"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe("generateUuid", () => { + it("возвращает валидный UUID v4", () => { + expect(generateUuid()).toMatch(UUID_V4); + }); +}); + +describe("uuidV4Fallback (insecure context)", () => { + it("даёт валидный UUID v4 без crypto.randomUUID", () => { + expect(uuidV4Fallback()).toMatch(UUID_V4); + }); + + it("генерирует разные значения", () => { + expect(uuidV4Fallback()).not.toBe(uuidV4Fallback()); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/utils/uuid.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.ts new file mode 100644 index 0000000..0bd9dd9 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/uuid.ts @@ -0,0 +1,35 @@ +/** + * Генерация UUID v4. + * + * `crypto.randomUUID()` доступен только в secure context (https или localhost), + * поэтому при открытии фронта по сетевому адресу (http://:5174) он отсутствует + * и падает с «crypto.randomUUID is not a function». `crypto.getRandomValues`, наоборот, + * доступен и в insecure context — используем его как фолбэк (RFC 4122 v4). + */ +export function generateUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return uuidV4Fallback(); +} + +/** RFC 4122 v4 на основе crypto.getRandomValues (работает в insecure context). */ +export function uuidV4Fallback(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // версия 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); +} From e8f253ac6a97e71d3b6dc00bd95c334fcb100bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 11:18:02 +0300 Subject: [PATCH 09/79] =?UTF-8?q?=D0=93=D0=B0=D1=80=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=D1=82=D1=80=D0=BE=D0=B3=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=85=D0=BB=D1=91=D1=81=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2=20=D0=B1=D0=B5=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=80=D1=8B=D0=B2=D0=BE=D0=B2=20=D0=B2=20pcp-tgu-s?= =?UTF-8?q?ervice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старт следующего плана всегда выбирается строго внутри текущего плана (нахлёст по времени), а раньше был fallback на ближайший более поздний старт, который мог дать разрыв. Если ни одна ЗРВ не начинается внутри текущего плана — цепочка обрывается, но разрыв между планами не создаётся. calculatePlansAfter тоже стартует хвост строго внутри принятого плана. Инвариант цепочки в тестах усилен проверкой строгого нахлёста; добавлен тест на провал видимости. --- .../service/PlanCalculationService.kt | 70 ++++++++++++------- .../service/PlanCalculationServiceTest.kt | 30 +++++++- 2 files changed, 73 insertions(+), 27 deletions(-) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index f738466..93f593d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -40,10 +40,10 @@ class PlanCalculationService( /** * Строит часть цепочки после уже известного (принятого) плана. * - * Если предыдущий план удалось сопоставить с текущими окнами наблюдения, - * следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось, - * используется безопасный fallback без нахлеста. В обоих случаях конец первого нового - * плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой. + * Первый новый план обязан стартовать строго внутри принятого плана (нахлёст без разрыва) + * и заканчиваться строго позже его конца (без вложенности). Если предыдущий план удалось + * сопоставить с текущими окнами — продолжаем с нахлестом на одну ЗРВ; иначе берём последнюю + * ЗРВ строго внутри принятого плана. Разрыв между принятым планом и хвостом не допускается. */ fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ @@ -61,18 +61,27 @@ class PlanCalculationService( ?.index ?: -1 - /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ - val fromStartIndex: Int = if ( - previousStartIndex >= 0 && - previousEndIndex > previousStartIndex - ) { + /** Старт хвоста при удачном сопоставлении базового плана с текущими окнами. */ + val matchedStartIndex: Int = if (previousStartIndex >= 0 && previousEndIndex > previousStartIndex) { resolveNextStartIndex( candidates = candidates, currentStartIndex = previousStartIndex, endIndex = previousEndIndex ) } else { - candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime } + previousStartIndex + } + + /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ + val fromStartIndex: Int = if (matchedStartIndex > previousStartIndex) { + matchedStartIndex + } else { + // База не сопоставилась или нахлёст по её окнам невозможен: стартуем с последней ЗРВ + // строго внутри принятого плана — нахлёст с базой без разрыва и без вложенности. + candidates.indices.lastOrNull { index -> + candidates[index].startTime > previousPlan.startTime && + candidates[index].startTime < previousPlan.endTime + } ?: -1 } if (fromStartIndex !in candidates.indices) { @@ -197,15 +206,16 @@ class PlanCalculationService( Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) /** - * Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ. + * Возвращает индекс старта следующего плана так, чтобы он гарантированно стартовал + * строго внутри текущего плана (нахлёст без разрыва). * - * Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ, - * которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта. - * Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и - * ту же ЗРВ видят несколько станций одновременно. + * Сначала пробуем нахлёст на одну ЗРВ: самая поздняя ЗРВ строго раньше конечной ЗРВ + * текущего плана. Если такой нет — берём самую позднюю ЗРВ, начинающуюся строго раньше + * конца текущего плана (нахлёст всё равно сохраняется). Нахлёст считается по времени, а не + * по плоскому индексу, поэтому устойчив к тому, что одну ЗРВ видят несколько станций сразу. * - * Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста), - * чтобы не зациклиться и не повторить тот же самый план. + * Если ни одна ЗРВ не начинается внутри текущего плана, продолжить цепочку без разрыва + * невозможно — возвращаем `currentStartIndex`, и вызывающий обрывает цепочку. */ private fun resolveNextStartIndex( candidates: List, @@ -214,21 +224,31 @@ class PlanCalculationService( ): Int { /** Время старта текущего плана. */ val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime + /** Время конца текущего плана. */ + val currentEndTime: LocalDateTime = candidates[endIndex].endTime /** Время старта конечной ЗРВ текущего плана. */ val endStartTime: LocalDateTime = candidates[endIndex].startTime - /** Самая поздняя ЗРВ строго внутри текущего плана — даёт одно окно нахлеста. */ - val overlappingIndex: Int = candidates.indices.lastOrNull { index -> + /** Предпочтительный нахлёст: последняя ЗРВ строго перед конечной ЗРВ. */ + val oneWindowOverlap: Int? = candidates.indices.lastOrNull { index -> candidates[index].startTime > currentStartTime && candidates[index].startTime < endStartTime - } ?: -1 - - if (overlappingIndex > currentStartIndex) { - return overlappingIndex + } + if (oneWindowOverlap != null) { + return oneWindowOverlap } - /** Ближайший более поздний старт, если нахлест невозможен. */ - return candidates.indexOfFirst { candidate -> candidate.startTime > currentStartTime } + /** Запасной нахлёст: последняя ЗРВ, начинающаяся строго раньше конца текущего плана. */ + val anyOverlap: Int? = candidates.indices.lastOrNull { index -> + candidates[index].startTime > currentStartTime && + candidates[index].startTime < currentEndTime + } + if (anyOverlap != null) { + return anyOverlap + } + + // Нахлёст невозможен — обрываем цепочку, чтобы не было разрыва между планами. + return currentStartIndex } /** diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index 5ffa69d..a6474a0 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -233,9 +233,31 @@ class PlanCalculationServiceTest { assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}") } + @Test + fun `calculatePlans never leaves a gap between plans across a visibility gap`() { + val service = createService(targetMinutes = 180L, deltaMinutes = 60L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + // Плотный кластер окон, затем большой провал видимости, затем далёкий кластер. + // Провал допустимо перекрыть одним длинным планом, но разрыва между планами быть не должно. + val windows = buildList { + for (minutes in 0..240 step 20) { + add(window("NEAR-$minutes", start.plusMinutes(minutes.toLong()))) + } + for (minutes in 0..120 step 20) { + add(window("FAR-$minutes", start.plusDays(3).plusMinutes(minutes.toLong()))) + } + } + + val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray())) + + // Главное требование: каждый следующий план стартует строго внутри предыдущего (без разрыва). + assertChainInvariant(plans) + assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}") + } + /** - * Проверяет главный инвариант цепочки: старт и конец строго возрастают, и ни один план - * не лежит целиком внутри другого. + * Проверяет главный инвариант цепочки: старт и конец строго возрастают, соседние планы + * строго перекрываются (без разрыва), и ни один план не лежит целиком внутри другого. */ private fun assertChainInvariant(plans: List) { for (index in 1 until plans.size) { @@ -247,6 +269,10 @@ class PlanCalculationServiceTest { plans[index].endTime > plans[index - 1].endTime, "end must strictly increase at index $index: $plans" ) + assertTrue( + plans[index].startTime < plans[index - 1].endTime, + "plans must strictly overlap (no gap) at index $index: $plans" + ) } for (outer in plans.indices) { for (inner in plans.indices) { From e0aa1ccf0e15bf029a0fde53bb48da3aeb0c2b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 11:25:58 +0300 Subject: [PATCH 10/79] =?UTF-8?q?=D0=A2=D0=B0=D0=B9=D0=BC=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20=D0=A2=D0=93=D0=A3:=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше таймлайн рисовал планы всех статусов, включая историю версий (SUPERSEDED/EXPIRED/REJECTED), из-за чего активная цепочка тонула в наложении старых планов. Теперь по умолчанию показываются только актуальные планы (PLANNED/ISSUING/WAITING_DECISION/ACCEPTED), а историю версий можно включить чекбоксом над таймлайном. --- .../features/tgu-planning/TguPlanningPage.tsx | 23 +++++++++++++++++-- .../src/features/tgu-planning/tguStatus.ts | 13 +++++++++++ .../src/styles/tgu-planning.css | 21 +++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index cd446e0..6712f23 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -10,6 +10,7 @@ import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; import { TguTimeline } from "./TguTimeline"; import { TguToolbar } from "./TguToolbar"; +import { isActivePlanStatus } from "./tguStatus"; import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -28,6 +29,9 @@ export function TguPlanningPage() { const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); const [activeTab, setActiveTab] = useState("timeline"); + // По умолчанию на таймлайне видна только актуальная цепочка; история версий + // (SUPERSEDED/EXPIRED/REJECTED и т.п.) скрыта, чтобы не было визуальной каши. + const [showHistory, setShowHistory] = useState(false); // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии // и дальше держим смонтированной (скрытой через display:none), чтобы её // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось @@ -98,9 +102,14 @@ export function TguPlanningPage() { () => buildPlatformsForInterval(rawPlatforms, plans, appliedRange), [rawPlatforms, plans, appliedRange] ); + // Планы, показываемые на таймлайне: по умолчанию только актуальная цепочка. + const timelinePlans = useMemo( + () => (showHistory ? plans : plans.filter((plan) => isActivePlanStatus(plan.status))), + [plans, showHistory] + ); const rows = useMemo( - () => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId), - [plans, platforms, appliedRange, selectedSpacecraftId] + () => buildTimelineRows(timelinePlans, platforms, appliedRange, selectedSpacecraftId), + [timelinePlans, platforms, appliedRange, selectedSpacecraftId] ); const selectedPlan = useMemo(() => { const plan = plans.find((item) => item.planId === selectedPlanId); @@ -181,6 +190,16 @@ export function TguPlanningPage() { }} />
+
+ +
[style.status, style])); +/** Статусы актуальной цепочки: их показываем на таймлайне по умолчанию. */ +const ACTIVE_PLAN_STATUSES = new Set([ + "PLANNED", + "ISSUING", + "WAITING_DECISION", + "ACCEPTED" +]); + +/** Проверяет, относится ли план к актуальной цепочке (а не к истории версий). */ +export function isActivePlanStatus(status: TguPlanStatus): boolean { + return ACTIVE_PLAN_STATUSES.has(status); +} + export function statusStyle(status: TguPlanStatus): TimelineStatusStyle { return ( STATUS_BY_NAME.get(status) ?? { diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 1152dd2..37e6639 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -346,6 +346,27 @@ background: #121516; } +.tgu-timeline-controls { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.85rem 0; +} + +.tgu-timeline-controls__toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--muted, #9aa4ad); + cursor: pointer; + user-select: none; +} + +.tgu-timeline-controls__toggle input { + cursor: pointer; +} + .tgu-timeline { min-width: 48rem; min-height: 100%; From d630a891f64748486ec9429bf598b97bd2942503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 11:32:16 +0300 Subject: [PATCH 11/79] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82:=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BB=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BE=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=BC=20(=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=20Reflexio)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Воспроизводит форму с экрана Reflexio (раннее окно, большой провал видимости, далёкий кластер) и проверяет, что цепочка перекрывает провал одним длинным планом и доходит до далёкого кластера без разрыва. --- .../service/PlanCalculationServiceTest.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index a6474a0..5dde633 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -237,14 +237,16 @@ class PlanCalculationServiceTest { fun `calculatePlans never leaves a gap between plans across a visibility gap`() { val service = createService(targetMinutes = 180L, deltaMinutes = 60L) val start = LocalDateTime.of(2026, 3, 10, 10, 0) - // Плотный кластер окон, затем большой провал видимости, затем далёкий кластер. - // Провал допустимо перекрыть одним длинным планом, но разрыва между планами быть не должно. + // Воспроизводит форму с экрана Reflexio: раннее окно, затем большой провал + // видимости, затем далёкий кластер. Провал допустимо перекрыть одним длинным + // планом, но разрыва между планами быть не должно. + val gap = start.plusDays(3) val windows = buildList { for (minutes in 0..240 step 20) { add(window("NEAR-$minutes", start.plusMinutes(minutes.toLong()))) } - for (minutes in 0..120 step 20) { - add(window("FAR-$minutes", start.plusDays(3).plusMinutes(minutes.toLong()))) + for (minutes in 0..240 step 20) { + add(window("FAR-$minutes", gap.plusMinutes(minutes.toLong()))) } } @@ -252,7 +254,16 @@ class PlanCalculationServiceTest { // Главное требование: каждый следующий план стартует строго внутри предыдущего (без разрыва). assertChainInvariant(plans) - assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}") + // Провал перекрыт одним длинным планом: есть план, начинающийся до провала и кончающийся после. + assertTrue( + plans.any { plan -> plan.startTime < gap && plan.endTime > gap }, + "the visibility gap must be bridged by a single plan: $plans" + ) + // И цепочка действительно доходит до далёкого кластера, а не обрывается до провала. + assertTrue( + plans.any { plan -> plan.startTime >= gap }, + "the chain must reach the far cluster after the gap: $plans" + ) } /** From c76a823dbc4f12b76403de4ea1cd02e2103fa63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 14:32:51 +0300 Subject: [PATCH 12/79] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20pcp-tgu-service:=20=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B4=D0=B0=D0=BD=D0=BD=D0=BE=D0=B5,=20=D0=B1?= =?UTF-8?q?=D1=83=D0=B4=D1=83=D1=89=D0=B5=D0=B5=20=D1=81=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=20=D0=BB=D0=B5=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше вся будущая цепочка планов материализовалась в planned_plan как PLANNED-строки, а отдельный воркер пересчёта (RebuildPlanScheduleUseCase + PlanRecalculationJob) держал её в актуальном состоянии и гасил выпавшие планы в SUPERSEDED/EXPIRED. Это плодило вложенные планы, гонки пересчёта и мусор в БД. Новая модель: в БД живёт только выданное (история навсегда), будущее — проекция, вычисляемая по запросу из ЗРВ. Перекрывать (supersede) и протухать (expire) больше нечего. Бэкенд: - Удалены TrackedPlan (entity/source/status + repo) и PlanRecalculationJob (entity/repo/service/worker) — материализация и воркер пересчёта больше не нужны. - Удалён RebuildPlanScheduleUseCase; будущее строит новый PlanProjectionService поверх PlanCalculationService. - PlanQueryService отдаёт проекцию (выданное из БД + будущее на лету). - Новый флоу реакции на смену параметров станции: StationParamsChangedConsumer + HandleStationParamsChangedUseCase (срочный перевыпуск через planning_state). - Новые консьюмеры обратной связи исполнения: LayinFailedConsumer, PlanNotAcceptedConsumer (+ DTO PlanExecutionFeedbackMessage, StationParamsChangedMessage). - PlannedPlanStatus: убраны мёртвые SUPERSEDED/EXPIRED; статусы выровнены под жизненный цикл выдачи/закладки. Миграции (ddl-auto: validate): - V7 drop tracked_plan - V8 drop plan_recalculation_job - V9 planning_state: поля срочного перевыпуска - V10 снять unique-ограничение бизнес-ключа planned_plan - V11 удалить легаси PLANNED-строки (материализованное будущее) - V12 удалить легаси SUPERSEDED/EXPIRED и столбец superseded_by_plan_id Тесты: удалены JobRerunFlowTest и RebuildPlanScheduleUseCaseTest; добавлены PlanProjectionServiceTest, PlanQueryServiceTest, IssueDuePlanWorkerOnDemandTest, HandleStationParamsChangedUseCaseTest, VisibilityPayloadParserTest. Скорректированы PlanIssueAndDecisionFlowTest, PlanCalculationServiceTest, VisibilityFlowTest и др. V11/V12 необратимы — перед миграцией на проде снять дамп planned_plan. --- config-repo/pcp-tgu-service.yaml | 10 +- .../tasks/TASK_tgu-plan-on-demand-redesign.md | 297 +++++++++++++++++ .../config/PlanningProperties.kt | 3 + .../config/TopicsProperties.kt | 11 +- .../controller/PlanController.kt | 8 +- .../controller/TestTguController.kt | 17 +- .../dto/PlanExecutionFeedbackMessage.kt | 27 ++ .../pcp_tgu_service/dto/PlanResponse.kt | 23 ++ .../dto/StationParamsChangedMessage.kt | 24 ++ .../entity/PlanRecalculationJobEntity.kt | 48 --- .../entity/PlannedPlanEntity.kt | 3 - .../entity/PlannedPlanStatus.kt | 16 +- .../entity/SpacecraftPlanningStateEntity.kt | 7 + .../entity/TrackedPlanEntity.kt | 63 ---- .../entity/TrackedPlanSource.kt | 7 - .../entity/TrackedPlanStatus.kt | 9 - .../api/VisibilityPayloadParser.kt | 12 +- .../integration/kafka/LayinFailedConsumer.kt | 24 ++ .../kafka/PlanNotAcceptedConsumer.kt | 24 ++ .../kafka/StationParamsChangedConsumer.kt | 24 ++ .../PlanRecalculationJobRepository.kt | 85 ----- .../repository/PlannedPlanRepository.kt | 138 +++----- .../repository/TrackedPlanJpaRepository.kt | 46 --- .../service/HandlePlanDecisionUseCase.kt | 117 ++++--- .../HandleStationParamsChangedUseCase.kt | 46 +++ .../service/IssueDuePlanWorker.kt | 129 ++++++-- .../service/PlanCalculationService.kt | 26 +- .../service/PlanProjectionService.kt | 51 +++ .../service/PlanQueryService.kt | 52 ++- .../service/PlanRecalculationJobService.kt | 20 -- .../service/PlanRecalculationWorker.kt | 85 ----- .../service/RebuildPlanScheduleUseCase.kt | 113 ------- .../SpacecraftVisibilitySnapshotService.kt | 13 +- .../src/main/resources/application.yml | 10 +- ..._drop_planned_plan_business_key_unique.sql | 5 + .../V11__purge_legacy_planned_rows.sql | 11 + .../V12__drop_legacy_superseded_expired.sql | 19 ++ .../db/migration/V7__drop_tracked_plan.sql | 2 + .../V8__drop_plan_recalculation_job.sql | 2 + .../V9__planning_state_urgent_reissue.sql | 4 + .../api/ExternalPointsClientTest.kt | 1 + .../api/VisibilityPayloadParserTest.kt | 36 +++ .../AcceptedPlanSlotsUpdateFlowTest.kt | 2 +- .../HandleStationParamsChangedUseCaseTest.kt | 52 +++ .../service/IssueDuePlanWorkerOnDemandTest.kt | 220 +++++++++++++ .../service/JobRerunFlowTest.kt | 301 ------------------ .../service/PlanCalculationServiceTest.kt | 32 ++ .../service/PlanIssueAndDecisionFlowTest.kt | 295 ++++++----------- .../service/PlanProjectionServiceTest.kt | 115 +++++++ .../service/PlanQueryServiceTest.kt | 78 +++++ .../service/RebuildPlanScheduleUseCaseTest.kt | 188 ----------- .../service/VisibilityFlowTest.kt | 13 +- 52 files changed, 1587 insertions(+), 1377 deletions(-) create mode 100644 docs/tasks/TASK_tgu-plan-on-demand-redesign.md create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt delete mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt delete mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 6c3cc58..0df08a4 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -31,19 +31,23 @@ springdoc: planning: notification-before-start-minutes: 263 - target-plan-duration-minutes: 180 - target-plan-duration-delta-minutes: 60 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 external-update-enabled: true - external-points-lookahead-days: 3 + external-points-lookahead-days: 10 in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + layin-failed: pcp.tgu.layin-failed.v1 + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 + station-params-changed: pcp.tgu.station-params-changed.v1 tgu: test-controller: diff --git a/docs/tasks/TASK_tgu-plan-on-demand-redesign.md b/docs/tasks/TASK_tgu-plan-on-demand-redesign.md new file mode 100644 index 0000000..88e1713 --- /dev/null +++ b/docs/tasks/TASK_tgu-plan-on-demand-redesign.md @@ -0,0 +1,297 @@ +# Задача: pcp-tgu-service — «храним только выданное» + закладочный флоу + +> ТЗ для кодинг-агента и для приёмки. Раздел 0 — промпт запуска. +> Разделы 1–9 — что и как делаем. Раздел 10 — **критерии приёмки** (по ним проверяем результат). +> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. + +--- + +## 0. Промпт для запуска агента (copy-paste) + +``` +Ты работаешь в монорепозитории observatio-terrae (pcp), модуль services/pcp-tgu-service (Kotlin/Spring Boot). + +Задача: перестроить хранение и выдачу планов ТГУ — перестать материализовать +будущую цепочку планов в БД (хранить только выданные планы и их историю — вечно), +считать будущее на лету как проекцию, и добавить закладочный флоу с реакцией +на два Kafka-события «не заложен» и «не принят». + +Перед началом обязательно прочитай: +1. /AGENTS.md — правила работы, стиль, формат финального ответа. +2. /docs/tasks/TASK_tgu-plan-on-demand-redesign.md — это ТЗ. +3. Текущий код: services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/ + (service/, entity/, repository/, controller/, integration/kafka/, config/). + +Делай строго по фазам раздела 5. Всё — в одной ветке/одном PR. +Ядро расчёта цепочки (PlanCalculationService) НЕ меняем — оно уже монотонно-корректно. +После работы прогоняй ./gradlew :services:pcp-tgu-service:test из корня монорепо. +В конце дай отчёт в формате из AGENTS.md и пройдись по разделу 10 (критерии приёмки). +``` + +--- + +## 1. Контекст и цель + +`pcp-tgu-service` (порт `:7011`) — роллинг-планировщик сеансов управления по каждому операционному КА. Поток: список КА из НСИ → окна видимости (ЗРВ) из баллистики → расчёт цепочки планов (~`target` 1440±240 мин, привязка 1:1 к ЗРВ, строгая монотонность) → выдача дозревшего плана через короткий BPMN → решение из Kafka. UI (через агрегатор `:7008`) рисует таймлайн планов. + +**Проблема сейчас.** Таблица `planned_plan` материализует **всю будущую цепочку** как `PLANNED`-строки и переписывает её на каждое изменение хэша ЗРВ. Это копит «лишнее»: пачки `SUPERSEDED`/`EXPIRED` на каждый пересчёт, рост без границ, отдельная машинерия `plan_recalculation_job`, и целый класс багов рассинхрона цепочки со снапшотом («схлопывание в SUPERSEDED», «зомби-PLANNED»). При этом будущая цепочка — **детерминированная проекция** от (снапшот ЗРВ + базовый план + конфиг). + +**Цель.** +1. Хранить в `planned_plan` **только планы, перешедшие в выдачу**, и их историю — **вечно** (ретеншн не делаем). +2. Будущее (не выданное) — **не хранить вовсе**, считать на лету как проекцию (в т.ч. для UI). +3. Добавить **закладочный флоу**: после `ACCEPTED` план оптимистично считается заложенным; реагируем только на явный негатив — два раздельных Kafka-события «не заложен» и «не принят», оба пока ведут себя одинаково (срочно выдать план на ближайшую ЗРВ), но разнесены для будущего расхождения. +4. Реагировать на **изменение параметров станций** (Kafka-событие): станция могла выйти из строя — нужно заново запросить ЗРВ у баллистики по затронутым КА и пересобрать цепочку. В on-demand-модели это сводится к форсу `visibility_refresh_job` → новый снапшот → проекция пересчитается сама. +5. Снести мёртвый код (`tracked_plan`) и ставшую ненужной машинерию пересчёта (`plan_recalculation_job`). + +Масштаб: **100–150 КА**, горизонт планирования **до 10 суток**, деплой **2–3 реплики** (горизонтальное масштабирование уже обеспечено координацией через БД: `FOR UPDATE SKIP LOCKED` + pessimistic-lock на КА). + +--- + +## 2. Границы задачи (scope) + +**В scope:** +- Перестроение хранения/выдачи планов в `pcp-tgu-service` (backend, Kotlin). +- Новый сервис проекции; переписанный воркер выдачи; read-эндпоинт, отдающий persisted ∪ проекцию. +- Новые статусы плана и закладочный флоу с двумя новыми Kafka-консьюмерами. +- Удаление мёртвого кода и снятие `plan_recalculation_job`. +- Миграции БД, изменения конфига (`application.yml` + `config-repo/pcp-tgu-service.yaml`). +- Юнит-тесты на новую логику. + +**Вне scope (без отдельного запроса):** +- Фронтенд `pcp-tgu-ops-ui` и агрегатор `pcp-ui-service` — контракт `/api/plans` сохраняем неизменным, фронт не трогаем. +- Изменение ядра расчёта `PlanCalculationService` (кроме переиспользования; алгоритм не меняем). +- BPMN-схемы (`issueSatellitePlan`, `updateAcceptedPlanSlots`) — не меняем. +- Источник/продюсер новых Kafka-событий «не заложен»/«не принят» — мы только консьюмеры; контракт ниже фиксируем. +- Ретеншн/архивация — НЕ делаем (выданное храним вечно). + +--- + +## 3. Текущая архитектура (точки изменения) + +- [PlannedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt) / `planned_plan` — хранит всю цепочку, upsert по бизнес-ключу `(spacecraft, start, end, kpp)`. +- [RebuildPlanScheduleUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt) — пересобирает цепочку: `calculatePlans/After` → upsert → `supersedeObsoleteFuturePlans` → `expireMissedPlans`. +- [PlanCalculationService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt) — **ядро, не трогаем**. +- [SpacecraftVisibilitySnapshotService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt) — на изменение хэша ставит `plan_recalculation_job`. +- [PlanRecalculationWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt) / `PlanRecalculationJobService` / `plan_recalculation_job` — **снимаем целиком**. +- [IssueDuePlanWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) — сейчас сканирует `PLANNED` (`findDueForUpdate`). Переписываем на on-demand. +- [HandlePlanDecisionUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt) — обработка `ACCEPTED/REJECTED`; `promoteNextPlanOrEnqueueRebuild` завязан на хранимые `PLANNED` → переписываем. +- [PlanQueryService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) / [PlanController](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) — read-путь, дополняем проекцией. +- Мёртвый код: [TrackedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt), `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository` — на репозиторий нет ссылок. + +--- + +## 4. Целевая архитектура + +- `planned_plan` пополняется строкой **только в момент выдачи** (статус `ISSUING`). Дальше строка живёт по жизненному циклу выдачи и **хранится вечно**. +- **Будущее не хранится.** `PlanProjectionService.project(spacecraftId)` считает цепочку на лету из последнего снапшота ЗРВ + текущей базы + конфига, переиспользуя `PlanCalculationService`. +- **Выдача** ([IssueDuePlanWorker]) идёт по активным КА, считает голову проекции; если голова «дозрела» (`issue_not_before ≤ now`) или взведён `urgent_reissue_from` — материализует **ровно эту голову** в `ISSUING` и выдаёт. Никаких `PLANNED`-строк в БД. +- **Read** ([PlanQueryService]) отдаёт `persisted (вся история) ∪ проекция будущего` (виртуальный `PLANNED`, в БД не пишется), дедуп по бизнес-ключу. Контракт `/api/plans` неизменен. +- **Закладочный флоу**: `ACCEPTED` → `AWAITING_LAYIN` (оптимистично = база, цепочка идёт дальше). Два новых события гасят план терминально (`LAYIN_FAILED` / `NOT_ACCEPTED`) и взводят срочную перевыдачу на ближайшую ЗРВ с лидом `now + reissue-prep-minutes`. + +--- + +## 5. Фазы работ + +### Фаза 0. Чистка мёртвого кода +- Удалить `TrackedPlanEntity`, `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository`. +- Миграция `V7__drop_tracked_plan.sql`: `DROP TABLE IF EXISTS tracked_plan;` +- Убедиться, что компиляция/тесты не ссылались на эти классы. + +### Фаза 1. On-demand проекция, конец материализации цепочки +- Новый `PlanProjectionService` с `project(spacecraftId): List` и `projectHead(spacecraftId): CalculatedPlan?`: + читает `spacecraft_visibility_snapshot`, текущую базу (`spacecraft_planning_state.currentBasePlanId`), считает цепочку через `PlanCalculationService` (`calculatePlans`/`calculatePlansAfter`). Опционально — in-memory кэш по `payload_hash` (если делаешь — инвалидируй при смене снапшота/базы; при 150 КА можно и без кэша). +- Переписать `IssueDuePlanWorker` на on-demand: идём по активным КА (`spacecraft_snapshot.active = true` / `spacecraft_planning_state`), для каждого через `PlanProjectionService.projectHead` берём ближайший план; если у КА нет активной попытки (`activeAttemptId == null`) и (`issue_not_before(head) ≤ now` **или** взведён `urgent_reissue_from`) — материализуем голову в `planned_plan` со статусом `ISSUING` и стартуем выдачу. Сохраняем claim-семантику с lock на КА. +- `RebuildPlanScheduleUseCase`: убрать материализацию (`upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans`). Логику расчёта забирает `PlanProjectionService`; сам use case либо удаляется, либо превращается в тонкую обёртку над проекцией (если на него ссылается `TestTguController` — переориентировать/упростить). +- **Снять машинерию пересчёта**: удалить `PlanRecalculationWorker`, `PlanRecalculationJobService`, `PlanRecalculationJobEntity`, `PlanRecalculationJobRepository`; убрать `enqueue(...)` из `SpacecraftVisibilitySnapshotService` (hash-проверку оставить — она просто перестаёт что-либо ставить). Миграция `V8__drop_plan_recalculation_job.sql`: `DROP TABLE IF EXISTS plan_recalculation_job;` +- `PlannedPlanRepository`: удалить ставшие мёртвыми `upsertPlannedByBusinessKey`, `findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime`, `findAllBySpacecraftIdAndStatusAndStartTimeLessThanEqualOrderByStartTime`, `findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime`. Оставить/добавить: `lockByPlanId`, выборка истории по КА и по всем для read, защита от дублей выдачи. + +### Фаза 2. Read-проекция для UI +- `PlanQueryService.getAllPlans/getPlansBySpacecraftId`: вернуть `persisted ∪ projection`. Persisted — все строки `planned_plan` (история вечна; `historyDays` оставить как фильтр **отображения** левого края, БД не режем). Projection — `PlanProjectionService.project(...)` по активным КА, виртуальный статус `PLANNED`, дедуп: проекционный план не добавляем, если его бизнес-ключ уже есть среди persisted. +- `PlanResponse`: научить представлять виртуальные планы (детерминированный `planId` из бизнес-ключа, либо nullable — на усмотрение, но контракт JSON для фронта не ломать; `status = PLANNED`). Добавить фабрику `from(CalculatedPlan, status)`. + +### Фаза 3. Статусы и закладочный флоу +- `PlannedPlanStatus`: добавить `AWAITING_LAYIN`, `LAID_IN`, `LAYIN_FAILED`, `NOT_ACCEPTED`. `PLANNED` оставить только как виртуальный для проекции (в БД не писать). +- `HandlePlanDecisionUseCase` (топик `satellite-plan-decision`): + - `ACCEPTED` → план `ACCEPTED` затем `AWAITING_LAYIN`, `currentBasePlanId = plan`, проекция продолжается; `acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)` (как есть). + - `REJECTED` → план терминально `REJECTED` → общий `reissueOnNearestWindow(...)`. + - Удалить `promoteNextPlanOrEnqueueRebuild` (он завязан на хранимые `PLANNED`). +- Новые события (раздельные консьюмеры, общее поведение): + - `TopicsProperties`: добавить `layinFailed` и `planNotAccepted`. + - DTO `PlanExecutionFeedbackMessage(eventId, spacecraftId, planId, occurredAt, reason)` — пока общий на оба события. + - Консьюмеры `LayinFailedConsumer`, `PlanNotAcceptedConsumer` (groupId `${spring.kafka.consumer.group-id}`). + - В use case — два метода `handleLayinFailed(msg)` / `handleNotAccepted(msg)`, **оба идемпотентны** по `eventId` через `processed_plan_decision_event`, оба зовут общий `reissueOnNearestWindow(...)`; статус помечаемого плана соответственно `LAYIN_FAILED` / `NOT_ACCEPTED`. +- `reissueOnNearestWindow(spacecraftId, failedPlanId, now)` (общий для REJECTED-после-cutoff / LAYIN_FAILED / NOT_ACCEPTED): + - под lock на КА: помечает план терминальным статусом (история — навечно), снимает его с роли базы (`currentBasePlanId` ← предыдущая валидная база или `null`), чистит `activePlanId/activeAttemptId`; + - ставит `spacecraft_planning_state.urgent_reissue_from = now + reissue-prep-minutes`. +- `IssueDuePlanWorker` urgent-path: если у КА `urgent_reissue_from != null` — голову проекции берём как ближайшую ЗРВ со `startTime ≥ urgent_reissue_from`, материализуем и выдаём немедленно (не ждём `issue_not_before`), после успешного старта `urgent_reissue_from = null`. +- Миграция `V9__planning_state_urgent_reissue.sql`: `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` + +### Фаза 3b. Событие «изменились параметры станций» +- `TopicsProperties` + конфиги: `topics.station-params-changed: pcp.tgu.station-params-changed.v1`. +- DTO `StationParamsChangedMessage(eventId, stationId?, occurredAt, reason?)`. +- Консьюмер `StationParamsChangedConsumer` (groupId `${spring.kafka.consumer.group-id}`). +- Use case `handleStationParamsChanged(msg)`: **идемпотентен** по `eventId` (общий ledger `processed_plan_decision_event`); ставит `visibility_refresh_job` (`VisibilityRefreshJobService.enqueue`, reason `STATION_PARAMS_CHANGED`) по **всем активным КА** (`spacecraft_snapshot.active = true`). Дальше [VisibilityRefreshWorker] перечитывает ЗРВ у баллистики → новый снапшот → on-demand проекция пересобирает цепочку автоматически. +- `stationId` в событии пока **не используется для сужения** (карты «станция → КА» в tgu нет; баллистика считает ЗРВ по-КА). Поле оставляем на будущее для точечного refresh; сейчас поведение — blanket refresh всех активных КА (при 150 КА дёшево). +- Уже выданные/принятые планы (`ISSUING`/`AWAITING_LAYIN`) ретроспективно не меняются — пересобирается только будущая часть цепочки (после базы). Если станция текущего плана отказала по сути — это придёт отдельным негативным событием (`layin-failed`/`not-accepted`). + +### Фаза 4. Конфиг +- `PlanningProperties` + `application.yml` + `config-repo/pcp-tgu-service.yaml`: + - `external-points-lookahead-days: 3 → 10`; + - новый `reissue-prep-minutes: 30`; + - `min-reissue-lead-minutes` — оставить (REJECTED-до-cutoff). +- `TopicsProperties` + конфиги: `topics.layin-failed: pcp.tgu.layin-failed.v1`, `topics.plan-not-accepted: pcp.tgu.plan-not-accepted.v1`, `topics.station-params-changed: pcp.tgu.station-params-changed.v1` (с возможностью переопределить). + +### Фаза 5. Тесты +- Сохранить `PlanCalculationServiceTest` (ядро не тронуто). +- Новые: `PlanProjectionServiceTest`, `IssueDuePlanWorker` (on-demand голова + urgent-path), `HandlePlanDecisionUseCase` (`ACCEPTED→AWAITING_LAYIN`; `REJECTED→reissue`), консьюмеры `layin-failed`/`not-accepted` (идемпотентность, раздельность, общий reissue), консьюмер `station-params-changed` (идемпотентность + постановка refresh по всем активным КА), read-проекция (`persisted ∪ projection` + дедуп). +- Удалить тесты снятой машинерии (recalc worker, supersede/expire); поправить `RebuildPlanScheduleUseCaseTest`. + +--- + +## 6. Модель данных и миграции + +| Миграция | Действие | +|---|---| +| `V7__drop_tracked_plan.sql` | `DROP TABLE IF EXISTS tracked_plan;` | +| `V8__drop_plan_recalculation_job.sql` | `DROP TABLE IF EXISTS plan_recalculation_job;` | +| `V9__planning_state_urgent_reissue.sql` | `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` | + +`planned_plan` — схему не меняем (новые значения статуса — это `VARCHAR`, миграция не нужна). Индексы оставляем. `ddl-auto: validate` — значит сущности должны точно соответствовать таблицам после миграций. + +--- + +## 7. Конфиг (итоговые значения) + +```yaml +planning: + notification-before-start-minutes: 263 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 + external-update-enabled: true + external-points-lookahead-days: 10 # было 3 + in-memory-history-days: 14 + external-update-fixed-delay-ms: 600000 + notification-check-fixed-delay-ms: 30000 + min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 # новый + worker-lock-ttl-seconds: 300 + +topics: + visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 + satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + layin-failed: pcp.tgu.layin-failed.v1 # новый + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 # новый + station-params-changed: pcp.tgu.station-params-changed.v1 # новый +``` + +--- + +## 8. Контракты Kafka (входящие, мы — консьюмер) + +Существующий `satellite-plan-decision` (`PlanDecisionMessage`) — без изменений. + +Новые топики, общий DTO `PlanExecutionFeedbackMessage`: + +```json +{ + "eventId": "uuid", // dedup-ключ (идемпотентность) + "spacecraftId": "string", + "planId": "uuid", // план, по которому пришёл негатив (текущая база) + "occurredAt": "2026-06-02T10:00:00", + "reason": "string|null" +} +``` + +- `pcp.tgu.layin-failed.v1` — «не заложен». +- `pcp.tgu.plan-not-accepted.v1` — «не принят». +- Поведение пока идентично; обработчики раздельные. + +Новый топик `pcp.tgu.station-params-changed.v1`, DTO `StationParamsChangedMessage`: + +```json +{ + "eventId": "uuid", // dedup-ключ (идемпотентность) + "stationId": "string|null", // какая станция изменилась; пока не используется для сужения + "occurredAt": "2026-06-02T10:00:00", + "reason": "string|null" +} +``` + +- При получении — поставить `visibility_refresh_job` по всем активным КА (перезапрос ЗРВ у баллистики), цепочка пересоберётся через on-demand проекцию. + +--- + +## 9. Допущения и риски + +- **Источник «ближайшей ЗРВ»** для срочной перевыдачи — тот же последний снапшот ЗРВ; если в нём нет окна со `startTime ≥ urgent_reissue_from`, голова пуста — КА ждёт следующего refresh окон (это корректное поведение, не ошибка). +- **Идемпотентность** новых событий — обязательна (at-least-once Kafka), через общий `processed_plan_decision_event`. +- **`ddl-auto: validate`**: после правок прогнать миграции на чистой БД и убедиться, что Hibernate-валидация проходит. +- **Совместимость UI**: JSON-форма `/api/plans` не меняется; виртуальные планы отдаются с тем же набором полей и `status: "PLANNED"`. +- **Сборка ресурсов**: при правке `application.yml` помнить про `./gradlew :services:pcp-tgu-service:processResources` (IntelliJ-рестарт не всегда пересобирает ресурсы). + +--- + +## 10. Критерии приёмки (по ним проверяем) + +### 10.1. Хранение «только выданное» +- [ ] При штатной работе (КА с окнами ЗРВ, ни одного дозревшего плана) в `planned_plan` **нет ни одной строки** для этого КА. Будущее видно только через `/api/plans` как `PLANNED`. +- [ ] Строка в `planned_plan` появляется **ровно в момент выдачи** и сразу в статусе `ISSUING` (не `PLANNED`). +- [ ] Статус `PLANNED` **никогда не пишется** в БД (проверяемо: `SELECT count(*) FROM planned_plan WHERE status='PLANNED'` = 0 в любой момент). +- [ ] Выданные планы и их история **не удаляются** (ретеншн-джоба нет; нет `DELETE`/`SUPERSEDED`-массовой чистки будущего). +- [ ] В коде нет материализации цепочки: `upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans` удалены. + +### 10.2. Проекция будущего +- [ ] `GET /api/plans` и `GET /api/plans/{spacecraftId}` возвращают persisted-планы **плюс** вычисленную проекцию будущего; JSON-контракт прежний, фронт работает без изменений. +- [ ] Проекция совпадает по содержанию с тем, что раньше материализовалось (та же монотонная цепочка из `PlanCalculationService`): для одних и тех же окон ЗРВ набор `(start,end,kpp)` будущих планов идентичен прежнему поведению. +- [ ] Нет дублей: если бизнес-ключ есть в persisted, проекция его не добавляет повторно. +- [ ] Левый край истории по-прежнему регулируется `historyDays`. + +### 10.3. Снятие машинерии и мёртвого кода +- [ ] Таблиц `tracked_plan` и `plan_recalculation_job` нет (миграции `DROP` применены); связанные сущности/репозитории/воркеры/сервисы удалены. +- [ ] `SpacecraftVisibilitySnapshotService` больше не ставит recalc-job; сохранение снапшота и hash-проверка работают. +- [ ] Проект компилируется без ссылок на удалённые классы; неиспользуемые методы репозитория убраны. + +### 10.4. Закладочный флоу (оптимизм + реакция на негатив) +- [ ] `ACCEPTED` → план переходит в `AWAITING_LAYIN`, становится базой; цепочка продолжается; `updateAcceptedPlanSlots`-job ставится. Никакого ожидания подтверждения закладки в виде доп. строк. +- [ ] Отсутствие любых событий после `ACCEPTED` = «заложен» (план остаётся базой, ничего не делаем). +- [ ] Событие `pcp.tgu.layin-failed.v1` по базовому плану → план в `LAYIN_FAILED`, взводится `urgent_reissue_from = now + reissue-prep-minutes`, и ближайшая ЗРВ ≥ этого времени **выдаётся немедленно** (не дожидаясь `issue_not_before`). +- [ ] Событие `pcp.tgu.plan-not-accepted.v1` → то же поведение, но статус `NOT_ACCEPTED`; обработчик **отдельный** (разные методы/консьюмеры), общее ядро `reissueOnNearestWindow`. +- [ ] Оба события **идемпотентны**: повтор того же `eventId` — no-op (повторной перевыдачи нет). +- [ ] `reissue-prep-minutes` берётся из конфига (по умолчанию 30); изменение значения меняет лид срочной выдачи. +- [ ] Если в снапшоте нет ЗРВ ≥ `urgent_reissue_from` — выдачи нет, флаг сохраняется, повторная попытка произойдёт после обновления окон (без ошибок/исключений в логе). + +### 10.4b. Реакция на изменение параметров станций +- [ ] Событие `pcp.tgu.station-params-changed.v1` ставит `visibility_refresh_job` по всем активным КА (reason `STATION_PARAMS_CHANGED`). +- [ ] После перезапроса ЗРВ снапшоты обновляются, и `/api/plans` отдаёт цепочку, пересобранную по новым окнам (без перезапуска сервиса и без ручного rebuild). +- [ ] Событие идемпотентно по `eventId` (повтор — без повторной постановки refresh). +- [ ] Уже выданные/принятые планы ретроспективно не меняются; пересобирается только будущее. + +### 10.5. Масштабирование / корректность под несколькими подами +- [ ] Claim во всех воркерах — `FOR UPDATE SKIP LOCKED`; выдача плана и обработка решения — под lock на КА (один план не выдаётся дважды при 2–3 подах). +- [ ] У одного КА одновременно не более одной активной попытки выдачи (`activeAttemptId`). +- [ ] Сервис стартует и работает с `external-points-lookahead-days: 10`. + +### 10.6. Конфиг и миграции +- [ ] `application.yml` и `config-repo/pcp-tgu-service.yaml` содержат итоговые значения раздела 7 (lookahead 10, `reissue-prep-minutes: 30`, два новых топика). +- [ ] `ddl-auto: validate` проходит на чистой БД после применения всех миграций (V7–V9). + +### 10.7. Тесты и сборка +- [ ] `./gradlew :services:pcp-tgu-service:test` — зелёный. +- [ ] Есть тесты на: проекцию, on-demand выдачу, urgent-path, `ACCEPTED→AWAITING_LAYIN`, оба новых консьюмера (идемпотентность + раздельность + общий reissue), read-объединение с дедупом. +- [ ] Старые тесты снятой машинерии удалены, оставшиеся — зелёные. + +--- + +## 11. Команды проверки + +```bash +# из корня монорепо +./gradlew :services:pcp-tgu-service:test +./gradlew :services:pcp-tgu-service:processResources # после правок application.yml + +# ручная проверка флоу (нужен tgu.test-controller.enabled=true локально): +# - запустить сервис на :7011, дождаться окон ЗРВ +# - GET http://localhost:7011/api/plans/{spacecraftId} -> в ответе PLANNED-проекция, в БД строк нет +# - сэмулировать ACCEPTED -> в БД ACCEPTED/AWAITING_LAYIN, проекция продолжается +# - отправить layin-failed/not-accepted -> терминальный статус + срочная выдача ближайшей ЗРВ +``` diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index 45a22d7..bd8241f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -35,6 +35,9 @@ data class PlanningProperties( /** Минимальный запас времени для повторной выдачи после REJECTED. */ val minReissueLeadMinutes: Long, + /** Минимальное время на подготовку срочно перевыдаваемого плана (после негатива), в минутах. */ + val reissuePrepMinutes: Long, + /** TTL lease'а worker job claim в секундах. */ val workerLockTtlSeconds: Long ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt index f6d38db..d96d6bf 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt @@ -9,5 +9,14 @@ data class TopicsProperties( val visibilityWindowsChanged: String, /** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */ - val satellitePlanDecision: String + val satellitePlanDecision: String, + + /** Topic-событие «не заложен» по принятому плану. */ + val layinFailed: String, + + /** Topic-событие «не принят» по принятому плану. */ + val planNotAccepted: String, + + /** Topic-событие об изменении параметров станций (перезапрос ЗРВ). */ + val stationParamsChanged: String ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index e199783..96da5e6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -17,20 +17,20 @@ class PlanController( private val planQueryService: PlanQueryService ) { - /** Возвращает планы по всем КА с указанным окном подтверждённой истории. */ + /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @GetMapping fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono> = Mono.fromCallable { - planQueryService.getAllPlans(historyDays).map(PlanResponse::from) + planQueryService.getAllPlans(historyDays) }.subscribeOn(Schedulers.boundedElastic()) - /** Возвращает цепочку планов для одного КА. */ + /** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */ @GetMapping("/{spacecraftId}") fun getPlansBySpacecraftId( @PathVariable spacecraftId: String, @RequestParam(defaultValue = "7") historyDays: Long ): Mono> = Mono.fromCallable { - planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from) + planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) }.subscribeOn(Schedulers.boundedElastic()) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt index 03d105c..7f63d1f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt @@ -18,10 +18,9 @@ import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker +import space.nstart.pcp_tgu_service.service.PlanProjectionService import space.nstart.pcp_tgu_service.service.PlanQueryService -import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService import space.nstart.pcp_tgu_service.service.PlatformService -import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase import space.nstart.pcp_tgu_service.service.TestPlanDecisionService import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService import java.nio.file.Files @@ -37,8 +36,7 @@ import java.util.UUID @RequestMapping("/test/tgu") class TestTguController( private val visibilityRefreshJobService: VisibilityRefreshJobService, - private val planRecalculationJobService: PlanRecalculationJobService, - private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, + private val planProjectionService: PlanProjectionService, private val issueDuePlanWorker: IssueDuePlanWorker, private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase, private val testPlanDecisionService: TestPlanDecisionService, @@ -56,12 +54,11 @@ class TestTguController( visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") }.subscribeOn(Schedulers.boundedElastic()) - /** Ставит и сразу выполняет rebuild persisted schedule для КА. */ - @PostMapping("/spacecraft/{spacecraftId}/rebuild-plans") - fun rebuildPlans(@PathVariable spacecraftId: String): Mono = - Mono.fromRunnable { - planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER") - rebuildPlanScheduleUseCase.rebuild(spacecraftId) + /** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */ + @GetMapping("/spacecraft/{spacecraftId}/projection") + fun getProjection(@PathVariable spacecraftId: String): Mono> = + Mono.fromCallable { + planProjectionService.project(spacecraftId).map(PlanResponse::projected) }.subscribeOn(Schedulers.boundedElastic()) /** Принудительно выдаёт конкретный persisted plan через новую attempt. */ diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt new file mode 100644 index 0000000..0ced40f --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие негативной обратной связи по выданному (принятому) плану. + * + * Используется двумя топиками — «не заложен» и «не принят». Сейчас форма общая; + * при будущем расхождении поведения её можно разделить. + */ +data class PlanExecutionFeedbackMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Идентификатор КА внутри TGU. */ + val spacecraftId: String, + + /** Stable persisted planId плана, по которому пришёл негатив (текущий базовый план). */ + val planId: UUID, + + /** Время события во внешней системе. */ + val occurredAt: LocalDateTime, + + /** Причина/комментарий. */ + val reason: String? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index e705e51..9c4bacc 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -1,7 +1,9 @@ package space.nstart.pcp_tgu_service.dto +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.nio.charset.StandardCharsets import java.time.LocalDateTime import java.util.UUID @@ -43,5 +45,26 @@ data class PlanResponse( kppId = plan.kppId, status = plan.status ) + + /** + * Виртуальный (не сохранённый) план будущей проекции со статусом PLANNED. + * + * planId детерминированно выводится из бизнес-ключа, чтобы один и тот же расчётный план + * стабильно опознавался фронтом между запросами. + */ + fun projected(plan: CalculatedPlan): PlanResponse = + PlanResponse( + planId = deterministicPlanId(plan), + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + status = PlannedPlanStatus.PLANNED + ) + + private fun deterministicPlanId(plan: CalculatedPlan): UUID { + val businessKey = "${plan.spacecraftId}|${plan.startTime}|${plan.endTime}|${plan.kppId}" + return UUID.nameUUIDFromBytes(businessKey.toByteArray(StandardCharsets.UTF_8)) + } } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt new file mode 100644 index 0000000..55f48f2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие об изменении параметров станций (например, станция вышла из строя). + * + * По нему перезапрашиваются ЗРВ у баллистики по всем активным КА, и цепочка пересобирается + * через on-demand проекцию. + */ +data class StationParamsChangedMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Какая станция изменилась; пока не используется для сужения набора КА. */ + val stationId: String?, + + /** Время события во внешней системе. */ + val occurredAt: LocalDateTime, + + /** Причина/комментарий. */ + val reason: String? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt deleted file mode 100644 index 49b2918..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt +++ /dev/null @@ -1,48 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime - -/** - * Job на перестроение persisted расписания планов одного КА. - */ -@Entity -@Table(name = "plan_recalculation_job") -class PlanRecalculationJobEntity( - @Id - @Column(name = "spacecraft_id", nullable = false, updatable = false) - var spacecraftId: String, - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: JobStatus = JobStatus.PENDING, - - @Column(name = "reason") - var reason: String? = null, - - @Column(name = "locked_by") - var lockedBy: String? = null, - - @Column(name = "locked_until") - var lockedUntil: LocalDateTime? = null, - - @Column(name = "attempts", nullable = false) - var attempts: Int = 0, - - @Column(name = "rerun_requested", nullable = false) - var rerunRequested: Boolean = false, - - @Column(name = "requested_at", nullable = false) - var requestedAt: LocalDateTime = LocalDateTime.now(), - - @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() -) { - /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ - constructor() : this(spacecraftId = "") -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt index 43259aa..e96a5e6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -47,9 +47,6 @@ class PlannedPlanEntity( @Column(name = "accepted_at") var acceptedAt: LocalDateTime? = null, - @Column(name = "superseded_by_plan_id") - var supersededByPlanId: UUID? = null, - @Column(name = "created_at", nullable = false) var createdAt: LocalDateTime = LocalDateTime.now(), diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt index 4c2b784..a974961 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt @@ -4,12 +4,24 @@ package space.nstart.pcp_tgu_service.entity * Жизненный цикл persisted business-plan в расписании TGU. */ enum class PlannedPlanStatus { + /** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */ PLANNED, ISSUING, WAITING_DECISION, ACCEPTED, + + /** Принят и оптимистично считается заложенным; текущий базовый план цепочки. */ + AWAITING_LAYIN, + + /** Подтверждённая закладка (терминальный успех). */ + LAID_IN, + + /** Пришло событие «не заложен» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ + LAYIN_FAILED, + + /** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ + NOT_ACCEPTED, + REJECTED, - SUPERSEDED, - EXPIRED, START_AMBIGUOUS } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt index ced8a80..b90bc82 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -27,6 +27,13 @@ class SpacecraftPlanningStateEntity( @Column(name = "chain_version", nullable = false) var chainVersion: Long = 0, + /** + * Если задано — ближайшую ЗРВ со стартом не раньше этого времени нужно выдать срочно + * (после REJECTED / «не заложен» / «не принят»), не дожидаясь обычного issue_not_before. + */ + @Column(name = "urgent_reissue_from") + var urgentReissueFrom: LocalDateTime? = null, + @Column(name = "updated_at", nullable = false) var updatedAt: LocalDateTime = LocalDateTime.now() ) { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt deleted file mode 100644 index b9615e9..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt +++ /dev/null @@ -1,63 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** JPA-сущность, хранящая план вместе с его процессным и подтверждающим жизненным циклом. */ -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime -import java.util.UUID - -@Entity -@Table(name = "tracked_plan") -class TrackedPlanEntity( - @Id - @Column(name = "plan_id", nullable = false, updatable = false) - var planId: UUID, - - @Column(name = "spacecraft_id", nullable = false) - var spacecraftId: String, - - @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime, - - @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime, - - @Column(name = "kpp_id", nullable = false) - var kppId: String, - - @Column(name = "process_instance_key") - var processInstanceKey: Long? = null, - - @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), - - @Column(name = "confirmed_at") - var confirmedAt: LocalDateTime? = null, - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: TrackedPlanStatus = TrackedPlanStatus.WAITING_CONFIRMATION, - - @Enumerated(EnumType.STRING) - @Column(name = "plan_source", nullable = false) - var planSource: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN, - - @Column(name = "base_plan_id") - var basePlanId: UUID? = null, - - @Column(name = "superseded_by_plan_id") - var supersededByPlanId: UUID? = null -) { - /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ - constructor() : this( - planId = UUID.randomUUID(), - spacecraftId = "", - startTime = LocalDateTime.MIN, - endTime = LocalDateTime.MIN, - kppId = "" - ) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt deleted file mode 100644 index beab660..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** Описывает способ создания отслеживаемого плана. */ -enum class TrackedPlanSource { - AUTO_CHAIN, - MANUAL -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt deleted file mode 100644 index 57d2131..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt +++ /dev/null @@ -1,9 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** Описывает состояние жизненного цикла отслеживаемого плана. */ -enum class TrackedPlanStatus { - NEW, - WAITING_CONFIRMATION, - CONFIRMED, - SUPERSEDED -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt index 1a46d9c..5745153 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt @@ -10,6 +10,7 @@ import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit /** * Парсит raw payload баллистики в доменную модель расчёта без доступа к БД. @@ -42,11 +43,18 @@ class VisibilityPayloadParser( return SpacecraftPoints(spacecraftId = spacecraftId, points = points) } - /** Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. */ + /** + * Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. + * + * Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time` + * хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется. + * Без обрезки проекция (наносекунды) и persisted-планы (микросекунды) перестают совпадать по + * бизнес-ключу — ломается дедуп в read-пути и матчинг базы в [calculatePlansAfter]. + */ private fun toLocalDateTime(value: String): LocalDateTime = try { Instant.parse(value).atZone(zoneId).toLocalDateTime() } catch (_: DateTimeParseException) { LocalDateTime.parse(value) - } + }.truncatedTo(ChronoUnit.MICROS) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt new file mode 100644 index 0000000..a8a11f8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase + +/** + * Kafka consumer события «не заложен»; делегирует срочную перевыдачу в use case. + */ +@Component +class LayinFailedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.layinFailed}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleLayinFailed(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt new file mode 100644 index 0000000..35802fe --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase + +/** + * Kafka consumer события «не принят»; пока поведение идентично «не заложен», обработчик раздельный. + */ +@Component +class PlanNotAcceptedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.planNotAccepted}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleNotAccepted(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt new file mode 100644 index 0000000..dc8c5e6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.service.HandleStationParamsChangedUseCase + +/** + * Kafka consumer события об изменении параметров станций; запускает перезапрос ЗРВ. + */ +@Component +class StationParamsChangedConsumer( + val topicsProperties: TopicsProperties, + private val handleStationParamsChangedUseCase: HandleStationParamsChangedUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.stationParamsChanged}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: StationParamsChangedMessage) { + handleStationParamsChangedUseCase.handle(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt deleted file mode 100644 index 8f237e6..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt +++ /dev/null @@ -1,85 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import java.time.LocalDateTime - -/** Доступ к jobs перестроения расписания. */ -interface PlanRecalculationJobRepository : JpaRepository { - /** Атомарно ставит или переоткрывает coalesced recalculation job по spacecraftId. */ - @Modifying - @Query( - value = """ - INSERT INTO plan_recalculation_job( - spacecraft_id, - status, - reason, - locked_by, - locked_until, - rerun_requested, - requested_at, - updated_at - ) - VALUES ( - :spacecraftId, - 'PENDING', - :reason, - NULL, - NULL, - FALSE, - :now, - :now - ) - ON CONFLICT (spacecraft_id) DO UPDATE SET - status = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status - ELSE 'PENDING' - END, - reason = EXCLUDED.reason, - locked_by = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by - ELSE NULL - END, - locked_until = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until - ELSE NULL - END, - rerun_requested = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE - ELSE FALSE - END, - requested_at = EXCLUDED.requested_at, - updated_at = EXCLUDED.updated_at - """, - nativeQuery = true - ) - fun upsertPending( - @Param("spacecraftId") spacecraftId: String, - @Param("reason") reason: String, - @Param("now") now: LocalDateTime - ): Int - - /** Claim pending или просроченные running jobs через row locks для горизонтального scale-out. */ - @Query( - value = """ - SELECT * - FROM plan_recalculation_job - WHERE status = 'PENDING' - OR ( - status = 'RUNNING' - AND locked_until < :now - ) - ORDER BY requested_at - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - fun findPendingForUpdate( - @Param("now") now: LocalDateTime, - @Param("limit") limit: Int - ): List -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt index c93c6df..62b594a 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -4,99 +4,56 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity -import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.time.LocalDateTime import java.util.UUID -/** Доступ к persisted расписанию business-plan. */ +/** Доступ к persisted планам (только выданные и их история; будущее не хранится). */ interface PlannedPlanRepository : JpaRepository { - /** Ищет persisted plan по стабильному business key. */ - fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String - ): PlannedPlanEntity? - - /** Возвращает планы КА в порядке старта. */ + /** Возвращает планы КА в порядке старта (для read-проекции и истории). */ fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List - /** Возвращает все планы в порядке старта. */ + /** Возвращает все планы в порядке старта (для read-проекции и истории). */ fun findAllByOrderByStartTime(): List - /** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */ - fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( - spacecraftId: String, - status: PlannedPlanStatus, - endTime: LocalDateTime - ): List - - /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ - fun findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId: String, - status: PlannedPlanStatus, - startTime: LocalDateTime - ): PlannedPlanEntity? - - /** Атомарно создаёт или обновляет PLANNED plan по business key, сохраняя stable planId. */ + /** + * Создаёт новую строку выдаваемого плана сразу в статусе ISSUING. + * + * В on-demand-модели каждая выдача — это INSERT с новым plan_id; апсерта по бизнес-ключу нет, + * повторная выдача того же окна после негатива создаёт отдельную строку (история хранится вечно). + */ @Query( value = """ - WITH upsert AS ( - INSERT INTO planned_plan( - plan_id, - spacecraft_id, - start_time, - end_time, - kpp_id, - status, - issue_not_before, - base_plan_id, - chain_version, - created_at, - updated_at - ) - VALUES ( - :planId, - :spacecraftId, - :startTime, - :endTime, - :kppId, - 'PLANNED', - :issueNotBefore, - :basePlanId, - :chainVersion, - :now, - :now - ) - ON CONFLICT (spacecraft_id, start_time, end_time, kpp_id) DO UPDATE SET - status = CASE - WHEN planned_plan.status IN ('SUPERSEDED', 'EXPIRED') THEN 'PLANNED' - ELSE planned_plan.status - END, - issue_not_before = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.issue_not_before - ELSE planned_plan.issue_not_before - END, - base_plan_id = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.base_plan_id - ELSE planned_plan.base_plan_id - END, - chain_version = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.chain_version - ELSE planned_plan.chain_version - END, - updated_at = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.updated_at - ELSE planned_plan.updated_at - END - RETURNING * + INSERT INTO planned_plan( + plan_id, + spacecraft_id, + start_time, + end_time, + kpp_id, + status, + issue_not_before, + base_plan_id, + chain_version, + created_at, + updated_at ) - SELECT * - FROM upsert + VALUES ( + :planId, + :spacecraftId, + :startTime, + :endTime, + :kppId, + 'ISSUING', + :issueNotBefore, + :basePlanId, + :chainVersion, + :now, + :now + ) + RETURNING * """, nativeQuery = true ) - fun upsertPlannedByBusinessKey( + fun insertIssuingPlan( @Param("planId") planId: UUID, @Param("spacecraftId") spacecraftId: String, @Param("startTime") startTime: LocalDateTime, @@ -108,7 +65,7 @@ interface PlannedPlanRepository : JpaRepository { @Param("now") now: LocalDateTime ): PlannedPlanEntity - /** Блокирует plan для обработки decision event или issue state transition. */ + /** Блокирует plan для обработки decision/feedback event или issue state transition. */ @Query( value = """ SELECT * @@ -119,25 +76,4 @@ interface PlannedPlanRepository : JpaRepository { nativeQuery = true ) fun lockByPlanId(@Param("planId") planId: UUID): PlannedPlanEntity? - - /** Claim due plans через PostgreSQL row locks, чтобы несколько pod не выдали один plan дважды. */ - @Query( - value = """ - SELECT * - FROM planned_plan - WHERE status = 'PLANNED' - AND (issue_not_before IS NULL OR issue_not_before <= :now) - AND start_time <= :issueBefore - AND start_time > :now - ORDER BY start_time - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - fun findDueForUpdate( - @Param("now") now: LocalDateTime, - @Param("issueBefore") issueBefore: LocalDateTime, - @Param("limit") limit: Int - ): List } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt deleted file mode 100644 index 21a5d4b..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt +++ /dev/null @@ -1,46 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -/** Предоставляет методы доступа к БД для отслеживаемых планов и их состояний. */ -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import org.springframework.data.jpa.repository.JpaRepository -import java.time.LocalDateTime -import java.util.UUID - -interface TrackedPlanJpaRepository : JpaRepository { - /** Возвращает отслеживаемые планы КА в указанном статусе. */ - fun findAllBySpacecraftIdAndStatus(spacecraftId: String, status: TrackedPlanStatus): List - - /** Возвращает все tracked-планы КА, упорядоченные по времени старта. */ - fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List - - /** Возвращает планы, подтверждённые с указанного момента, упорядоченные по времени старта. */ - fun findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime( - spacecraftId: String, - status: TrackedPlanStatus, - confirmedAt: LocalDateTime - ): List - - /** Возвращает отслеживаемый план по его бизнес-полям и статусу. */ - fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String, - status: TrackedPlanStatus - ): TrackedPlanEntity? - - /** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */ - fun findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String - ): TrackedPlanEntity? - - /** Возвращает последний подтверждённый отслеживаемый план для КА. */ - fun findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc( - spacecraftId: String, - status: TrackedPlanStatus - ): TrackedPlanEntity? -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt index b61cea8..18d5a6f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt @@ -1,11 +1,14 @@ package space.nstart.pcp_tgu_service.service +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity @@ -18,7 +21,11 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaReposit import java.time.LocalDateTime /** - * Обрабатывает decision events идемпотентно по eventId и строго по attemptId. + * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId. + * + * - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается. + * - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ + * запускается срочная перевыдача ([reissueOnNearestWindow]). */ @Service class HandlePlanDecisionUseCase( @@ -26,15 +33,15 @@ class HandlePlanDecisionUseCase( private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val plannedPlanRepository: PlannedPlanRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, - private val planRecalculationJobService: PlanRecalculationJobService, private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService, - private val planningProperties: space.nstart.pcp_tgu_service.config.PlanningProperties + private val planningProperties: PlanningProperties ) { - /** Применяет решение; повтор того же eventId является успешным no-op. */ + private val log = LoggerFactory.getLogger(javaClass) + + /** Применяет решение по attempt; повтор того же eventId — успешный no-op. */ @Transactional fun handle(message: PlanDecisionMessage) { - val inserted = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) - if (inserted == 0) { + if (!claimEvent(message.eventId)) { return } @@ -72,6 +79,50 @@ class HandlePlanDecisionUseCase( } } + /** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */ + @Transactional + fun handleLayinFailed(message: PlanExecutionFeedbackMessage) { + applyNegativeFeedback(message, PlannedPlanStatus.LAYIN_FAILED) + } + + /** Событие «не принят» по базовому плану — пока поведение идентично «не заложен». */ + @Transactional + fun handleNotAccepted(message: PlanExecutionFeedbackMessage) { + applyNegativeFeedback(message, PlannedPlanStatus.NOT_ACCEPTED) + } + + private fun applyNegativeFeedback(message: PlanExecutionFeedbackMessage, terminalStatus: PlannedPlanStatus) { + if (!claimEvent(message.eventId)) { + return + } + + val plan = plannedPlanRepository.lockByPlanId(message.planId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found") + if (plan.spacecraftId != message.spacecraftId) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Feedback spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}" + ) + } + + val state = getOrCreateLockedState(message.spacecraftId) + if (state.currentBasePlanId != plan.planId || plan.status != PlannedPlanStatus.AWAITING_LAYIN) { + // Событие пришло не по текущему базовому плану (устарело/дубль логики) — ничего не делаем. + log.info( + "Negative feedback ignored: plan {} is not the current AWAITING_LAYIN base (status={}, base={}).", + plan.planId, plan.status, state.currentBasePlanId + ) + return + } + + val now = LocalDateTime.now() + plan.status = terminalStatus + plan.updatedAt = now + plannedPlanRepository.save(plan) + + reissueOnNearestWindow(state, plan, now) + } + private fun recordDecisionOnAttempt( message: PlanDecisionMessage, attempt: PlanIssueAttemptEntity @@ -96,20 +147,20 @@ class HandlePlanDecisionUseCase( val now = LocalDateTime.now() recordDecisionOnAttempt(message, attempt) - plan.status = PlannedPlanStatus.ACCEPTED + plan.status = PlannedPlanStatus.AWAITING_LAYIN plan.acceptedAt = message.decisionTime plan.updatedAt = now state.currentBasePlanId = plan.planId state.activePlanId = null state.activeAttemptId = null + state.urgentReissueFrom = null state.chainVersion += 1 state.updatedAt = now planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) spacecraftPlanningStateJpaRepository.save(state) - planRecalculationJobService.enqueue(message.spacecraftId, "PLAN_ACCEPTED") acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) } @@ -122,44 +173,38 @@ class HandlePlanDecisionUseCase( val now = LocalDateTime.now() recordDecisionOnAttempt(message, attempt) - state.activePlanId = null - state.activeAttemptId = null - state.updatedAt = now - - if (now < plan.startTime.minusMinutes(planningProperties.minReissueLeadMinutes)) { - plan.status = PlannedPlanStatus.PLANNED - plan.issueNotBefore = now - plan.updatedAt = now - } else { - plan.status = PlannedPlanStatus.EXPIRED - plan.updatedAt = now - promoteNextPlanOrEnqueueRebuild(plan.spacecraftId, now) - } + plan.status = PlannedPlanStatus.REJECTED + plan.updatedAt = now planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) - spacecraftPlanningStateJpaRepository.save(state) + + reissueOnNearestWindow(state, plan, now) } /** - * Decision use case только меняет state. Новую попытку позже создаст IssueDuePlanWorker. + * Общий маршрут после негатива (REJECTED/«не заложен»/«не принят»): снимает активную выдачу + * и взводит срочную перевыдачу на ближайшую ЗРВ. Базовый указатель не трогаем — проекция + * сама подберёт ближайшее окно (для принятого-и-провалившегося плана он же служит якорем цепочки). */ - private fun promoteNextPlanOrEnqueueRebuild(spacecraftId: String, now: LocalDateTime) { - val nextPlan = plannedPlanRepository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId = spacecraftId, - status = PlannedPlanStatus.PLANNED, - startTime = now + private fun reissueOnNearestWindow( + state: SpacecraftPlanningStateEntity, + failedPlan: PlannedPlanEntity, + now: LocalDateTime + ) { + state.activePlanId = null + state.activeAttemptId = null + state.urgentReissueFrom = maxOf( + now.plusMinutes(planningProperties.reissuePrepMinutes), + failedPlan.startTime ) - - if (nextPlan == null) { - planRecalculationJobService.enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") - } else { - nextPlan.issueNotBefore = now - nextPlan.updatedAt = now - plannedPlanRepository.save(nextPlan) - } + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) } + private fun claimEvent(eventId: java.util.UUID): Boolean = + processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0 + private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt new file mode 100644 index 0000000..7583db4 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime + +/** + * На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА. + * + * Дальше [VisibilityRefreshWorker] обновит снапшоты, а будущая цепочка пересоберётся + * через on-demand [PlanProjectionService]. + */ +@Service +class HandleStationParamsChangedUseCase( + private val processedPlanDecisionEventRepository: ProcessedPlanDecisionEventRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val visibilityRefreshJobService: VisibilityRefreshJobService +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Идемпотентно по eventId; ставит refresh job по всем активным КА. */ + @Transactional + fun handle(message: StationParamsChangedMessage) { + val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0 + if (!claimed) { + return + } + + val active = spacecraftSnapshotRepository.findAllByActiveTrue() + active.forEach { snapshot -> + visibilityRefreshJobService.enqueue(snapshot.spacecraftId, snapshot.noradId, REASON) + } + log.info( + "Station params changed (stationId={}): enqueued visibility refresh for {} active spacecraft.", + message.stationId, active.size + ) + } + + private companion object { + const val REASON = "STATION_PARAMS_CHANGED" + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index 8f89117..79d8968 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus @@ -16,28 +17,34 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.UUID /** - * Выдаёт due plans из БД через попытки и короткий BPMN process per attempt. + * Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ + * ([PlanProjectionService]) и материализуется в planned_plan ровно в момент выдачи (статус ISSUING). */ @Service class IssueDuePlanWorker( private val planningProperties: PlanningProperties, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, private val plannedPlanRepository: PlannedPlanRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val planProjectionService: PlanProjectionService, private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter, private val transactionTemplate: TransactionTemplate ) { private val log = LoggerFactory.getLogger(javaClass) - /** Периодически claim'ит due plans и стартует BPMN вне DB transaction. */ + /** Периодически по активным КА считает голову проекции и выдаёт дозревший план. */ @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") fun issueDuePlans() { - val prepared = transactionTemplate.execute { prepareDuePlans(limit = 10) }.orEmpty() - prepared.forEach { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + spacecraftSnapshotRepository.findAllByActiveTrue().forEach { snapshot -> + val prepared = transactionTemplate.execute { prepareDueForSpacecraft(snapshot.spacecraftId) } + prepared?.let { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + } } /** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */ @@ -47,50 +54,103 @@ class IssueDuePlanWorker( return startPreparedAttempt(prepared) } - private fun prepareDuePlans(limit: Int): List { + /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ + private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { val now = LocalDateTime.now() - val issueBefore = now.plusMinutes(planningProperties.notificationBeforeStartMinutes) - return plannedPlanRepository.findDueForUpdate(now, issueBefore, limit) - .mapNotNull { plan -> preparePlan(plan, now) } + val state = getOrCreateLockedState(spacecraftId) + if (state.activeAttemptId != null) { + return null + } + + val projection = planProjectionService.project(spacecraftId) + if (projection.isEmpty()) { + return null + } + + val urgentFrom = state.urgentReissueFrom + val head = if (urgentFrom != null) { + projection.firstOrNull { plan -> !plan.startTime.isBefore(urgentFrom) } + } else { + projection.first() + } ?: return null + + if (!head.startTime.isAfter(now)) { + return null + } + + val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes) + val due = urgentFrom != null || !now.isBefore(issueNotBefore) + if (!due) { + return null + } + + val plan = materializeIssuingPlan(head, state, issueNotBefore, now) + val attempt = createStartingAttempt(plan.planId, now) + + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) } private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { val now = LocalDateTime.now() val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null - if (plan.status !in setOf(PlannedPlanStatus.PLANNED, PlannedPlanStatus.REJECTED)) { - return null - } - return preparePlan(plan, now) - } - - private fun preparePlan(plan: PlannedPlanEntity, now: LocalDateTime): PreparedAttempt? { val state = getOrCreateLockedState(plan.spacecraftId) if (state.activeAttemptId != null) { return null } + if (plan.status !in REISSUABLE_STATUSES) { + return null + } - val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)?.attemptNo ?: 0) + 1 - val attempt = planIssueAttemptRepository.save( + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + val attempt = createStartingAttempt(plan.planId, now) + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + private fun materializeIssuingPlan( + head: CalculatedPlan, + state: SpacecraftPlanningStateEntity, + issueNotBefore: LocalDateTime, + now: LocalDateTime + ): PlannedPlanEntity = + plannedPlanRepository.insertIssuingPlan( + planId = UUID.randomUUID(), + spacecraftId = head.spacecraftId, + startTime = head.startTime, + endTime = head.endTime, + kppId = head.kppId, + issueNotBefore = issueNotBefore, + basePlanId = state.currentBasePlanId, + chainVersion = state.chainVersion, + now = now + ) + + private fun createStartingAttempt(planId: UUID, now: LocalDateTime): PlanIssueAttemptEntity { + val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)?.attemptNo ?: 0) + 1 + return planIssueAttemptRepository.save( PlanIssueAttemptEntity( attemptId = UUID.randomUUID(), - planId = plan.planId, + planId = planId, attemptNo = attemptNo, status = PlanIssueAttemptStatus.STARTING, createdAt = now, updatedAt = now ) ) - - plan.status = PlannedPlanStatus.ISSUING - plan.updatedAt = now - plannedPlanRepository.save(plan) - - state.activePlanId = plan.planId - state.activeAttemptId = attempt.attemptId - state.updatedAt = now - spacecraftPlanningStateJpaRepository.save(state) - - return PreparedAttempt(plan.planId, attempt.attemptId) } private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse { @@ -159,4 +219,15 @@ class IssueDuePlanWorker( val planId: UUID, val attemptId: UUID ) + + private companion object { + /** Статусы persisted-плана, который допустимо выдать вручную через issuePlanNow (test-only). */ + val REISSUABLE_STATUSES = setOf( + PlannedPlanStatus.PLANNED, + PlannedPlanStatus.REJECTED, + PlannedPlanStatus.LAYIN_FAILED, + PlannedPlanStatus.NOT_ACCEPTED, + PlannedPlanStatus.START_AMBIGUOUS + ) + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index 93f593d..0f82246 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -48,6 +48,23 @@ class PlanCalculationService( fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) + + if (candidates.isEmpty()) { + return emptyList() + } + + // Базовый (принятый) план полностью в прошлом относительно доступных окон — он + // больше не ограничивает будущее (нахлёст с ним невозможен и не нужен). Строим + // свежую цепочку с самого раннего окна. Иначе проекция схлопнулась бы в пустоту. + if (previousPlan.endTime <= candidates.first().startTime) { + return buildChain( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + fromStartIndex = 0, + initialLowerEndBound = null + ) + } + /** Индекс кандидата, с которого начинался предыдущий план. */ val previousStartIndex: Int = candidates.indexOfFirst { candidate -> candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime @@ -85,7 +102,14 @@ class PlanCalculationService( } if (fromStartIndex !in candidates.indices) { - return emptyList() + // База перекрывается с окнами, но якорь хвоста подобрать не удалось — не + // схлопываемся в пустоту, а строим свежую цепочку с самого раннего окна. + return buildChain( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + fromStartIndex = 0, + initialLowerEndBound = null + ) } return buildChain( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt new file mode 100644 index 0000000..64b7d3b --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt @@ -0,0 +1,51 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository + +/** + * Считает будущую цепочку планов КА на лету из последнего снапшота ЗРВ и текущей базы. + * + * Проекция нигде не сохраняется: это детерминированная функция от (снапшот + база + конфиг). + * Её используют read-путь (UI) и воркер выдачи, чтобы понять, какой план выдавать следующим. + */ +@Service +class PlanProjectionService( + private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val plannedPlanRepository: PlannedPlanRepository, + private val visibilityPayloadParser: VisibilityPayloadParser, + private val planCalculationService: PlanCalculationService +) { + /** Полная проекция будущей цепочки для КА; пусто, если нет снапшота или окон. */ + @Transactional(readOnly = true) + fun project(spacecraftId: String): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + + val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) + val basePlan = state?.currentBasePlanId + ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + + return if (basePlan == null) { + planCalculationService.calculatePlans(points) + } else { + planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) + } + } + + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = + CalculatedPlan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index 8ae5027..c3980d7 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -4,32 +4,51 @@ import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.UUID /** - * Read-only query service для persisted planned_plan. + * Read-сервис планов: объединяет persisted (выданные, история — вечно) с вычисленной + * на лету проекцией будущего ([PlanProjectionService]). Проекция в БД не пишется. */ @Service class PlanQueryService( - private val plannedPlanRepository: PlannedPlanRepository + private val plannedPlanRepository: PlannedPlanRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val planProjectionService: PlanProjectionService ) { - /** Возвращает все persisted планы с отсечением глубокой истории. */ + /** Все планы (persisted ∪ проекция) с отсечением глубокой истории по `historyDays`. */ @Transactional(readOnly = true) - fun getAllPlans(historyDays: Long): List { - val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) - return plannedPlanRepository.findAllByOrderByStartTime() + fun getAllPlans(historyDays: Long): List { + val historySince = historySince(historyDays) + val persisted = plannedPlanRepository.findAllByOrderByStartTime() .filter { plan -> plan.endTime >= historySince } + val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } + + val projected = spacecraftSnapshotRepository.findAllByActiveTrue() + .flatMap { snapshot -> planProjectionService.project(snapshot.spacecraftId) } + .filter { plan -> plan.businessKey() !in persistedKeys } + + return combine(persisted, projected) } - /** Возвращает persisted планы одного КА с отсечением глубокой истории. */ + /** Планы одного КА (persisted ∪ проекция) с отсечением глубокой истории. */ @Transactional(readOnly = true) - fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { - val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) - return plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) + fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { + val historySince = historySince(historyDays) + val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) .filter { plan -> plan.endTime >= historySince } + val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } + + val projected = planProjectionService.project(spacecraftId) + .filter { plan -> plan.businessKey() !in persistedKeys } + + return combine(persisted, projected) } /** Возвращает persisted plan по ID для test-only/debug API. */ @@ -37,4 +56,17 @@ class PlanQueryService( fun getByPlanId(planId: UUID): PlannedPlanEntity = plannedPlanRepository.findById(planId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") } + + private fun combine(persisted: List, projected: List): List = + (persisted.map(PlanResponse::from) + projected.map(PlanResponse::projected)) + .sortedBy { response -> response.startTime } + + private fun historySince(historyDays: Long): LocalDateTime = + LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + + private fun PlannedPlanEntity.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" + + private fun CalculatedPlan.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt deleted file mode 100644 index 1a59b39..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt +++ /dev/null @@ -1,20 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import java.time.LocalDateTime - -/** - * Coalesce-сервис для plan_recalculation_job по spacecraftId. - */ -@Service -class PlanRecalculationJobService( - private val planRecalculationJobRepository: PlanRecalculationJobRepository -) { - /** Ставит или переоткрывает job перестроения расписания без дублей. */ - @Transactional - fun enqueue(spacecraftId: String, reason: String) { - planRecalculationJobRepository.upsertPending(spacecraftId, reason, LocalDateTime.now()) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt deleted file mode 100644 index a5879e2..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt +++ /dev/null @@ -1,85 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Service -import org.springframework.transaction.support.TransactionTemplate -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.entity.JobStatus -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import java.net.InetAddress -import java.time.LocalDateTime - -/** - * Claim'ит jobs перестроения расписания и вызывает RebuildPlanScheduleUseCase. - */ -@Service -class PlanRecalculationWorker( - private val planningProperties: PlanningProperties, - private val planRecalculationJobRepository: PlanRecalculationJobRepository, - private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, - private val transactionTemplate: TransactionTemplate -) { - private val log = LoggerFactory.getLogger(javaClass) - private val workerId = "${InetAddress.getLocalHost().hostName}-${java.util.UUID.randomUUID()}" - - /** Периодически обрабатывает pending recalculation jobs. */ - @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") - fun processPendingJobs() { - val jobs = claimJobs(limit = 10) - jobs.forEach { job -> processJob(job.spacecraftId) } - } - - private fun claimJobs(limit: Int): List = - transactionTemplate.execute { - val now = LocalDateTime.now() - val jobs = planRecalculationJobRepository.findPendingForUpdate(now, limit) - jobs.forEach { job -> - job.status = JobStatus.RUNNING - job.lockedBy = workerId - job.lockedUntil = now.plusSeconds(planningProperties.workerLockTtlSeconds) - job.attempts += 1 - job.updatedAt = now - } - planRecalculationJobRepository.saveAll(jobs) - jobs - }.orEmpty() - - private fun processJob(spacecraftId: String) { - try { - rebuildPlanScheduleUseCase.rebuild(spacecraftId) - markDone(spacecraftId) - } catch (ex: Exception) { - log.warn("Plan recalculation job failed. spacecraftId={}", spacecraftId, ex) - releaseOrFail(spacecraftId) - } - } - - private fun markDone(spacecraftId: String) { - transactionTemplate.executeWithoutResult { - val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult - job.status = if (job.rerunRequested) JobStatus.PENDING else JobStatus.DONE - job.rerunRequested = false - job.lockedBy = null - job.lockedUntil = null - job.updatedAt = LocalDateTime.now() - planRecalculationJobRepository.save(job) - } - } - - private fun releaseOrFail(spacecraftId: String) { - transactionTemplate.executeWithoutResult { - val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult - job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING - job.lockedBy = null - job.lockedUntil = null - job.updatedAt = LocalDateTime.now() - planRecalculationJobRepository.save(job) - } - } - - private companion object { - const val MAX_ATTEMPTS = 3 - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt deleted file mode 100644 index b63953e..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt +++ /dev/null @@ -1,113 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.CalculatedPlan -import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity -import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity -import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser -import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository -import java.time.LocalDateTime -import java.util.UUID - -/** - * Перестраивает persisted расписание планов из последнего raw visibility snapshot. - */ -@Service -class RebuildPlanScheduleUseCase( - private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, - private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, - private val plannedPlanRepository: PlannedPlanRepository, - private val visibilityPayloadParser: VisibilityPayloadParser, - private val planCalculationService: PlanCalculationService, - private val planningProperties: PlanningProperties -) { - /** Rebuild по одному КА: upsert сохраняет стабильный planId для business key. */ - @Transactional - fun rebuild(spacecraftId: String) { - val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId) - .orElseThrow { IllegalStateException("Visibility snapshot for spacecraftId=$spacecraftId was not found") } - val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) - val state = getOrCreateState(spacecraftId) - val basePlan = state.currentBasePlanId?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } - - val calculatedPlans = if (basePlan == null) { - planCalculationService.calculatePlans(points) - } else { - planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) - } - - val now = LocalDateTime.now() - val chainVersion = state.chainVersion + 1 - val savedPlans = calculatedPlans.map { calculatedPlan -> - upsertPlan(calculatedPlan, state.currentBasePlanId, chainVersion, now) - } - - supersedeObsoleteFuturePlans( - spacecraftId = spacecraftId, - calculatedBusinessKeys = savedPlans.map { plan -> plan.businessKey() }.toSet(), - now = now - ) - state.chainVersion = chainVersion - state.updatedAt = now - spacecraftPlanningStateJpaRepository.save(state) - } - - private fun getOrCreateState(spacecraftId: String): SpacecraftPlanningStateEntity { - spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) - ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) - return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId) - ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create") - } - - private fun upsertPlan( - calculatedPlan: CalculatedPlan, - basePlanId: UUID?, - chainVersion: Long, - now: LocalDateTime - ): PlannedPlanEntity = - plannedPlanRepository.upsertPlannedByBusinessKey( - planId = UUID.randomUUID(), - spacecraftId = calculatedPlan.spacecraftId, - startTime = calculatedPlan.startTime, - endTime = calculatedPlan.endTime, - kppId = calculatedPlan.kppId, - issueNotBefore = calculatedPlan.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes), - basePlanId = basePlanId, - chainVersion = chainVersion, - now = now - ) - - private fun supersedeObsoleteFuturePlans( - spacecraftId: String, - calculatedBusinessKeys: Set, - now: LocalDateTime - ) { - plannedPlanRepository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( - spacecraftId = spacecraftId, - status = PlannedPlanStatus.PLANNED, - endTime = now - ) - .filter { plan -> plan.businessKey() !in calculatedBusinessKeys } - .forEach { plan -> - plan.status = PlannedPlanStatus.SUPERSEDED - plan.updatedAt = now - plannedPlanRepository.save(plan) - } - } - - private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = - CalculatedPlan( - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId - ) - - private fun PlannedPlanEntity.businessKey(): String = - "$spacecraftId|$startTime|$endTime|$kppId" -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt index 2836a6e..7dde8c8 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt @@ -9,16 +9,16 @@ import java.security.MessageDigest import java.time.LocalDateTime /** - * Сохраняет raw visibility payload и ставит recalculation только при изменении hash. + * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота + * ([PlanProjectionService]), поэтому отдельный triggers пересчёта здесь не нужен. */ @Service class SpacecraftVisibilitySnapshotService( private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, - private val visibilityPayloadParser: VisibilityPayloadParser, - private val planRecalculationJobService: PlanRecalculationJobService + private val visibilityPayloadParser: VisibilityPayloadParser ) { /** - * Сохраняет snapshot. Recalculation job создаётся только для нового или изменившегося payload. + * Сохраняет snapshot. Hash используется только для метаданных и диагностики изменений окон. */ @Transactional fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) { @@ -42,7 +42,6 @@ class SpacecraftVisibilitySnapshotService( updatedAt = now ) ) - planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") return } @@ -60,10 +59,6 @@ class SpacecraftVisibilitySnapshotService( } spacecraftVisibilitySnapshotRepository.save(existing) - - if (changed) { - planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") - } } private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata { diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 3b44568..14e21ef 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -14,19 +14,23 @@ spring: planning: notification-before-start-minutes: 263 - target-plan-duration-minutes: 180 - target-plan-duration-delta-minutes: 60 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 external-update-enabled: true - external-points-lookahead-days: 3 + external-points-lookahead-days: 10 in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + layin-failed: pcp.tgu.layin-failed.v1 + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 + station-params-changed: pcp.tgu.station-params-changed.v1 tgu: test-controller: diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql new file mode 100644 index 0000000..03cc7d6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql @@ -0,0 +1,5 @@ +-- В on-demand-модели планы не апсертятся по бизнес-ключу: при перевыдаче того же окна +-- (после REJECTED/негатива) создаётся новая строка с новым plan_id, а старая остаётся в истории. +-- Поэтому уникальность по (spacecraft_id, start_time, end_time, kpp_id) больше не нужна. +ALTER TABLE planned_plan + DROP CONSTRAINT IF EXISTS uq_planned_plan_business_key; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql new file mode 100644 index 0000000..f0c51f6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql @@ -0,0 +1,11 @@ +-- Чистка легаси: до редизайна старый код материализовал всю будущую цепочку в planned_plan +-- со статусом PLANNED. Новая модель «храним только выданное» проекцию в БД не пишет — будущее +-- считается на лету. Persisted-строк в статусе PLANNED как класса больше не существует. +-- +-- Эти строки и порождали видимые «дубликаты» в UI: persisted-PLANNED (микросекунды) показывался +-- рядом со своим проекционным двойником (наносекунды), не схлопываясь по бизнес-ключу. +-- +-- Удаляем только PLANNED. Выданная история (ISSUING/WAITING_DECISION/ACCEPTED/AWAITING_LAYIN/ +-- LAID_IN/REJECTED/LAYIN_FAILED/NOT_ACCEPTED/SUPERSEDED/EXPIRED и пр.) не затрагивается. +DELETE FROM planned_plan +WHERE status = 'PLANNED'; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql new file mode 100644 index 0000000..85ee844 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql @@ -0,0 +1,19 @@ +-- Чистка легаси статусов SUPERSEDED/EXPIRED. +-- +-- В старой модели вся будущая цепочка материализовалась в planned_plan как PLANNED-строки. +-- SUPERSEDED гасил будущие планы, выпавшие из пересчитанной цепочки (это делал ныне удалённый +-- RebuildPlanScheduleUseCase); EXPIRED помечал неизданный план, чьё окно прошло. +-- +-- Новая модель «храним только выданное, будущее считаем на лету» проекцию в БД не пишет: +-- перекрывать (supersede) нечего, а неизданный просроченный план строкой никогда не был. +-- Оба статуса в коде больше не присваиваются (убраны из PlannedPlanStatus). +-- +-- Эти строки — не выданная история, а легаси-бухгалтерия будущего, поэтому удаляем безвозвратно. +-- Выданная история (ISSUING/WAITING_DECISION/ACCEPTED/AWAITING_LAYIN/LAID_IN/REJECTED/ +-- LAYIN_FAILED/NOT_ACCEPTED/START_AMBIGUOUS) не затрагивается. +DELETE FROM planned_plan +WHERE status IN ('SUPERSEDED', 'EXPIRED'); + +-- Столбец под supersede-флоу больше не используется (поле supersededByPlanId удалено из сущности). +ALTER TABLE planned_plan + DROP COLUMN IF EXISTS superseded_by_plan_id; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql new file mode 100644 index 0000000..1ce8ff1 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql @@ -0,0 +1,2 @@ +-- Legacy-таблица из V1/V2: заменена на planned_plan (V4), в коде не используется. +DROP TABLE IF EXISTS tracked_plan; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql new file mode 100644 index 0000000..e21fd14 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql @@ -0,0 +1,2 @@ +-- Расписание считается on-demand из снапшота ЗРВ; отдельная очередь пересчёта больше не нужна. +DROP TABLE IF EXISTS plan_recalculation_job; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql new file mode 100644 index 0000000..b8591a2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql @@ -0,0 +1,4 @@ +-- Время, начиная с которого нужно срочно выдать следующий план после негативного события +-- (REJECTED / «не заложен» / «не принят»). NULL — срочной перевыдачи не требуется. +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL; diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index df21568..d90e0a4 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -114,6 +114,7 @@ class ExternalPointsClientTest { externalUpdateFixedDelayMs = 0, notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, + reissuePrepMinutes = 30, workerLockTtlSeconds = 300 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt new file mode 100644 index 0000000..87be7b4 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt @@ -0,0 +1,36 @@ +package space.nstart.pcp_tgu_service.integration.api + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class VisibilityPayloadParserTest { + + private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) + + @Test + fun `truncates window times to microseconds to match persisted timestamp precision`() { + // Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан + // обрезать хвост, иначе проекция и persisted-планы не совпадут по бизнес-ключу. + val payload = """ + [{ + "noradId": 20, + "stationId": 102, + "onStart": {"time": "2026-06-02T14:14:08.548125028Z", "elevation": 5.0}, + "onStop": {"time": "2026-06-03T14:01:27.615390539Z", "elevation": 5.0}, + "onMaximum": {"time": "2026-06-02T20:00:00Z", "elevation": 45.0} + }] + """.trimIndent() + + val points = parser.parse("20", payload) + val window = points.points.single().observationWindows.single() + + assertEquals(0, window.startTime.nano % 1_000, "наносекундный остаток должен быть обрезан") + assertEquals(0, window.endTime.nano % 1_000, "наносекундный остаток должен быть обрезан") + // Микросекундная часть сохраняется как есть. + assertEquals(548_125_000, window.startTime.nano) + assertEquals(615_390_000, window.endTime.nano) + assertEquals(LocalDateTime::class.java, window.startTime.javaClass) + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt index 85bcc69..52f6246 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest { starter: AcceptedPlanSlotsUpdateProcessStarter ): AcceptedPlanSlotsUpdateWorker = AcceptedPlanSlotsUpdateWorker( - planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300), + planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300), acceptedPlanSlotsUpdateJobRepository = repository, acceptedPlanSlotsUpdateProcessStarter = starter, transactionTemplate = transactionTemplate() diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt new file mode 100644 index 0000000..6c4d131 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt @@ -0,0 +1,52 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +class HandleStationParamsChangedUseCaseTest { + + @Test + fun `enqueues visibility refresh for every active spacecraft once per event`() { + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf( + SpacecraftSnapshotEntity(spacecraftId = "1", noradId = 100L), + SpacecraftSnapshotEntity(spacecraftId = "2", noradId = 200L) + ) + ) + val refreshJobService = mock(VisibilityRefreshJobService::class.java) + val useCase = HandleStationParamsChangedUseCase(processedRepository, snapshotRepository, refreshJobService) + val message = message() + + useCase.handle(message) + useCase.handle(message) + + verify(refreshJobService, times(1)).enqueue("1", 100L, "STATION_PARAMS_CHANGED") + verify(refreshJobService, times(1)).enqueue("2", 200L, "STATION_PARAMS_CHANGED") + } + + private fun message(): StationParamsChangedMessage = + StationParamsChangedMessage( + eventId = UUID.randomUUID(), + stationId = "ST-7", + occurredAt = LocalDateTime.now(), + reason = "OUT_OF_SERVICE" + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt new file mode 100644 index 0000000..c2c381c --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -0,0 +1,220 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class IssueDuePlanWorkerOnDemandTest { + + private val spacecraftId = "25544" + + @Test + fun `materializes due projection head as ISSUING and starts one attempt`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + // issueNotBefore = start - 30m; start=now+10m → issueNotBefore in the past → due now. + val head = plan(now().plusMinutes(10), now().plusHours(3)) + val worker = worker(planStore, attemptStore, state, projection = listOf(head)) + + worker.issueDuePlans() + + assertEquals(1, planStore.size) + val materialized = planStore.values.single() + assertEquals(head.startTime, materialized.startTime) + // markStarted перевёл выданный план в WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, materialized.status) + assertNotNull(state.activeAttemptId) + assertEquals(1, attemptStore.size) + } + + @Test + fun `does not issue when projection head is not due yet`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + // start far in the future → issueNotBefore well after now → not due. + val head = plan(now().plusHours(10), now().plusHours(13)) + val worker = worker(planStore, attemptStore, state, projection = listOf(head)) + + worker.issueDuePlans() + + assertTrue(planStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `urgent reissue issues nearest window past the floor immediately and clears the flag`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val floor = now().plusMinutes(30) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, urgentReissueFrom = floor) + val beforeFloor = plan(now().plusMinutes(10), now().plusHours(3)) + val afterFloor = plan(now().plusMinutes(40), now().plusHours(4)) + val worker = worker(planStore, attemptStore, state, projection = listOf(beforeFloor, afterFloor)) + + worker.issueDuePlans() + + val materialized = planStore.values.single() + assertEquals(afterFloor.startTime, materialized.startTime) + assertNull(state.urgentReissueFrom) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `skips spacecraft that already has an active attempt`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activeAttemptId = UUID.randomUUID()) + val head = plan(now().plusMinutes(10), now().plusHours(3)) + val projection = mock(PlanProjectionService::class.java) + `when`(projection.project(spacecraftId)).thenReturn(listOf(head)) + val worker = worker(planStore, attemptStore, state, projectionService = projection) + + worker.issueDuePlans() + + assertTrue(planStore.isEmpty()) + verify(projection, never()).project(spacecraftId) + } + + private fun worker( + planStore: MutableMap, + attemptStore: MutableMap, + state: SpacecraftPlanningStateEntity, + projection: List = emptyList(), + projectionService: PlanProjectionService = projectionReturning(projection) + ): IssueDuePlanWorker { + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L)) + ) + val starter = mock(BpmnPlanIssueProcessStarter::class.java) + `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val p = invocation.arguments[0] as PlannedPlanEntity + val a = invocation.arguments[1] as PlanIssueAttemptEntity + ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) + } + return IssueDuePlanWorker( + planningProperties = planningProperties(), + spacecraftSnapshotRepository = snapshotRepository, + plannedPlanRepository = plannedRepository(planStore), + planIssueAttemptRepository = attemptRepository(attemptStore), + spacecraftPlanningStateJpaRepository = stateRepository(state), + planProjectionService = projectionService, + bpmnPlanIssueProcessStarter = starter, + transactionTemplate = transactionTemplate() + ) + } + + private fun projectionReturning(projection: List): PlanProjectionService { + val service = mock(PlanProjectionService::class.java) + `when`(service.project(spacecraftId)).thenReturn(projection) + return service + } + + private fun plannedRepository(store: MutableMap): PlannedPlanRepository { + val repository = mock(PlannedPlanRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val plan = invocation.arguments[0] as PlannedPlanEntity + store[plan.planId] = plan + plan + } + `when`( + repository.insertIssuingPlan( + anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), + anyNonNull(), anyOrNull(), org.mockito.ArgumentMatchers.anyLong(), anyNonNull() + ) + ).thenAnswer { invocation -> + val entity = PlannedPlanEntity( + planId = invocation.arguments[0] as UUID, + spacecraftId = invocation.arguments[1] as String, + startTime = invocation.arguments[2] as LocalDateTime, + endTime = invocation.arguments[3] as LocalDateTime, + kppId = invocation.arguments[4] as String, + status = PlannedPlanStatus.ISSUING, + issueNotBefore = invocation.arguments[5] as LocalDateTime + ) + store[entity.planId] = entity + entity + } + return repository + } + + private fun attemptRepository(store: MutableMap): PlanIssueAttemptRepository { + val repository = mock(PlanIssueAttemptRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.findTopByPlanIdOrderByAttemptNoDesc(anyNonNull())).thenAnswer { invocation -> + store.values.filter { attempt -> attempt.planId == invocation.arguments[0] }.maxByOrNull { attempt -> attempt.attemptNo } + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val attempt = invocation.arguments[0] as PlanIssueAttemptEntity + store[attempt.attemptId] = attempt + attempt + } + return repository + } + + private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { + val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) + `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + return repository + } + + private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun now(): LocalDateTime = LocalDateTime.now() + + private fun planningProperties(): PlanningProperties = + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300) + + private fun transactionTemplate(): TransactionTemplate = + TransactionTemplate( + object : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + override fun commit(status: TransactionStatus) = Unit + override fun rollback(status: TransactionStatus) = Unit + } + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + @Suppress("UNCHECKED_CAST") + private fun anyOrNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt deleted file mode 100644 index 506b63e..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import org.springframework.data.jpa.repository.Query -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.TransactionStatus -import org.springframework.transaction.support.SimpleTransactionStatus -import org.springframework.transaction.support.TransactionTemplate -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.entity.JobStatus -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import space.nstart.pcp_tgu_service.entity.VisibilityRefreshJobEntity -import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository -import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository -import java.time.LocalDateTime -import java.util.Optional - -class JobRerunFlowTest { - - @Test - fun `enqueue does not reset running visibility job lock`() { - val query = visibilityUpsertQuery() - - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.status")) - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_by")) - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_until")) - } - - @Test - fun `enqueue does not reset running recalculation job lock`() { - val query = recalculationUpsertQuery() - - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status")) - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by")) - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until")) - } - - @Test - fun `enqueue running job sets rerunRequested to true`() { - assertTrue( - visibilityUpsertQuery() - .contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN TRUE") - ) - assertTrue( - recalculationUpsertQuery() - .contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE") - ) - } - - @Test - fun `visibility markDone with rerunRequested moves job to pending`() { - val spacecraftId = "SC-1" - val jobStore = linkedMapOf( - spacecraftId to VisibilityRefreshJobEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - status = JobStatus.PENDING, - rerunRequested = true - ) - ) - val repository = visibilityJobRepository(jobStore) - val externalPointsClient = mock(ExternalPointsClient::class.java) - val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) - `when`( - externalPointsClient.fetchRawVisibilityPayload( - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull(), - anyNonNull() - ) - ).thenReturn("[]") - val worker = VisibilityRefreshWorker( - planningProperties = planningProperties(), - visibilityRefreshJobRepository = repository, - spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), - externalPointsClient = externalPointsClient, - spacecraftVisibilitySnapshotService = snapshotService, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.PENDING, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `visibility markDone without rerunRequested moves job to done`() { - val spacecraftId = "SC-2" - val jobStore = linkedMapOf( - spacecraftId to VisibilityRefreshJobEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - status = JobStatus.PENDING - ) - ) - val repository = visibilityJobRepository(jobStore) - val externalPointsClient = mock(ExternalPointsClient::class.java) - val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) - `when`( - externalPointsClient.fetchRawVisibilityPayload( - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull(), - anyNonNull() - ) - ).thenReturn("[]") - val worker = VisibilityRefreshWorker( - planningProperties = planningProperties(), - visibilityRefreshJobRepository = repository, - spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), - externalPointsClient = externalPointsClient, - spacecraftVisibilitySnapshotService = snapshotService, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.DONE, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `recalculation markDone with rerunRequested moves job to pending`() { - val spacecraftId = "SC-3" - val jobStore = linkedMapOf( - spacecraftId to PlanRecalculationJobEntity( - spacecraftId = spacecraftId, - status = JobStatus.PENDING, - rerunRequested = true - ) - ) - val repository = recalculationJobRepository(jobStore) - val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) - val worker = PlanRecalculationWorker( - planningProperties = planningProperties(), - planRecalculationJobRepository = repository, - rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.PENDING, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `recalculation markDone without rerunRequested moves job to done`() { - val spacecraftId = "SC-4" - val jobStore = linkedMapOf( - spacecraftId to PlanRecalculationJobEntity( - spacecraftId = spacecraftId, - status = JobStatus.PENDING - ) - ) - val repository = recalculationJobRepository(jobStore) - val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) - val worker = PlanRecalculationWorker( - planningProperties = planningProperties(), - planRecalculationJobRepository = repository, - rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.DONE, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - private fun visibilityUpsertQuery(): String = - VisibilityRefreshJobRepository::class.java - .getMethod( - "upsertPending", - String::class.java, - java.lang.Long::class.java, - String::class.java, - LocalDateTime::class.java - ) - .getAnnotation(Query::class.java) - .value - - private fun recalculationUpsertQuery(): String = - PlanRecalculationJobRepository::class.java - .getMethod( - "upsertPending", - String::class.java, - String::class.java, - LocalDateTime::class.java - ) - .getAnnotation(Query::class.java) - .value - - private fun visibilityJobRepository( - store: MutableMap - ): VisibilityRefreshJobRepository { - val repository = mock(VisibilityRefreshJobRepository::class.java) - `when`( - repository.findPendingForUpdate( - anyNonNull(), - org.mockito.ArgumentMatchers.anyInt() - ) - ).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val limit = invocation.arguments[1] as Int - store.values - .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } - .sortedBy { job -> job.requestedAt } - .take(limit) - } - `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> - Optional.ofNullable(store[invocation.arguments[0]]) - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val job = invocation.arguments[0] as VisibilityRefreshJobEntity - store[job.spacecraftId] = job - job - } - `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> - val jobs = invocation.arguments[0] as Iterable - jobs.forEach { job -> store[job.spacecraftId] = job } - jobs.toList() - } - return repository - } - - private fun recalculationJobRepository( - store: MutableMap - ): PlanRecalculationJobRepository { - val repository = mock(PlanRecalculationJobRepository::class.java) - `when`( - repository.findPendingForUpdate( - anyNonNull(), - org.mockito.ArgumentMatchers.anyInt() - ) - ).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val limit = invocation.arguments[1] as Int - store.values - .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } - .sortedBy { job -> job.requestedAt } - .take(limit) - } - `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> - Optional.ofNullable(store[invocation.arguments[0]]) - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val job = invocation.arguments[0] as PlanRecalculationJobEntity - store[job.spacecraftId] = job - job - } - `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> - val jobs = invocation.arguments[0] as Iterable - jobs.forEach { job -> store[job.spacecraftId] = job } - jobs.toList() - } - return repository - } - - private fun isExpiredRunning(status: JobStatus, lockedUntil: LocalDateTime?, now: LocalDateTime): Boolean = - status == JobStatus.RUNNING && - lockedUntil != null && - lockedUntil.isBefore(now) - - private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) - - private fun transactionTemplate(): TransactionTemplate = - TransactionTemplate( - object : PlatformTransactionManager { - override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() - override fun commit(status: TransactionStatus) = Unit - override fun rollback(status: TransactionStatus) = Unit - } - ) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - org.mockito.ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index 5dde633..b3315df 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -72,6 +72,37 @@ class PlanCalculationServiceTest { assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime }) } + @Test + fun `calculatePlansAfter rebuilds a fresh chain when the base plan is entirely in the past`() { + // Регресс: при обновлении окна тянутся из [now, now+Nд], и принятый базовый план + // со временем уходит в прошлое за левый край окна. Тогда хвост нельзя сопоставить + // с базой — но расписание не должно схлопнуться в пустоту (иначе supersede погасил + // бы всё будущее). Должна строиться свежая цепочка с самого раннего окна. + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val futureStart = LocalDateTime.of(2026, 3, 10, 10, 0) + val spacecraftPoints = spacecraftPoints( + window("KPP-1", futureStart), + window("KPP-2", futureStart.plusHours(8)), + window("KPP-3", futureStart.plusHours(16)), + window("KPP-4", futureStart.plusHours(23).plusMinutes(55), 15), + window("KPP-5", futureStart.plusHours(24).plusMinutes(10)) + ) + // Базовый план целиком раньше любого доступного окна (состарился и выпал из горизонта). + val agedOutBasePlan = CalculatedPlan( + spacecraftId = "25544", + startTime = futureStart.minusDays(2), + endTime = futureStart.minusDays(1), + kppId = "KPP-OLD" + ) + + val plans = service.calculatePlansAfter(spacecraftPoints, agedOutBasePlan) + + assertChainInvariant(plans) + assertTrue(plans.isNotEmpty(), "расписание не должно схлопнуться в пустоту: $plans") + assertEquals(service.calculatePlans(spacecraftPoints), plans, "должна строиться та же свежая цепочка") + assertEquals(futureStart, plans.first().startTime) + } + @Test fun `calculatePlans returns empty list when only one window is available`() { val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L) @@ -307,6 +338,7 @@ class PlanCalculationServiceTest { externalUpdateFixedDelayMs = 0, notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, + reissuePrepMinutes = 30, workerLockTtlSeconds = 300 ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 32fd2f4..3f1e376 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -1,8 +1,10 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.Mockito.never @@ -16,6 +18,7 @@ import org.springframework.transaction.support.TransactionTemplate import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus @@ -26,6 +29,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.Optional import java.util.UUID @@ -33,198 +37,114 @@ import java.util.UUID class PlanIssueAndDecisionFlowTest { @Test - fun `issue worker creates one active attempt and repeated issue of same plan creates new attempt after state is cleared`() { + fun `issuePlanNow creates one active attempt and repeated manual issue creates a new attempt after state cleared`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED) val planStore = linkedMapOf(plan.planId to plan) val attemptStore = linkedMapOf() val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) - val plannedRepository = plannedRepository(planStore) - val attemptRepository = attemptRepository(attemptStore) - val stateRepository = stateRepository(state) - val starter = mock(BpmnPlanIssueProcessStarter::class.java) - `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> - val p = invocation.arguments[0] as PlannedPlanEntity - val a = invocation.arguments[1] as PlanIssueAttemptEntity - ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) - } - val worker = IssueDuePlanWorker( - planningProperties = planningProperties(), - plannedPlanRepository = plannedRepository, - planIssueAttemptRepository = attemptRepository, - spacecraftPlanningStateJpaRepository = stateRepository, - bpmnPlanIssueProcessStarter = starter, - transactionTemplate = transactionTemplate() - ) + val worker = issueWorker(planStore, attemptStore, state) val firstResponse = worker.issuePlanNow(plan.planId) - val blockedResponse = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() + val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() state.activePlanId = null state.activeAttemptId = null - plan.status = PlannedPlanStatus.PLANNED + plan.status = PlannedPlanStatus.REJECTED val secondResponse = worker.issuePlanNow(plan.planId) - assertEquals(1, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.attemptNo) - assertEquals(2, attemptStore.values.first { it.attemptId == secondResponse.attemptId }.attemptNo) + assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo) + assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo) assertNotEquals(firstResponse.attemptId, secondResponse.attemptId) - assertEquals(PlanIssueAttemptStatus.STARTED, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.status) - assertEquals(firstResponse.processInstanceKey, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.processInstanceKey) assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) - assertTrue(blockedResponse?.message?.startsWith("404 NOT_FOUND") == true) + assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true) } @Test - fun `mark started does not roll accepted plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.ACCEPTED - planStore.getValue(response.planId).status = PlannedPlanStatus.ACCEPTED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `mark started does not roll rejected planned plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED - planStore.getValue(response.planId).status = PlannedPlanStatus.PLANNED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.PLANNED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `mark started does not roll rejected expired plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED - planStore.getValue(response.planId).status = PlannedPlanStatus.EXPIRED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.EXPIRED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `accepted decision sets base plan clears active ids and enqueues recalculation once`() { + fun `accepted decision moves plan to AWAITING_LAYIN sets base and enqueues slots update once`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase(plan, attempt, state, processedRepository, recalculationJobService, slotsUpdateJobService) + val useCase = decisionUseCase(plan, attempt, state, processedRepository, slotsUpdateJobService) val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED) useCase.handle(message) useCase.handle(message) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) assertEquals(plan.planId, state.currentBasePlanId) - assertEquals(null, state.activePlanId) - assertEquals(null, state.activeAttemptId) - verify(recalculationJobService).enqueue(spacecraftId, "PLAN_ACCEPTED") + assertNull(state.activePlanId) + assertNull(state.activeAttemptId) + assertNull(state.urgentReissueFrom) verify(slotsUpdateJobService).enqueue(plan.planId) } @Test - fun `rejected before cutoff reopens same plan without direct new attempt`() { + fun `rejected decision marks plan REJECTED and arms urgent reissue`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.PLANNED, plan.status) - assertEquals(null, state.activeAttemptId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertEquals(PlannedPlanStatus.REJECTED, plan.status) + assertNull(state.activeAttemptId) + assertNotNull(state.urgentReissueFrom) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test - fun `rejected after cutoff expires plan and promotes next planned plan`() { + fun `layin failed on current base marks LAYIN_FAILED and arms urgent reissue idempotently`() { + assertNegativeFeedbackArmsReissue(PlannedPlanStatus.LAYIN_FAILED) { useCase, message -> + useCase.handleLayinFailed(message) + } + } + + @Test + fun `plan not accepted on current base marks NOT_ACCEPTED and arms urgent reissue idempotently`() { + assertNegativeFeedbackArmsReissue(PlannedPlanStatus.NOT_ACCEPTED) { useCase, message -> + useCase.handleNotAccepted(message) + } + } + + @Test + fun `negative feedback for a plan that is not the current base is ignored`() { val spacecraftId = "25544" - val currentPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusMinutes(2), LocalDateTime.now().plusMinutes(20), PlannedPlanStatus.WAITING_DECISION) - val nextPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2), PlannedPlanStatus.PLANNED) - val attempt = attempt(currentPlan.planId, status = PlanIssueAttemptStatus.STARTED) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = currentPlan.planId, activeAttemptId = attempt.attemptId) - val planStore = linkedMapOf(currentPlan.planId to currentPlan, nextPlan.planId to nextPlan) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) - val useCase = decisionUseCase( - plan = currentPlan, - attempt = attempt, - state = state, - plannedRepository = plannedRepository(planStore), - recalculationJobService = recalculationJobService + val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN) + val stalePlan = plannedPlan(spacecraftId, time(2), time(3), PlannedPlanStatus.REJECTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository(), + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base, stalePlan.planId to stalePlan)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() ) - useCase.handle(decisionMessage(spacecraftId, currentPlan.planId, attempt.attemptId, PlanDecision.REJECTED)) + useCase.handleLayinFailed(feedbackMessage(spacecraftId, stalePlan.planId)) - assertEquals(PlannedPlanStatus.EXPIRED, currentPlan.status) - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(null, state.activeAttemptId) - assertEquals(true, nextPlan.issueNotBefore != null) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertEquals(PlannedPlanStatus.REJECTED, stalePlan.status) + assertNull(state.urgentReissueFrom) } @Test fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val activeAttemptId = UUID.randomUUID() val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)) @@ -232,59 +152,66 @@ class PlanIssueAndDecisionFlowTest { assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) assertEquals(plan.planId, state.activePlanId) assertEquals(activeAttemptId, state.activeAttemptId) - assertEquals(null, state.currentBasePlanId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_ACCEPTED") + assertNull(state.currentBasePlanId) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test fun `terminal attempt is not reprocessed by new decision event`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.ACCEPTED) - val oldEventId = UUID.randomUUID() - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.ACCEPTED) - attempt.decisionEventId = oldEventId - val state = SpacecraftPlanningStateEntity( - spacecraftId = spacecraftId, - currentBasePlanId = plan.planId, - activePlanId = null, - activeAttemptId = null - ) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.ACCEPTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = plan.planId) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(oldEventId, attempt.decisionEventId) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) assertEquals(plan.planId, state.currentBasePlanId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertNull(state.urgentReissueFrom) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } + private fun assertNegativeFeedbackArmsReissue( + expectedStatus: PlannedPlanStatus, + invoke: (HandlePlanDecisionUseCase, PlanExecutionFeedbackMessage) -> Unit + ) { + val spacecraftId = "25544" + val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId) + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository, + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() + ) + val message = feedbackMessage(spacecraftId, base.planId) + + invoke(useCase, message) + invoke(useCase, message) + + assertEquals(expectedStatus, base.status) + assertNotNull(state.urgentReissueFrom) + } + private fun decisionUseCase( plan: PlannedPlanEntity, attempt: PlanIssueAttemptEntity, state: SpacecraftPlanningStateEntity, processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(), - recalculationJobService: PlanRecalculationJobService = mock(PlanRecalculationJobService::class.java), - slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), - plannedRepository: PlannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)) + slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) ): HandlePlanDecisionUseCase = HandlePlanDecisionUseCase( processedPlanDecisionEventRepository = processedRepository, planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)), - plannedPlanRepository = plannedRepository, + plannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)), spacecraftPlanningStateJpaRepository = stateRepository(state), - planRecalculationJobService = recalculationJobService, acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, planningProperties = planningProperties() ) @@ -292,20 +219,21 @@ class PlanIssueAndDecisionFlowTest { private fun issueWorker( planStore: MutableMap, attemptStore: MutableMap, - beforeStartReturns: (ProcessStartResponse) -> Unit + state: SpacecraftPlanningStateEntity ): IssueDuePlanWorker { val starter = mock(BpmnPlanIssueProcessStarter::class.java) `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> val p = invocation.arguments[0] as PlannedPlanEntity val a = invocation.arguments[1] as PlanIssueAttemptEntity ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) - .also(beforeStartReturns) } return IssueDuePlanWorker( planningProperties = planningProperties(), + spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), plannedPlanRepository = plannedRepository(planStore), planIssueAttemptRepository = attemptRepository(attemptStore), - spacecraftPlanningStateJpaRepository = stateRepository(SpacecraftPlanningStateEntity(spacecraftId = planStore.values.first().spacecraftId)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + planProjectionService = mock(PlanProjectionService::class.java), bpmnPlanIssueProcessStarter = starter, transactionTemplate = transactionTemplate() ) @@ -326,26 +254,6 @@ class PlanIssueAndDecisionFlowTest { store[plan.planId] = plan plan } - `when`(repository.findDueForUpdate(anyNonNull(), anyNonNull(), org.mockito.ArgumentMatchers.anyInt())).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val issueBefore = invocation.arguments[1] as LocalDateTime - store.values.filter { plan -> - plan.status == PlannedPlanStatus.PLANNED && - (plan.issueNotBefore == null || !plan.issueNotBefore!!.isAfter(now)) && - !plan.startTime.isAfter(issueBefore) && - plan.startTime.isAfter(now) - }.sortedBy { plan -> plan.startTime } - } - `when`(repository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values - .filter { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.status == invocation.arguments[1] && - plan.startTime > invocation.arguments[2] as LocalDateTime - } - .minByOrNull { plan -> plan.startTime } - } return repository } @@ -376,7 +284,7 @@ class PlanIssueAndDecisionFlowTest { spacecraftId: String, start: LocalDateTime, end: LocalDateTime, - status: PlannedPlanStatus = PlannedPlanStatus.PLANNED + status: PlannedPlanStatus ): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(), @@ -412,10 +320,19 @@ class PlanIssueAndDecisionFlowTest { reason = null ) + private fun feedbackMessage(spacecraftId: String, planId: UUID): PlanExecutionFeedbackMessage = + PlanExecutionFeedbackMessage( + eventId = UUID.randomUUID(), + spacecraftId = spacecraftId, + planId = planId, + occurredAt = LocalDateTime.now(), + reason = null + ) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt new file mode 100644 index 0000000..4efb71c --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -0,0 +1,115 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class PlanProjectionServiceTest { + + private val spacecraftId = "25544" + + @Test + fun `returns empty projection when there is no snapshot`() { + val calculation = mock(PlanCalculationService::class.java) + val service = service(snapshot = null, calculation = calculation) + + val result = service.project(spacecraftId) + + assertTrue(result.isEmpty()) + verify(calculation, never()).calculatePlans(anyNonNull()) + } + + @Test + fun `builds a fresh chain when there is no base plan`() { + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(1), time(4))) + `when`(calculation.calculatePlans(anyNonNull())).thenReturn(expected) + val service = service(state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId), calculation = calculation) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + verify(calculation).calculatePlans(anyNonNull()) + verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull()) + } + + @Test + fun `builds the tail after the current base plan`() { + val base = PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = time(0), + endTime = time(3), + kppId = "KPP-1", + status = PlannedPlanStatus.AWAITING_LAYIN + ) + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(2), time(5))) + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenReturn(expected) + val service = service( + state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId), + basePlan = base, + calculation = calculation + ) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + verify(calculation).calculatePlansAfter(anyNonNull(), anyNonNull()) + verify(calculation, never()).calculatePlans(anyNonNull()) + } + + private fun service( + snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(), + state: SpacecraftPlanningStateEntity? = null, + basePlan: PlannedPlanEntity? = null, + calculation: PlanCalculationService + ): PlanProjectionService { + val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) + `when`(snapshotRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(snapshot)) + val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state)) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + val parser = mock(VisibilityPayloadParser::class.java) + `when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList())) + return PlanProjectionService(snapshotRepository, stateRepository, plannedRepository, parser, calculation) + } + + private fun snapshot(): SpacecraftVisibilitySnapshotEntity = + SpacecraftVisibilitySnapshotEntity( + spacecraftId = spacecraftId, + noradId = 25544L, + payloadJson = "[]", + payloadHash = "hash", + fetchedAt = LocalDateTime.now() + ) + + private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt new file mode 100644 index 0000000..602de33 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -0,0 +1,78 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +class PlanQueryServiceTest { + + private val spacecraftId = "25544" + + @Test + fun `getAllPlans merges persisted with projection and dedupes by business key`() { + val persisted = plannedPlan(time(1), time(4), PlannedPlanStatus.AWAITING_LAYIN) + // Та же бизнес-ключ-тройка, что у persisted — должна быть выкинута из проекции. + val duplicateProjected = calculated(persisted.startTime, persisted.endTime) + val freshProjected = calculated(time(5), time(8)) + + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(persisted)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L)) + ) + val projection = mock(PlanProjectionService::class.java) + `when`(projection.project(spacecraftId)).thenReturn(listOf(duplicateProjected, freshProjected)) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertEquals(2, result.size) + val persistedResponse = result.first { it.startTime == persisted.startTime } + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) + val projectedResponse = result.first { it.startTime == freshProjected.startTime } + assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status) + // Отсортировано по startTime. + assertTrue(result[0].startTime <= result[1].startTime) + } + + @Test + fun `getAllPlans drops persisted plans older than the history window`() { + val old = plannedPlan(time(-240), time(-239), PlannedPlanStatus.ACCEPTED) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(old)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(emptyList()) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertTrue(result.isEmpty()) + } + + private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status + ) + + private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt deleted file mode 100644 index 11ebc40..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt +++ /dev/null @@ -1,188 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.CalculatedPlan -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity -import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity -import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity -import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser -import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository -import java.time.LocalDateTime -import java.util.Optional -import java.util.UUID - -class RebuildPlanScheduleUseCaseTest { - - @Test - fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { - val spacecraftId = "25544" - val planStore = linkedMapOf() - // Устаревший PLANNED-план, который уже стартовал, но ещё не завершился — - // должен быть погашен по новому условию end_time > now (а не start_time > now). - val stalePlanId = UUID.randomUUID() - planStore[stalePlanId] = PlannedPlanEntity( - planId = stalePlanId, - spacecraftId = spacecraftId, - startTime = LocalDateTime.now().minusHours(1), - endTime = LocalDateTime.now().plusHours(1), - kppId = "KPP-STALE", - status = PlannedPlanStatus.PLANNED - ) - val plannedRepository = plannedRepository(planStore) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) - val stateRepository = stateRepository(state) - val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - `when`(snapshotRepository.findById(spacecraftId)).thenReturn( - Optional.of( - SpacecraftVisibilitySnapshotEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - payloadJson = rawPayload(), - payloadHash = "hash", - fetchedAt = LocalDateTime.now() - ) - ) - ) - val calculationService = mock(PlanCalculationService::class.java) - val baseTime = LocalDateTime.now().plusHours(1) - val firstPlan = CalculatedPlan(spacecraftId, baseTime, baseTime.plusHours(2), "KPP-1") - val secondPlan = CalculatedPlan(spacecraftId, baseTime.plusHours(3), baseTime.plusHours(5), "KPP-2") - `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan, secondPlan)) - val service = RebuildPlanScheduleUseCase( - spacecraftVisibilitySnapshotRepository = snapshotRepository, - spacecraftPlanningStateJpaRepository = stateRepository, - plannedPlanRepository = plannedRepository, - visibilityPayloadParser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()), - planCalculationService = calculationService, - planningProperties = planningProperties() - ) - - service.rebuild(spacecraftId) - val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId - // Устаревший активный план без места в новой цепочке погашен уже на первом rebuild. - assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.getValue(stalePlanId).status) - - `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) - service.rebuild(spacecraftId) - - assertEquals(firstPlanId, planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId) - assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.values.first { plan -> plan.kppId == "KPP-2" }.status) - assertNotEquals(planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId, planStore.values.first { plan -> plan.kppId == "KPP-2" }.planId) - assertEquals(2, state.chainVersion) - } - - private fun plannedRepository(store: MutableMap): PlannedPlanRepository { - val repository = mock(PlannedPlanRepository::class.java) - `when`( - repository.upsertPlannedByBusinessKey( - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull() - ) - ).thenAnswer { invocation -> - val planId = invocation.arguments[0] as UUID - val spacecraftId = invocation.arguments[1] as String - val startTime = invocation.arguments[2] as LocalDateTime - val endTime = invocation.arguments[3] as LocalDateTime - val kppId = invocation.arguments[4] as String - val issueNotBefore = invocation.arguments[5] as LocalDateTime - val basePlanId = invocation.arguments[6] as UUID? - val chainVersion = invocation.arguments[7] as Long - val now = invocation.arguments[8] as LocalDateTime - val existing = store.values.firstOrNull { plan -> - plan.spacecraftId == spacecraftId && - plan.startTime == startTime && - plan.endTime == endTime && - plan.kppId == kppId - } - - if (existing != null) { - if (existing.status == PlannedPlanStatus.SUPERSEDED || existing.status == PlannedPlanStatus.EXPIRED) { - existing.status = PlannedPlanStatus.PLANNED - } - if (existing.status == PlannedPlanStatus.PLANNED) { - existing.issueNotBefore = issueNotBefore - existing.basePlanId = basePlanId - existing.chainVersion = chainVersion - existing.updatedAt = now - } - existing - } else { - PlannedPlanEntity( - planId = planId, - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId, - status = PlannedPlanStatus.PLANNED, - issueNotBefore = issueNotBefore, - basePlanId = basePlanId, - chainVersion = chainVersion, - createdAt = now, - updatedAt = now - ).also { plan -> store[plan.planId] = plan } - } - } - `when`(repository.findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values.firstOrNull { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.startTime == invocation.arguments[1] && - plan.endTime == invocation.arguments[2] && - plan.kppId == invocation.arguments[3] - } - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val plan = invocation.arguments[0] as PlannedPlanEntity - store[plan.planId] = plan - plan - } - `when`(repository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values - .filter { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.status == invocation.arguments[1] && - plan.endTime > invocation.arguments[2] as LocalDateTime - } - .sortedBy { plan -> plan.startTime } - } - return repository - } - - private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { - val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) - `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) - `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - return repository - } - - private fun rawPayload(): String = - """[{"noradId":25544,"stationId":7,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}]""" - - private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - org.mockito.ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt index 7ac41da..576c1b1 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt @@ -35,9 +35,8 @@ class VisibilityFlowTest { } @Test - fun `same visibility payload updates fetchedAt but does not enqueue recalculation`() { + fun `same visibility payload updates fetchedAt and persists snapshot`() { val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val payload = rawPayloadArray(stationId = 7) val existing = SpacecraftVisibilitySnapshotEntity( spacecraftId = "25544", @@ -48,27 +47,25 @@ class VisibilityFlowTest { ) `when`(repository.findById("25544")).thenReturn(Optional.of(existing)) `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val service = SpacecraftVisibilitySnapshotService(repository, parser()) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt) - verify(recalculationJobService, never()).enqueue("25544", "VISIBILITY_CHANGED") + verify(repository, times(1)).save(anyNonNull()) } @Test - fun `changed visibility payload stores raw payload and enqueues recalculation`() { + fun `new visibility payload stores raw snapshot`() { val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) `when`(repository.findById("25544")).thenReturn(Optional.empty()) `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val service = SpacecraftVisibilitySnapshotService(repository, parser()) val payload = rawPayloadArray(stationId = 7) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) verify(repository, times(1)).save(anyNonNull()) - verify(recalculationJobService).enqueue("25544", "VISIBILITY_CHANGED") } private fun parser(): VisibilityPayloadParser = From 86215b300a469276e4398fd1ade233632ef93c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 14:33:04 +0300 Subject: [PATCH 13/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20SUPERSEDED/EXPIRED=20=D0=B8=20=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=D0=B1=D0=BE=D0=BA=D1=81=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BD=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B5=20=C2=AB=D0=9F=D0=BB=D0=B0=D0=BD=D1=8B=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Статусы SUPERSEDED/EXPIRED удалены из модели tgu-service, поэтому чистим их и во фронте, чтобы не описывать несуществующие состояния. - tguTypes: убраны EXPIRED/SUPERSEDED из union TguPlanStatus. - tguStatus: удалены их стили; заодно снят осиротевший фильтр isActivePlanStatus/ACTIVE_PLAN_STATUSES (после удаления чекбокса не нужен). - tguTimelineMapper: EXPIRED убран из PROBLEM_STATUSES. - tgu-planning.css: удалены классы .status-superseded/.status-expired и осиротевший блок .tgu-timeline-controls. - TguPlanningPage: убран тумблер «Показать историю версий» вместе со стейтом showHistory и фильтром timelinePlans — таймлайн рендерит все планы из API напрямую (перестали скрываться AWAITING_LAYIN/REJECTED/LAYIN_FAILED/NOT_ACCEPTED). EXPIRED в requestTypes (статус заявок) — отдельный домен, не тронут. --- .../features/tgu-planning/TguPlanningPage.tsx | 23 ++------------- .../src/features/tgu-planning/tguStatus.ts | 15 ---------- .../tgu-planning/tguTimelineMapper.ts | 2 +- services/pcp-tgu-ops-ui/src/model/tguTypes.ts | 2 -- .../src/styles/tgu-planning.css | 29 ------------------- 5 files changed, 3 insertions(+), 68 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 6712f23..cd446e0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -10,7 +10,6 @@ import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; import { TguTimeline } from "./TguTimeline"; import { TguToolbar } from "./TguToolbar"; -import { isActivePlanStatus } from "./tguStatus"; import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -29,9 +28,6 @@ export function TguPlanningPage() { const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); const [activeTab, setActiveTab] = useState("timeline"); - // По умолчанию на таймлайне видна только актуальная цепочка; история версий - // (SUPERSEDED/EXPIRED/REJECTED и т.п.) скрыта, чтобы не было визуальной каши. - const [showHistory, setShowHistory] = useState(false); // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии // и дальше держим смонтированной (скрытой через display:none), чтобы её // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось @@ -102,14 +98,9 @@ export function TguPlanningPage() { () => buildPlatformsForInterval(rawPlatforms, plans, appliedRange), [rawPlatforms, plans, appliedRange] ); - // Планы, показываемые на таймлайне: по умолчанию только актуальная цепочка. - const timelinePlans = useMemo( - () => (showHistory ? plans : plans.filter((plan) => isActivePlanStatus(plan.status))), - [plans, showHistory] - ); const rows = useMemo( - () => buildTimelineRows(timelinePlans, platforms, appliedRange, selectedSpacecraftId), - [timelinePlans, platforms, appliedRange, selectedSpacecraftId] + () => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId), + [plans, platforms, appliedRange, selectedSpacecraftId] ); const selectedPlan = useMemo(() => { const plan = plans.find((item) => item.planId === selectedPlanId); @@ -190,16 +181,6 @@ export function TguPlanningPage() { }} />
-
- -
[style.status, style])); -/** Статусы актуальной цепочки: их показываем на таймлайне по умолчанию. */ -const ACTIVE_PLAN_STATUSES = new Set([ - "PLANNED", - "ISSUING", - "WAITING_DECISION", - "ACCEPTED" -]); - -/** Проверяет, относится ли план к актуальной цепочке (а не к истории версий). */ -export function isActivePlanStatus(status: TguPlanStatus): boolean { - return ACTIVE_PLAN_STATUSES.has(status); -} - export function statusStyle(status: TguPlanStatus): TimelineStatusStyle { return ( STATUS_BY_NAME.get(status) ?? { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts index f5d5b1c..180b613 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts @@ -1,7 +1,7 @@ import type { TguPlan, TguPlatform } from "../../model/tguTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes"; -const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]); +const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]); export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] { const platformByNorad = buildPlatformByNorad(platforms); diff --git a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts index e62484d..bea1f4f 100644 --- a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts @@ -4,8 +4,6 @@ export type TguPlanStatus = | "WAITING_DECISION" | "PLANNED" | "ISSUING" - | "EXPIRED" - | "SUPERSEDED" | "START_AMBIGUOUS" | string; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 37e6639..6cb5cd7 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -183,14 +183,6 @@ background-color: #2f80ed; } -.status-expired { - background-color: #495057; -} - -.status-superseded { - background-color: #c7ced6; -} - .status-ambiguous { background-color: #b42318; outline: 1px dashed #ffd1d1; @@ -346,27 +338,6 @@ background: #121516; } -.tgu-timeline-controls { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.6rem 0.85rem 0; -} - -.tgu-timeline-controls__toggle { - display: inline-flex; - align-items: center; - gap: 0.4rem; - font-size: 0.8rem; - color: var(--muted, #9aa4ad); - cursor: pointer; - user-select: none; -} - -.tgu-timeline-controls__toggle input { - cursor: pointer; -} - .tgu-timeline { min-width: 48rem; min-height: 100%; From e738c236a31b4d1d3b0215d35894a022cddc55a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 14:51:43 +0300 Subject: [PATCH 14/79] =?UTF-8?q?=D0=9D=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Если план простоял в WAITING_DECISION дольше waiting-decision-grace-minutes (по умолчанию 30) после своего начала и решения по нему так и нет — перестаём ждать подтверждение: снимаем активную выдачу, двигаем якорь цепочки на этот план и выдаём следующий. Статус плана не меняется (остаётся WAITING_DECISION, не ACCEPTED) — это молчаливое продолжение цепочки, а не приёмка. Запоздалое решение по такому плану просто пишется на attempt и не откатывает ушедшую вперёд цепочку. --- config-repo/pcp-tgu-service.yaml | 1 + .../config/PlanningProperties.kt | 9 ++- .../service/IssueDuePlanWorker.kt | 37 +++++++++- .../src/main/resources/application.yml | 1 + .../api/ExternalPointsClientTest.kt | 3 +- .../AcceptedPlanSlotsUpdateFlowTest.kt | 2 +- .../service/IssueDuePlanWorkerOnDemandTest.kt | 68 ++++++++++++++++++- .../service/PlanCalculationServiceTest.kt | 3 +- .../service/PlanIssueAndDecisionFlowTest.kt | 2 +- 9 files changed, 119 insertions(+), 7 deletions(-) diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 0df08a4..50c3800 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -41,6 +41,7 @@ planning: min-reissue-lead-minutes: 5 reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 + waiting-decision-grace-minutes: 30 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index bd8241f..0ac4329 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -39,5 +39,12 @@ data class PlanningProperties( val reissuePrepMinutes: Long, /** TTL lease'а worker job claim в секундах. */ - val workerLockTtlSeconds: Long + val workerLockTtlSeconds: Long, + + /** + * Сколько минут после начала плана ждать решения оператора, прежде чем продолжить цепочку + * без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION, + * но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки. + */ + val waitingDecisionGraceMinutes: Long ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index 79d8968..c361ee6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -58,7 +58,7 @@ class IssueDuePlanWorker( private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { val now = LocalDateTime.now() val state = getOrCreateLockedState(spacecraftId) - if (state.activeAttemptId != null) { + if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) { return null } @@ -96,6 +96,41 @@ class IssueDuePlanWorker( return PreparedAttempt(plan.planId, attempt.attemptId) } + /** + * Если активная выдача — это план в WAITING_DECISION, по которому уже прошло + * [PlanningProperties.waitingDecisionGraceMinutes] минут после старта и решения так и нет, + * перестаём ждать подтверждение: снимаем блокировку (activePlanId/activeAttemptId), + * двигаем якорь цепочки на этот план и идём дальше. Статус плана НЕ меняем — он остаётся + * WAITING_DECISION (молчаливое продолжение, а не ACCEPTED). Attempt оставляем как есть: + * запоздалое решение по нему просто запишется на attempt, не трогая ушедшую вперёд цепочку. + * + * @return true, если блокировка снята и можно выдавать следующий план в этом же тике. + */ + private fun tryAdvancePastUnconfirmedPlan(state: SpacecraftPlanningStateEntity, now: LocalDateTime): Boolean { + val activePlanId = state.activePlanId ?: return false + val plan = plannedPlanRepository.findById(activePlanId).orElse(null) ?: return false + if (plan.status != PlannedPlanStatus.WAITING_DECISION) { + return false + } + if (now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))) { + return false + } + + state.currentBasePlanId = plan.planId + state.activePlanId = null + state.activeAttemptId = null + state.urgentReissueFrom = null + state.chainVersion += 1 + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + log.info( + "Plan {} (spacecraft {}) stayed WAITING_DECISION past grace; continuing chain without confirmation.", + plan.planId, plan.spacecraftId + ) + return true + } + private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { val now = LocalDateTime.now() val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 14e21ef..99adb35 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -24,6 +24,7 @@ planning: min-reissue-lead-minutes: 5 reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 + waiting-decision-grace-minutes: 30 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index d90e0a4..5cd1237 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -115,6 +115,7 @@ class ExternalPointsClientTest { notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, reissuePrepMinutes = 30, - workerLockTtlSeconds = 300 + workerLockTtlSeconds = 300, + waitingDecisionGraceMinutes = 30 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt index 52f6246..5f00dad 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest { starter: AcceptedPlanSlotsUpdateProcessStarter ): AcceptedPlanSlotsUpdateWorker = AcceptedPlanSlotsUpdateWorker( - planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300), + planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30), acceptedPlanSlotsUpdateJobRepository = repository, acceptedPlanSlotsUpdateProcessStarter = starter, transactionTemplate = transactionTemplate() diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index c2c381c..1604334 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -103,6 +103,72 @@ class IssueDuePlanWorkerOnDemandTest { verify(projection, never()).project(spacecraftId) } + @Test + fun `advances chain past WAITING_DECISION plan once grace elapsed and issues next without accepting it`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Активный план висит в WAITING_DECISION; старт был 40 минут назад (grace=30 → истёк). + val unconfirmed = persistedPlan( + status = PlannedPlanStatus.WAITING_DECISION, + start = now().minusMinutes(40), + end = now().plusHours(1) + ) + planStore[unconfirmed.planId] = unconfirmed + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = UUID.randomUUID(), + activePlanId = unconfirmed.planId, + activeAttemptId = UUID.randomUUID() + ) + val next = plan(now().plusMinutes(10), now().plusHours(3)) + val worker = worker(planStore, attemptStore, state, projection = listOf(next)) + + worker.issueDuePlans() + + // Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status) + // Якорь цепочки сдвинут на него, следующий план выдан с новым attempt. + assertEquals(unconfirmed.planId, state.currentBasePlanId) + assertEquals(2, planStore.size) + assertNotNull(state.activeAttemptId) + assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime) + } + + @Test + fun `keeps blocking on WAITING_DECISION plan before grace elapses`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт был всего 10 минут назад (grace=30 → ещё не истёк) → блокировка держится. + val unconfirmed = persistedPlan( + status = PlannedPlanStatus.WAITING_DECISION, + start = now().minusMinutes(10), + end = now().plusHours(1) + ) + planStore[unconfirmed.planId] = unconfirmed + val attemptId = UUID.randomUUID() + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + activePlanId = unconfirmed.planId, + activeAttemptId = attemptId + ) + val worker = worker(planStore, attemptStore, state, projection = listOf(plan(now().plusMinutes(10), now().plusHours(3)))) + + worker.issueDuePlans() + + assertEquals(1, planStore.size) + assertEquals(attemptId, state.activeAttemptId) + } + + private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status + ) + private fun worker( planStore: MutableMap, attemptStore: MutableMap, @@ -195,7 +261,7 @@ class IssueDuePlanWorkerOnDemandTest { private fun now(): LocalDateTime = LocalDateTime.now() private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300) + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index b3315df..f2c1877 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -339,7 +339,8 @@ class PlanCalculationServiceTest { notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, reissuePrepMinutes = 30, - workerLockTtlSeconds = 300 + workerLockTtlSeconds = 300, + waitingDecisionGraceMinutes = 30 ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 3f1e376..87e1b88 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -332,7 +332,7 @@ class PlanIssueAndDecisionFlowTest { private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300) + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate( From 061ac1390c45749d1886034c8fe52a458a0ac1d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 15:00:30 +0300 Subject: [PATCH 15/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=9A=D0=90=20=D1=81=D0=BE=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=BC=20OPERATIONAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit С остальными КА мы не работаем, поэтому отсекаем их сразу на входе в loadData: фильтруем платформы по статусу и оставляем планы только для эксплуатируемых КА (по norad-id), иначе неoperational-аппарат вернулся бы fallback-строкой по плану в buildPlatformsForInterval. --- .../features/tgu-planning/TguPlanningPage.tsx | 19 +++++++++--- .../tgu-planning/tguTimelineMapper.test.ts | 30 +++++++++++++++++++ .../tgu-planning/tguTimelineMapper.ts | 18 +++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index cd446e0..456067d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -10,7 +10,13 @@ import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; import { TguTimeline } from "./TguTimeline"; import { TguToolbar } from "./TguToolbar"; -import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper"; +import { + buildPlatformsForInterval, + buildTimelineRows, + isOperationalPlatform, + mapPlans, + operationalSpacecraftIds +} from "./tguTimelineMapper"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -61,19 +67,24 @@ export function TguPlanningPage() { fetchPlans(historyDays) ]); + // На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу + // на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам. + const operationalPlatforms = + platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : []; + if (platformResult.status === "rejected") { setPlatformLoadFailed(true); } else { setPlatformLoadFailed(false); - setRawPlatforms(platformResult.value); + setRawPlatforms(operationalPlatforms); } if (planResult.status === "rejected") { setPlans([]); setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы."); } else { - const platforms = platformResult.status === "fulfilled" ? platformResult.value : []; - setPlans(mapPlans(planResult.value, platforms)); + const operationalIds = operationalSpacecraftIds(operationalPlatforms); + setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId))); } setLoading(false); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts new file mode 100644 index 0000000..bebb323 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { TguPlatform } from "../../model/tguTypes"; +import { isOperationalPlatform, operationalSpacecraftIds } from "./tguTimelineMapper"; + +function platform(overrides: Partial): TguPlatform { + return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides }; +} + +describe("isOperationalPlatform", () => { + it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => { + expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true); + expect(isOperationalPlatform(platform({ status: " operational " }))).toBe(true); + expect(isOperationalPlatform(platform({ status: "STANDBY" }))).toBe(false); + expect(isOperationalPlatform(platform({ status: "LOST" }))).toBe(false); + expect(isOperationalPlatform(platform({ status: null }))).toBe(false); + expect(isOperationalPlatform(platform({ status: undefined }))).toBe(false); + }); +}); + +describe("operationalSpacecraftIds", () => { + it("собирает norad-id в виде строк и пропускает платформы без norad-id", () => { + const ids = operationalSpacecraftIds([ + platform({ noradId: 2 }), + platform({ noradId: 30 }), + platform({ noradId: null }), + platform({ noradId: undefined }) + ]); + expect(ids).toEqual(new Set(["2", "30"])); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts index 180b613..f8e7da6 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts @@ -3,6 +3,24 @@ import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../.. const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]); +const OPERATIONAL_STATUS = "OPERATIONAL"; + +/** На морде показываем только эксплуатируемые КА — с остальными мы не работаем. */ +export function isOperationalPlatform(platform: TguPlatform): boolean { + return (platform.status ?? "").trim().toUpperCase() === OPERATIONAL_STATUS; +} + +/** norad-id эксплуатируемых КА; planId.spacecraftId сопоставляется именно с norad-id. */ +export function operationalSpacecraftIds(platforms: TguPlatform[]): Set { + const ids = new Set(); + for (const platform of platforms) { + if (platform.noradId !== null && platform.noradId !== undefined) { + ids.add(String(platform.noradId)); + } + } + return ids; +} + export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] { const platformByNorad = buildPlatformByNorad(platforms); From a61e23c0955395ddf5a414d035c9dd39c10ff660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 16:53:24 +0300 Subject: [PATCH 16/79] =?UTF-8?q?pcp-tgu-service:=20=D0=BD=D0=B5=20=D0=B4?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=86=D0=B8=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проекция будущей цепочки якорилась только на принятый базовый план (currentBasePlanId) и игнорировала выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION). Из-за этого она заново выводила уже выданный слот, и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) точечный дедуп по businessKey в PlanQueryService не срабатывал — на вкладке «Планы» появлялся призрак-дубль PLANNED поверх WAITING_DECISION. Теперь якорь проекции — самая дальняя зафиксированная точка цепочки: если есть план в полёте, цепочку строим после него. На путь выдачи не влияет: там activePlanId к моменту вызова проекции уже снят. --- .../service/PlanProjectionService.kt | 37 ++++++++- .../service/PlanProjectionServiceTest.kt | 79 ++++++++++++++++++- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt index 64b7d3b..1e9b09a 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt @@ -4,6 +4,8 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository @@ -31,16 +33,38 @@ class PlanProjectionService( val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) - val basePlan = state?.currentBasePlanId - ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + val anchorPlan = resolveAnchorPlan(state) - return if (basePlan == null) { + return if (anchorPlan == null) { planCalculationService.calculatePlans(points) } else { - planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) + planCalculationService.calculatePlansAfter(points, anchorPlan.toCalculatedPlan()) } } + /** + * Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка. + * + * Если есть выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION) — цепочку + * продолжаем ПОСЛЕ него, а не после принятого базового плана. Иначе проекция заново вывела бы + * уже выданный слот и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) нарисовала бы + * его призрак-дубль PLANNED рядом с реальным WAITING_DECISION: точечный дедуп по + * businessKey в [PlanQueryService] такой рассинхрон не ловит. + * + * `activePlanId` заполнен только вместе с активной выдачей; в пути выдачи он к моменту + * вызова проекции уже снят, поэтому якорение на план в полёте не влияет на выбор головы. + */ + private fun resolveAnchorPlan(state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? { + val inFlight = state?.activePlanId + ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + ?.takeIf { plan -> plan.status in IN_FLIGHT_STATUSES } + if (inFlight != null) { + return inFlight + } + return state?.currentBasePlanId + ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + } + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = CalculatedPlan( spacecraftId = spacecraftId, @@ -48,4 +72,9 @@ class PlanProjectionService( endTime = endTime, kppId = kppId ) + + private companion object { + /** Статусы выданного, но ещё не принятого плана — он уже фиксирует слот цепочки. */ + val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION) + } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt index 4efb71c..c40d53e 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -76,10 +76,66 @@ class PlanProjectionServiceTest { verify(calculation, never()).calculatePlans(anyNonNull()) } + @Test + fun `anchors the tail after the in-flight issued plan, not the accepted base`() { + val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN) + val inFlight = persistedPlan(time(2), time(5), "KPP-inflight", PlannedPlanStatus.WAITING_DECISION) + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(4), time(7))) + var anchor: CalculatedPlan? = null + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + anchor = invocation.getArgument(1) + expected + } + val service = service( + state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = base.planId, + activePlanId = inFlight.planId + ), + plansById = mapOf(base.planId to base, inFlight.planId to inFlight), + calculation = calculation + ) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + assertEquals(inFlight.startTime, anchor?.startTime) + assertEquals(inFlight.endTime, anchor?.endTime) + assertEquals(inFlight.kppId, anchor?.kppId) + } + + @Test + fun `falls back to the base plan when the active plan is no longer in flight`() { + val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN) + val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN) + val calculation = mock(PlanCalculationService::class.java) + var anchor: CalculatedPlan? = null + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + anchor = invocation.getArgument(1) + emptyList() + } + val service = service( + state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = base.planId, + activePlanId = accepted.planId + ), + plansById = mapOf(base.planId to base, accepted.planId to accepted), + calculation = calculation + ) + + service.project(spacecraftId) + + assertEquals(base.startTime, anchor?.startTime) + assertEquals(base.kppId, anchor?.kppId) + } + private fun service( snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(), state: SpacecraftPlanningStateEntity? = null, basePlan: PlannedPlanEntity? = null, + plansById: Map? = null, calculation: PlanCalculationService ): PlanProjectionService { val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) @@ -87,12 +143,33 @@ class PlanProjectionServiceTest { val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java) `when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state)) val plannedRepository = mock(PlannedPlanRepository::class.java) - `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + if (plansById != null) { + `when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation -> + Optional.ofNullable(plansById[invocation.getArgument(0)]) + } + } else { + `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + } val parser = mock(VisibilityPayloadParser::class.java) `when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList())) return PlanProjectionService(snapshotRepository, stateRepository, plannedRepository, parser, calculation) } + private fun persistedPlan( + start: LocalDateTime, + end: LocalDateTime, + kppId: String, + status: PlannedPlanStatus + ): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status + ) + private fun snapshot(): SpacecraftVisibilitySnapshotEntity = SpacecraftVisibilitySnapshotEntity( spacecraftId = spacecraftId, From faf264e416005ee1bb4d4686b55cb72fbc4016ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 16:53:32 +0300 Subject: [PATCH 17/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D1=88=D1=80=D1=83=D1=82=D1=8B=20=D1=81=D1=8A=D1=91=D0=BC=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=B0=D0=BD=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B8=D0=B4=D0=B8=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drawPlanWorks проецировал точки напрямую и проверял видимость по одной копии мира: полигон, пересекающий 180°, рисовался через всю карту, а слой мигал «то есть, то нет» при зуме/панораме. Перенёс паттерн из drawRequests/drawSwath: unwrapX (склейка контура через шов) + отрисовка в трёх копиях мира с culling по каждой. --- .../tgu-map-2d/canvas/drawPlanWorks.ts | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts index b1bbc57..e42ca6f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts @@ -2,6 +2,26 @@ import { statusStyle } from "../../tgu-planning/tguStatus"; import type { MapProjection } from "../geometry/mapProjection"; import type { MapPolygon } from "../model/mapTypes"; +type Pt = { x: number; y: number }; + +/** + * «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на + * полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту. + */ +function unwrapX(points: Pt[], worldSize: number): Pt[] { + if (points.length === 0) return []; + const out: Pt[] = [points[0]]; + let prevX = points[0].x; + for (let i = 1; i < points.length; i++) { + let x = points[i].x; + while (x - prevX > worldSize / 2) x -= worldSize; + while (prevX - x > worldSize / 2) x += worldSize; + out.push({ x, y: points[i].y }); + prevX = x; + } + return out; +} + export function drawPlanWorks( ctx: CanvasRenderingContext2D, polygons: MapPolygon[], @@ -11,26 +31,31 @@ export function drawPlanWorks( ctx.save(); for (const polygon of polygons) { if (polygon.points.length < 3) continue; - const screenPoints = polygon.points.map((point) => projection.project(point)); - if (!screenPoints.some((point) => point.x >= -32 && point.x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32)) { - continue; - } + const raw = polygon.points.map((point) => projection.project(point)); + const pts = unwrapX(raw, projection.worldSize); const color = statusStyle(polygon.status || "PLANNED").color; - ctx.beginPath(); - screenPoints.forEach((point, index) => { - if (index === 0) { - ctx.moveTo(point.x, point.y); - } else { - ctx.lineTo(point.x, point.y); - } - }); - ctx.closePath(); ctx.fillStyle = hexToRgba(color, 0.2); ctx.strokeStyle = hexToRgba(color, 0.72); ctx.lineWidth = 1.2; - ctx.fill(); - ctx.stroke(); + + // Рисуем полигон в трёх копиях мира (текущей и соседних), чтобы он был виден + // независимо от того, какая копия попала в видимую область при зуме/панораме. + for (const dx of [0, projection.worldSize, -projection.worldSize]) { + const visible = pts.some((point) => { + const x = point.x + dx; + return x >= -32 && x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32; + }); + if (!visible) continue; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y); + else ctx.lineTo(pts[i].x + dx, pts[i].y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } } ctx.restore(); } From fb0332a0309450b062547a464d88245c21a8e836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 16:53:48 +0300 Subject: [PATCH 18/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20UTC=20=D0=B2=D0=BE=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8;=20=D0=B4=D0=B5=D1=84=D0=BE=D0=BB?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BD=20=D0=9F=D0=BB=D0=B0=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Время бэка (naive LocalDateTime и OffsetDateTime) разбиралось через new Date(string)/Date.parse, из-за чего naive-строки трактовались как локальное время браузера: вкладки расходились по времени, а путь создания заявки сдвигал введённое время на офсет. Ввёл единую точку utils/utcTime (parseUtc/formatUtc*/toUtcInputValue/toUtcIso) и перевёл на неё вкладки «Планы», «Заявки», «Карта 2D» — чтение, пикеры диапазона, создание заявки. Плюс на вкладке «Планы» график по умолчанию открывается на интервал сейчас−30 мин … сейчас+3 дня. docs/tasks/TASK_datetime-utc-consistency.md — ТЗ на сверку всей группы сервисов pcp на единообразие времени. --- docs/tasks/TASK_datetime-utc-consistency.md | 198 ++++++++++++++++++ .../tgu-map-2d/Tgu2DMapRequestsPanel.tsx | 17 +- .../features/tgu-map-2d/Tgu2DMapSidebar.tsx | 7 +- .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 24 +-- .../features/tgu-planning/TguPlanDetails.tsx | 3 +- .../features/tgu-planning/TguPlanningPage.tsx | 30 +-- .../src/features/tgu-planning/TguTimeline.tsx | 7 +- .../tgu-planning/tguTimelineMapper.ts | 5 +- .../tgu-requests/RequestCreatePanel.tsx | 5 +- .../tgu-requests/RequestDetailsPanel.tsx | 3 +- .../features/tgu-requests/TguRequestsTab.tsx | 27 +-- services/pcp-tgu-ops-ui/src/utils/utcTime.ts | 51 +++++ 12 files changed, 304 insertions(+), 73 deletions(-) create mode 100644 docs/tasks/TASK_datetime-utc-consistency.md create mode 100644 services/pcp-tgu-ops-ui/src/utils/utcTime.ts diff --git a/docs/tasks/TASK_datetime-utc-consistency.md b/docs/tasks/TASK_datetime-utc-consistency.md new file mode 100644 index 0000000..f52103f --- /dev/null +++ b/docs/tasks/TASK_datetime-utc-consistency.md @@ -0,0 +1,198 @@ +# Задача: единообразие времени (UTC) по всей группе сервисов `pcp` + +> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента). +> Разделы 1–9 — инструкция, которую агент читает в репозитории и которой следует. +> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. + +--- + +## 0. Промпт для запуска агента (copy-paste) + +``` +Ты работаешь в монорепозитории observatio-terrae (pcp). + +Задача: провести аудит и привести к единообразию работу со временем (датами) по всей +группе сервисов pcp. Целевое соглашение: ВСЁ ВРЕМЯ — UTC, представление на API не зависит +от часового пояса машины-клиента или сервера. + +Перед началом обязательно прочитай: +1. /AGENTS.md — правила работы, стиль, формат финального ответа. +2. /docs/tasks/TASK_datetime-utc-consistency.md — это ТЗ (данный файл). +3. Эталонную реализацию на фронте: services/pcp-tgu-ops-ui/src/utils/utcTime.ts + и историю правок в pcp-tgu-ops-ui (вкладки «Планы», «Редактор», «Заявки», «Карта 2D»). + +Это в первую очередь АУДИТ + точечные правки, а не массовый рефактор. Сначала инвентаризация +(раздел 4), затем фиксы по чек-листу (раздел 5) с приоритетом на места, где сдвиг времени +виден пользователю или уходит на бэкенд при записи. Backend (Kotlin) трогать только там, где +сериализация реально неоднозначна (раздел 6) — сериализационные правки сопровождать тестом. + +Не ломай чужие контракты: если меняешь формат на проводе — обнови потребителей и тесты. +После каждого модуля прогоняй его тесты/сборку. В конце дай отчёт по формату из AGENTS.md +плюс заполненную таблицу инвентаризации (раздел 4) и список расхождений. +``` + +--- + +## 1. Контекст и зачем это + +Время в системе хранится и передаётся как **UTC**, но представление на границах API +неоднородно: часть сервисов отдаёт naive `LocalDateTime` (ISO без зоны, напр. +`"2026-05-29T01:00:00"`, по соглашению — UTC), часть — `OffsetDateTime`/`Instant` +(ISO с зоной, напр. `"2026-05-29T01:00:00Z"`). Эта разница уже привела к багу во фронте: + +- `pcp-tgu-service` отдаёт планы как naive `LocalDateTime`. +- Фронт парсил такие строки через `new Date(s)` / `Date.parse(s)`, а JS трактует строку + **без зоны как локальное время браузера** → сдвиг на офсет (для МСК — +3 ч). +- Одни вкладки показывали через `toLocaleString` (пояс браузера), другие — в UTC → + одно и то же время выглядело по-разному на разных экранах и у разных операторов. +- В пути **записи** заявки оператор, вводя `01:00`, отправлял на бэк `22:00`. + +Корень — не «UTC против местного», а **несогласованность парсинга/форматирования** и +**неоднородность формата на проводе между сервисами**. Эта задача делает всё единообразным. + +## 2. Целевое соглашение (canonical) + +1. **Всё время — UTC.** На любом API времена представляют момент в UTC. +2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис + исторически отдаёт naive `LocalDateTime`, это допустимо **только** при железном правиле + «naive = UTC», но такой эндпоинт — кандидат на миграцию к `Z` (раздел 6). +3. **Фронт:** + - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких + «голых» `new Date(string)` / `Date.parse(string)`; + - отображение — только UTC (`formatUtcDateTime/Date/Time`, либо `toLocale*` с + `timeZone: "UTC"`); никакого `toLocaleString` без `timeZone`; + - `` — значение через `toUtcInputValue`, чтение обратно + через `parseUtc`; отправка на offset-бэк — через `toUtcIso`. +4. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка. + +## 3. Эталон (уже сделано — переиспользовать, не переделывать) + +В `pcp-tgu-ops-ui` это соглашение уже внедрено и может служить образцом: + +- `src/utils/utcTime.ts` — `parseUtc`, `formatUtcDateTime/Date/Time`, `toUtcInputValue`, + `toUtcIso`. **Единая точка** парсинга/форматирования. +- Переведены вкладки «Планы», «Редактор», «Заявки», «Карта 2D» (источник `startMs/endMs` + в `tguTimelineMapper`, пикеры диапазона, детали заявок, путь создания заявки). +- Нюанс: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) — `parseUtc` их тоже + корректно обрабатывает (зона есть → не добавляет `Z`), а запись идёт через `toUtcIso`. + +Любой новый код во фронте обязан следовать этому слою. + +## 4. Инвентаризация (заполнить в отчёте) + +Стартовый срез (тип `LocalDateTime` vs `OffsetDateTime/ZonedDateTime/Instant` в файлах +`dto|controller|api|response|model`); агент должен уточнить и довести до пер-эндпоинтной +картины «что именно отдаётся наружу»: + +| Сервис | Преобладает на границе API | Действие | +|---|---|---| +| pcp-tgu-service | naive `LocalDateTime` | задокументировать «=UTC», кандидат на `Z` | +| pcp-request-service | `OffsetDateTime` (`Z`) | эталон, проверить полноту | +| pcp-dynamic-plan-service | naive `LocalDateTime` (+1 offset-файл) | проверить смешение | +| pcp-complex-mission-service | naive `LocalDateTime` (+2 offset) | проверить смешение | +| pcp-ui-service | naive `LocalDateTime` (+1 offset) | проверить (агрегатор/BFF?) | +| slots-service | naive `LocalDateTime` | задокументировать «=UTC» | +| pcp-coverage-scheme-service | naive `LocalDateTime` | задокументировать «=UTC» | +| pcp-ballistics-service | naive `LocalDateTime` | задокументировать «=UTC» | +| pcp-mission-planing-service | naive `LocalDateTime` | задокументировать «=UTC» | +| pcp-route-processing-service | нет времени на границе (проверить) | — | +| pcp-satellite-catalog-service | нет (проверить) | — | +| pcp-stations-service | нет (проверить) | — | +| tle-monitoring-service | нет (проверить) | — | + +Колонки итоговой таблицы: **сервис · эндпоинт/поле · тип · формат на проводе (пример) · +naive? · потребители · вердикт (ok / задокументировать / мигрировать на `Z`)**. + +Полезно: во всех проверенных сервисах Jackson настроен с `JavaTimeModule` и +`disable(WRITE_DATES_AS_TIMESTAMPS)` → времена сериализуются как ISO-строки (не числа), +поэтому `LocalDateTime` уходит без зоны, `OffsetDateTime`/`Instant` — с `Z`/офсетом. + +## 5. Фронт: инвариант и фиксы (`pcp-tgu-ops-ui`) + +Эталон внедрён, но нужно **закрепить как инвариант** и проверить, что ничего не утекло: + +1. Найти нарушителей (раздел 7, паттерн A). Любой «голый» разбор/формат строки времени + бэка чинить через `utils/utcTime`. +2. Добавить **защиту от регрессий** — на выбор: + - юнит-тест-«страж», который грепает `src/` и падает на запрещённых паттернах + (`new Date()`, `Date.parse(`, `toLocaleString(` без `timeZone`, + `formatDateTimeLocal`); **или** + - ESLint-правило (`no-restricted-syntax`) с тем же смыслом. +3. Прогнать `npm run test` и `npm run build` в модуле. + +## 6. Backend: когда и что править + +По умолчанию — **не менять формат на проводе** (это контракт). Действия: + +1. **Документировать инвариант.** Для сервисов с naive `LocalDateTime`: явно зафиксировать + «все времена на API — UTC» (KDoc у DTO + запись в `docs/standards`, раздел 8). Это дёшево + и безопасно, и снимает неоднозначность для будущих клиентов. +2. **Чинить реальные баги.** Если найдётся место, где сервис: + - кладёт в naive-поле **локальное** время сервера (`LocalDateTime.now()` вместо + `LocalDateTime.now(ZoneOffset.UTC)` / `Instant.now()`), или + - конвертирует под зону JVM при чтении/записи — + это **баг**: исправить на UTC, добавить тест. +3. **Миграция к явному `Z` (опционально, по согласованию).** Перевод naive `LocalDateTime` + на `OffsetDateTime`/`Instant` (UTC) на границе — самый чистый итог, но это смена контракта: + делать только с обновлением потребителей (на фронте `parseUtc` уже совместим с обоими) и + сериализационным тестом. Оформить как отдельные under-задачи per-service, не «большим взрывом». +4. **Тест формата.** Для каждого сервиса, отдающего время, иметь тест, фиксирующий формат на + проводе (есть `Z` / нет зоны) — чтобы контракт не «уехал» молча. + +## 7. Как искать нарушителей (детект-паттерны) + +**A. Фронт (`services/pcp-tgu-ops-ui/src`), запрещено вне `utils/utcTime.ts`:** + +```bash +# разбор строки как локального времени +grep -rn "new Date([a-zA-Z].*)\.getTime\|Date\.parse(" src --include=*.ts --include=*.tsx | grep -v utcTime +# показ в поясе браузера +grep -rn "toLocaleString\|toLocaleDateString\|toLocaleTimeString" src --include=*.ts --include=*.tsx | grep -v 'timeZone: "UTC"' +# локальные хелперы пикеров +grep -rn "formatDateTimeLocal" src +# локальные компоненты даты +grep -rn "getFullYear()\|getMonth()\|getDate()\|getHours()\|getMinutes()" src --include=*.ts --include=*.tsx | grep -v getUTC +``` + +**B. Backend (Kotlin) — потенциально локальное «сейчас» в UTC-полях:** + +```bash +# now() без UTC-зоны +grep -rn "LocalDateTime\.now()\|LocalDate\.now()\|LocalTime\.now()" services/*/src/main +# конвертации под зону системы +grep -rn "atZone(\|ZoneId\.systemDefault\|toLocalDateTime()\|systemDefault()" services/*/src/main +# типы времени на границе (для инвентаризации) +grep -rn "LocalDateTime\|OffsetDateTime\|ZonedDateTime\|Instant" services/*/src/main | grep -iE "dto|controller|response" +``` + +Каждое попадание классифицировать: **ок / задокументировать / починить / мигрировать**. + +## 8. Зафиксировать стандарт + +Создать `docs/standards/DATETIME_UTC.md` (на него ссылаться из KDoc/комментариев): + +- соглашение из раздела 2 (всё UTC; формат на проводе; правила фронта); +- таблица состояния сервисов (итог раздела 4); +- правило для нового кода: фронт — только `utils/utcTime`; бэк — UTC-`now`, + предпочтительно `Z` на новых API. + +При наличии общего чек-листа в `docs/standards/CHECKLIST.md` — добавить пункт +«Время на API — UTC; фронт парсит/форматирует через utcTime». + +## 9. Definition of Done + +- [ ] Заполнена пер-эндпоинтная таблица инвентаризации (раздел 4) по всем 13 сервисам. +- [ ] Фронт: нарушителей по паттернам раздела 7A не осталось; добавлена защита от регрессий + (тест-страж или ESLint-правило); `npm run test` и `npm run build` зелёные. +- [ ] Backend: задокументирован инвариант «API = UTC» у сервисов с naive `LocalDateTime`; + найденные реальные баги локального `now`/конвертаций исправлены с тестами. +- [ ] Создан `docs/standards/DATETIME_UTC.md`; обновлён `CHECKLIST.md` (если есть). +- [ ] Список расхождений и предложенные under-задачи на миграцию к `Z` (если такие нужны) + приведены в финальном отчёте (формат — `AGENTS.md`). + +### Не делать + +- Массовую смену формата на проводе без обновления потребителей и тестов. +- Изменение семантики хранения в БД (там уже UTC) — только представление/парсинг. +- Локализацию под пояс машины «чтобы было привычнее» — это и есть источник проблемы. +``` diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx index 706c160..f10280b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx @@ -1,5 +1,6 @@ import type { RequestMapItem } from "../../model/requestTypes"; import type { MapPolygon } from "./model/mapTypes"; +import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem }; @@ -95,18 +96,18 @@ export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, on } function formatDateTime(value: string): string { - return new Date(value).toLocaleString("ru-RU"); + return formatUtcDateTime(parseUtc(value)); } function formatDateRange(begin: string, end: string): string { - const b = new Date(begin); - const e = new Date(end); + const b = new Date(parseUtc(begin)); + const e = new Date(parseUtc(end)); const sameDay = - b.getFullYear() === e.getFullYear() && - b.getMonth() === e.getMonth() && - b.getDate() === e.getDate(); - const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" }; - const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" }; + b.getUTCFullYear() === e.getUTCFullYear() && + b.getUTCMonth() === e.getUTCMonth() && + b.getUTCDate() === e.getUTCDate(); + const dateOpts: Intl.DateTimeFormatOptions = { timeZone: "UTC", day: "2-digit", month: "2-digit" }; + const timeOpts: Intl.DateTimeFormatOptions = { timeZone: "UTC", hour: "2-digit", minute: "2-digit" }; const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`; const endStr = sameDay ? b.toLocaleTimeString("ru-RU", timeOpts) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx index 0ae04cc..ecf26e4 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx @@ -3,6 +3,7 @@ import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMappe import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers"; import type { RequestsState } from "./Tgu2DMapTab"; +import { parseUtc } from "../../utils/utcTime"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -46,7 +47,7 @@ export function Tgu2DMapSidebar({ const quickToday = () => { const now = new Date(); - const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const fromMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); onRequestsQuickRange(fromMs, fromMs + DAY_MS); }; @@ -60,8 +61,8 @@ export function Tgu2DMapSidebar({ onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS); }; - const requestsFromMs = new Date(requestsState.fromValue).getTime(); - const requestsToMs = new Date(requestsState.toValue).getTime(); + const requestsFromMs = parseUtc(requestsState.fromValue); + const requestsToMs = parseUtc(requestsState.toValue); const requestsRangeInvalid = !Number.isFinite(requestsFromMs) || !Number.isFinite(requestsToMs) || diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index fb6e699..b9b5637 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -15,6 +15,7 @@ import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; +import { parseUtc, toUtcInputValue } from "../../utils/utcTime"; type Tgu2DMapTabProps = { range: TimelineRange; @@ -37,8 +38,8 @@ export type RequestsState = { function buildInitialRequestsState(range: TimelineRange): RequestsState { return { - fromValue: formatDateTimeLocal(range.fromMs), - toValue: formatDateTimeLocal(range.toMs), + fromValue: toUtcInputValue(range.fromMs), + toValue: toUtcInputValue(range.toMs), items: [], loading: false, loadedCount: 0, @@ -77,8 +78,8 @@ export function Tgu2DMapTab({ const mapInvalidRange = invalidRange || mapInterval.tooLarge; const loadRequests = useCallback(async (fromValue: string, toValue: string) => { - const fromMs = new Date(fromValue).getTime(); - const toMs = new Date(toValue).getTime(); + const fromMs = parseUtc(fromValue); + const toMs = parseUtc(toValue); if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); @@ -123,8 +124,8 @@ export function Tgu2DMapTab({ }, []); const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => { - const fromValue = formatDateTimeLocal(fromMs); - const toValue = formatDateTimeLocal(toMs); + const fromValue = toUtcInputValue(fromMs); + const toValue = toUtcInputValue(toMs); setRequestsState((current) => ({ ...current, fromValue, toValue })); void loadRequests(fromValue, toValue); }, [loadRequests]); @@ -296,6 +297,7 @@ export function Tgu2DMapTab({ function formatDate(ms: number): string { return new Date(ms).toLocaleString("ru-RU", { + timeZone: "UTC", day: "2-digit", month: "2-digit", hour: "2-digit", @@ -303,16 +305,6 @@ function formatDate(ms: number): string { }); } -function formatDateTimeLocal(ms: number): string { - const date = new Date(ms); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -} - function layerLabel(layer: Tgu2DMapLayerKey): string { switch (layer) { case "tracks": diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index 7712ec7..78f23e3 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -2,6 +2,7 @@ import type { TguPlanDecision } from "../../model/tguTypes"; import type { TguPlanUi } from "../../model/timelineTypes"; import { platformLabel } from "./tguTimelineMapper"; import { statusStyle } from "./tguStatus"; +import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; type TguPlanDetailsProps = { plan?: TguPlanUi; @@ -90,7 +91,7 @@ function Detail({ label, value, mono }: { label: string; value?: string; mono?: } function formatDateTime(value: string): string { - return new Date(value).toLocaleString("ru-RU"); + return formatUtcDateTime(parseUtc(value)); } function formatDuration(ms: number): string { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 456067d..7a95b6f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -17,13 +17,15 @@ import { mapPlans, operationalSpacecraftIds } from "./tguTimelineMapper"; +import { parseUtc, toUtcInputValue } from "../../utils/utcTime"; const DAY_MS = 24 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; export function TguPlanningPage() { const initialRange = useMemo(defaultRange, []); - const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs)); - const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs)); + const [fromValue, setFromValue] = useState(toUtcInputValue(initialRange.fromMs)); + const [toValue, setToValue] = useState(toUtcInputValue(initialRange.toMs)); const [appliedRange, setAppliedRange] = useState(initialRange); const [rawPlatforms, setRawPlatforms] = useState([]); const [plans, setPlans] = useState([]); @@ -45,8 +47,8 @@ export function TguPlanningPage() { const parsedInputRange = useMemo( () => ({ - fromMs: new Date(fromValue).getTime(), - toMs: new Date(toValue).getTime() + fromMs: parseUtc(fromValue), + toMs: parseUtc(toValue) }), [fromValue, toValue] ); @@ -133,8 +135,8 @@ export function TguPlanningPage() { const quickRange = (hours: number) => { const fromMs = startOfCurrentDayMs(); const toMs = fromMs + hours * 60 * 60 * 1000; - setFromValue(formatDateTimeLocal(fromMs)); - setToValue(formatDateTimeLocal(toMs)); + setFromValue(toUtcInputValue(fromMs)); + setToValue(toUtcInputValue(toMs)); setAppliedRange({ fromMs, toMs }); setPageError(undefined); setDecisionNotice(undefined); @@ -253,23 +255,13 @@ export function TguPlanningPage() { } function defaultRange(): TimelineRange { - const fromMs = startOfCurrentDayMs(); - return { fromMs, toMs: fromMs + DAY_MS }; + const now = Date.now(); + return { fromMs: now - 30 * MINUTE_MS, toMs: now + 3 * DAY_MS }; } function startOfCurrentDayMs(): number { const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); -} - -function formatDateTimeLocal(ms: number): string { - const date = new Date(ms); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; + return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); } function historyDaysForRange(range: TimelineRange): number { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx index 1f4271b..495e614 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -13,6 +13,7 @@ import { } from "./tguTimelineLayout"; import { platformLabel } from "./tguTimelineMapper"; import { statusStyle } from "./tguStatus"; +import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime"; type TguTimelineProps = { rows: TimelineRow[]; @@ -193,13 +194,13 @@ function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePl } function formatTickDate(ms: number): string { - return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); + return formatUtcDate(ms); } function formatTickTime(ms: number): string { - return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + return formatUtcTime(ms); } function formatDateTime(value: string): string { - return new Date(value).toLocaleString("ru-RU"); + return formatUtcDateTime(parseUtc(value)); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts index f8e7da6..1839f85 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts @@ -1,5 +1,6 @@ import type { TguPlan, TguPlatform } from "../../model/tguTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes"; +import { parseUtc } from "../../utils/utcTime"; const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]); @@ -26,8 +27,8 @@ export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[ return plans .map((plan) => { - const startMs = new Date(plan.startTime).getTime(); - const endMs = new Date(plan.endTime).getTime(); + const startMs = parseUtc(plan.startTime); + const endMs = parseUtc(plan.endTime); const platform = platformByNorad.get(plan.spacecraftId); return { ...plan, diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx index d1957d6..3945378 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx @@ -6,6 +6,7 @@ import type { RsaParams, RsaResultType } from "../../model/requestTypes"; +import { parseUtc } from "../../utils/utcTime"; export type RequestFormSubmit = { name: string; @@ -85,8 +86,8 @@ export function RequestCreatePanel({ const [rsaResolution, setRsaResolution] = useState("1"); const importanceNum = Number(importance); - const beginMs = new Date(begin).getTime(); - const endMs = new Date(end).getTime(); + const beginMs = parseUtc(begin); + const endMs = parseUtc(end); const kppList = parseKpp(kpp); const kppValid = kppList !== null; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx index d50252a..93d2298 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; type Props = { requests: RequestAtPoint[]; @@ -154,7 +155,7 @@ export function RequestDetailsPanel({ } function formatDateTime(value: string): string { - return new Date(value).toLocaleString("ru-RU"); + return formatUtcDateTime(parseUtc(value)); } function surveyTypeLabel(surveyType: string): string { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx index 62b15de..2656678 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -9,6 +9,7 @@ import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; import { generateUuid } from "../../utils/uuid"; +import { parseUtc, toUtcInputValue, toUtcIso } from "../../utils/utcTime"; import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; import { RequestDetailsPanel } from "./RequestDetailsPanel"; import { polygonToWkt } from "./requestGeometry"; @@ -35,8 +36,8 @@ type PanelMode = "none" | "details" | "create"; function buildInitialLoadState(range: TimelineRange): RequestsLoadState { return { - fromValue: formatDateTimeLocal(range.fromMs), - toValue: formatDateTimeLocal(range.toMs), + fromValue: toUtcInputValue(range.fromMs), + toValue: toUtcInputValue(range.toMs), items: [], loading: false, loadedCount: 0, @@ -73,8 +74,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa }, []); const loadRequests = useCallback(async (fromValue: string, toValue: string) => { - const fromMs = new Date(fromValue).getTime(); - const toMs = new Date(toValue).getTime(); + const fromMs = parseUtc(fromValue); + const toMs = parseUtc(toValue); if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); @@ -202,8 +203,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa name: form.name, geometry, importance: form.importance, - beginDateTime: new Date(form.beginDateTime).toISOString(), - endDateTime: new Date(form.endDateTime).toISOString(), + beginDateTime: toUtcIso(form.beginDateTime), + endDateTime: toUtcIso(form.endDateTime), kpp: form.kpp, highPriorityTransmit: form.highPriorityTransmit, optics: form.optics, @@ -242,8 +243,8 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa [closeAll, loadRequests, requestsState.fromValue, requestsState.toValue] ); - const fromMs = new Date(requestsState.fromValue).getTime(); - const toMs = new Date(requestsState.toValue).getTime(); + const fromMs = parseUtc(requestsState.fromValue); + const toMs = parseUtc(requestsState.toValue); const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs; const hasPanel = panelMode !== "none"; @@ -409,13 +410,3 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
); } - -function formatDateTimeLocal(ms: number): string { - const date = new Date(ms); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -} diff --git a/services/pcp-tgu-ops-ui/src/utils/utcTime.ts b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts new file mode 100644 index 0000000..117c03e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts @@ -0,0 +1,51 @@ +/** + * Единый разбор и форматирование времени в UTC. + * + * Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны + * (например `"2026-05-29T01:00:00"`) и по соглашению системы это UTC. + * Конструктор `Date`/`Date.parse` трактует такую строку как локальное время + * браузера, поэтому naive-строки нужно парсить явно как UTC, а отображать — + * с `timeZone: "UTC"`. Весь интерфейс ТГУ работает в UTC. + */ + +/** Разбирает ISO-строку как UTC: если зоны нет — добавляет `Z`. */ +export function parseUtc(value: string): number { + const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(value); + return Date.parse(hasZone ? value : `${value}Z`); +} + +/** Дата-время в UTC, формат ru-RU. */ +export function formatUtcDateTime(ms: number): string { + return new Date(ms).toLocaleString("ru-RU", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }); +} + +/** Дата (дд.мм) в UTC. */ +export function formatUtcDate(ms: number): string { + return new Date(ms).toLocaleDateString("ru-RU", { timeZone: "UTC", day: "2-digit", month: "2-digit" }); +} + +/** Время (чч:мм) в UTC. */ +export function formatUtcTime(ms: number): string { + return new Date(ms).toLocaleTimeString("ru-RU", { timeZone: "UTC", hour: "2-digit", minute: "2-digit" }); +} + +/** Значение для `` в UTC (`YYYY-MM-DDTHH:mm`). */ +export function toUtcInputValue(ms: number): string { + return new Date(ms).toISOString().slice(0, 16); +} + +/** + * Naive-строку (из `datetime-local`) трактует как UTC и возвращает ISO с `Z`. + * Для бэкендов с `OffsetDateTime` на границе API (напр. pcp-request-service): + * пользователь вводит время по UTC, отправляем его же как UTC-смещение. + */ +export function toUtcIso(value: string): string { + return new Date(parseUtc(value)).toISOString(); +} From ac2220d0634079abfeb6939d6efbfda80016f747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 16:54:01 +0300 Subject: [PATCH 19/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=E2=80=94=20=D0=BF=D0=BB=D0=B0=D0=BD,=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BD?= =?UTF-8?q?=D0=B8=D0=B6=D0=BD=D1=8F=D1=8F=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Слева выводим даты начала/конца плана и корректные счётчики съёмки/сброса (из plan modes, а не из заглушки-черновика). - На таймлайне показываем реальные включения плана (read-only): убран фейковый «сброс на весь интервал»; вместо линии «сейчас» — маркеры начала и конца плана; клик по включению открывает его инфо справа. - Включения съёмки и сброса разнесены на два ряда (верх/низ) и различаются цветом, слева — подписи рядов. - Нижняя панель: активная дорожка резиновая — примыкает к низу и не пустует, при подъёме панели ряды расширяются; высота бара ограничена потолком и центрируется в ряду, чтобы узкие включения не вытягивались. - Время в редакторе парсится/форматируется через utils/utcTime. --- .../src/features/tgu-editor/EditorPanels.tsx | 89 +++++++----- .../src/features/tgu-editor/EditorRail.tsx | 27 ++-- .../features/tgu-editor/EditorTimeline.tsx | 128 +++++++----------- .../src/features/tgu-editor/TguEditorTab.tsx | 37 +++-- .../features/tgu-editor/editorDraft.test.ts | 97 ++++++++++--- .../src/features/tgu-editor/editorDraft.ts | 59 ++++++-- .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 84 ++++++++++-- 7 files changed, 340 insertions(+), 181 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 016ff09..6219284 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,5 +1,6 @@ import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { statusStyle } from "../tgu-planning/tguStatus"; +import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import type { RvaZone } from "../../api/ballisticsApi"; import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; @@ -28,6 +29,8 @@ type WorkInspectorProps = { modes: EditorMode[]; stations: EditorGroundStation[]; conflicts: string[]; + /** Включение существующего плана — показываем только информацию, без правок. */ + readOnly?: boolean; onUpdate: (workId: string, patch: EditorWorkPatch) => void; onDelete: (workId: string) => void; onDuplicate: (workId: string) => void; @@ -96,6 +99,7 @@ export function WorkInspector({ modes, stations, conflicts, + readOnly, onUpdate, onDelete, onDuplicate, @@ -118,26 +122,43 @@ export function WorkInspector({
Время
- onUpdate(work.id, { startMs: value })} /> - onUpdate(work.id, { endMs: value })} /> -
длительность: {formatDuration(work.endMs - work.startMs)}
+ {readOnly ? ( +
+
Начало
+
{formatDateTime(work.startMs)}
+
Конец
+
{formatDateTime(work.endMs)}
+
Длительность
+
{formatDuration(work.endMs - work.startMs)}
+
+ ) : ( + <> + onUpdate(work.id, { startMs: value })} /> + onUpdate(work.id, { endMs: value })} /> +
длительность: {formatDuration(work.endMs - work.startMs)}
+ + )}
{work.kind === "shoot" && (
Режим аппаратуры
-
- {modes.map((mode) => ( - - ))} -
+ {readOnly ? ( +
{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}
+ ) : ( +
+ {modes.map((mode) => ( + + ))} +
+ )} {work.targetName &&
цель: {work.targetName}
}
)} @@ -145,24 +166,30 @@ export function WorkInspector({ {work.kind === "downlink" && (
Станция приёма
- + {readOnly ? ( +
{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}
+ ) : ( + + )}
)} -
- - -
+ {!readOnly && ( +
+ + +
+ )}
@@ -556,7 +583,7 @@ function surveyTypeLabel(surveyType: string): string { } function formatIso(iso: string): string { - return new Date(iso).toLocaleString("ru-RU"); + return formatUtcDateTime(parseUtc(iso)); } function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx index c44a959..b2bbef4 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorRail.tsx @@ -1,11 +1,12 @@ import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes"; -import type { Draft, EditorWork } from "./model/editorTypes"; +import { formatDateTime } from "./editorPresentation"; type EditorRailProps = { selectedSpacecraftId?: string; selectedPlan?: TguPlanUi; platforms: TguPlatformUi[]; - draft: Draft; + surveyCount: number; + downlinkCount: number; conflictCount: number; contextSpacecraftIds: Set; onToggleContext: (spacecraftId: string) => void; @@ -15,7 +16,8 @@ export function EditorRail({ selectedSpacecraftId, selectedPlan, platforms, - draft, + surveyCount, + downlinkCount, conflictCount, contextSpacecraftIds, onToggleContext @@ -32,10 +34,19 @@ export function EditorRail({ {selectedPlan ? `${selectedPlan.planId} · КПП ${selectedPlan.kppId}` : "План не выбран"}
+ {selectedPlan && ( +
+
Начало
+
{formatDateTime(selectedPlan.startMs)}
+
Окончание
+
{formatDateTime(selectedPlan.endMs)}
+
+ )} +
- - - + + + 0 ? "var(--now)" : undefined} />
@@ -76,7 +87,3 @@ function RailStat({ label, value, color }: { label: string; value: number; color ); } - -function countWorks(works: EditorWork[], kind: EditorWork["kind"]): number { - return works.filter((work) => work.kind === kind).length; -} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx index 3d00949..35249b0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx @@ -4,128 +4,86 @@ import type { EditorContextLane, EditorTimelineWindow, EditorVisibilityTrack, - EditorWork, - EditorWorkPatch + EditorWork } from "./model/editorTypes"; import { VisibilityTracks } from "./VisibilityTracks"; type EditorTimelineProps = { activeLabel: string; - draft: EditorWork[]; + works: EditorWork[]; selectedWorkId?: string; window: EditorTimelineWindow; - nowMs: number; + planStartMs?: number; + planEndMs?: number; conflicts: Record; contextLanes: EditorContextLane[]; visibilityTracks: EditorVisibilityTrack[]; onSelectWork: (workId: string) => void; onClearSelection: () => void; - onChangeWork: (workId: string, patch: EditorWorkPatch) => void; }; const LABEL_WIDTH = 168; const AXIS_HEIGHT = 40; -const ACTIVE_HEIGHT = 66; +/** Минимальная высота активной дорожки (два ряда: съёмка + сброс). */ +const MIN_ACTIVE_HEIGHT = 84; const VISIBILITY_HEIGHT = 24; const OTHER_HEIGHT = 30; -const SNAP_MS = 60 * 1000; - -type DragState = { - workId: string; - mode: "move" | "left" | "right"; - startX: number; - startMs: number; - endMs: number; -}; export function EditorTimeline({ activeLabel, - draft, + works, selectedWorkId, window: timelineWindow, - nowMs, + planStartMs, + planEndMs, conflicts, contextLanes, visibilityTracks, onSelectWork, - onClearSelection, - onChangeWork + onClearSelection }: EditorTimelineProps) { const wrapperRef = useRef(null); - const dragRef = useRef(undefined); const [bodyWidth, setBodyWidth] = useState(900); + const [bodyHeight, setBodyHeight] = useState(260); const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs); const pxPerMs = bodyWidth / span; const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs; const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]); const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]); - const visibilityTop = AXIS_HEIGHT + ACTIVE_HEIGHT; - const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT; - const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0); - const totalHeight = - AXIS_HEIGHT + - ACTIVE_HEIGHT + + // Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА). + const belowActiveHeight = visibilityTracks.length * VISIBILITY_HEIGHT + (contextLanes.length > 0 ? 22 : 0) + contextLanes.length * OTHER_HEIGHT + 8; + // Активная дорожка резиновая: занимает всё свободное место панели, поэтому при + // подъёме панели вверх ряды съёмки/сброса расширяются, а низ не пустует. + const activeHeight = Math.max(MIN_ACTIVE_HEIGHT, bodyHeight - AXIS_HEIGHT - belowActiveHeight); + const visibilityTop = AXIS_HEIGHT + activeHeight; + const dividerTop = visibilityTop + visibilityTracks.length * VISIBILITY_HEIGHT; + const contextTop = dividerTop + (contextLanes.length > 0 ? 22 : 0); + const contentHeight = AXIS_HEIGHT + activeHeight + belowActiveHeight; + const surfaceHeight = Math.max(bodyHeight, contentHeight); useEffect(() => { const element = wrapperRef.current; if (!element) return; - const observer = new ResizeObserver(() => { + const measure = () => { setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH)); - }); + setBodyHeight(element.clientHeight); + }; + + const observer = new ResizeObserver(measure); observer.observe(element); - setBodyWidth(Math.max(560, element.clientWidth - LABEL_WIDTH)); + measure(); return () => observer.disconnect(); }, []); - useEffect(() => { - const onMove = (event: MouseEvent) => { - const drag = dragRef.current; - if (!drag) return; - - const deltaMs = Math.round(((event.clientX - drag.startX) / pxPerMs) / SNAP_MS) * SNAP_MS; - if (drag.mode === "move") { - onChangeWork(drag.workId, { startMs: drag.startMs + deltaMs, endMs: drag.endMs + deltaMs }); - } else if (drag.mode === "left") { - onChangeWork(drag.workId, { startMs: Math.min(drag.endMs - SNAP_MS, drag.startMs + deltaMs) }); - } else { - onChangeWork(drag.workId, { endMs: Math.max(drag.startMs + SNAP_MS, drag.endMs + deltaMs) }); - } - }; - - const onUp = () => { - dragRef.current = undefined; - }; - - window.addEventListener("mousemove", onMove); - window.addEventListener("mouseup", onUp); - return () => { - window.removeEventListener("mousemove", onMove); - window.removeEventListener("mouseup", onUp); - }; - }, [onChangeWork, pxPerMs]); - - const startDrag = (event: React.MouseEvent, work: EditorWork, mode: DragState["mode"]) => { - event.preventDefault(); - event.stopPropagation(); - dragRef.current = { - workId: work.id, - mode, - startX: event.clientX, - startMs: work.startMs, - endMs: work.endMs - }; - onSelectWork(work.id); - }; - return (
-
+
Время (UTC)
@@ -149,19 +107,27 @@ export function EditorTimeline({ ))}
- {nowMs >= timelineWindow.fromMs && nowMs <= timelineWindow.toMs && ( -
- Сейчас + {planStartMs != null && planStartMs >= timelineWindow.fromMs && planStartMs <= timelineWindow.toMs && ( +
+ Начало плана
)} -
-
+ {planEndMs != null && planEndMs >= timelineWindow.fromMs && planEndMs <= timelineWindow.toMs && ( +
+ Конец плана +
+ )} + +
+
{activeLabel} редактируется + Съёмка + Сброс
- {draft.map((work) => { + {works.map((work) => { const left = toX(work.startMs); const width = Math.max(6, toX(work.endMs) - toX(work.startMs)); const color = kindColor(work.kind); @@ -169,8 +135,9 @@ export function EditorTimeline({ const hasConflict = Boolean(conflicts[work.id]?.length); return ( -
startDrag(event, work, "move")} onClick={(event) => { event.stopPropagation(); onSelectWork(work.id); }} > - startDrag(event, work, "left")} /> - startDrag(event, work, "right")} /> {width > 54 && {work.label}} {work.isNew && NEW} {width > 82 && {formatTime(work.startMs)}-{formatTime(work.endMs)}} -
+ ); })}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 4be10f9..0919a4a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -6,6 +6,7 @@ import type { RequestMapItem } from "../../model/requestTypes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; +import { parseUtc } from "../../utils/utcTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers"; @@ -19,6 +20,7 @@ import { deleteWork, duplicateWork, mutate, + planModesToWorks, redo, seedDraft, undo, @@ -40,6 +42,7 @@ import type { EditorTimelineWindow, EditorVisibilityTrack, EditorVisibilityWindow, + EditorWork, EditorWorkPatch, TguEditorTabProps } from "./model/editorTypes"; @@ -68,7 +71,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const idCounterRef = useRef(0); const targetCounterRef = useRef(0); const centerRef = useRef(null); - const [mapRatio, setMapRatio] = useState(0.72); + // Доля высоты под карту: по умолчанию карта крупная, а нижняя панель компактно + // примыкает к низу. Подъём резайзера вверх уменьшает долю и расширяет панель. + const [mapRatio, setMapRatio] = useState(0.8); const [history, setHistory] = useState(() => ({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], @@ -124,14 +129,17 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, .map(([revolution, pts], idx) => ({ index: idx + 1, revolution, - startMs: Date.parse(pts[0].time), - endMs: Date.parse(pts[pts.length - 1].time), + startMs: parseUtc(pts[0].time), + endMs: parseUtc(pts[pts.length - 1].time), })); }, [flightLineData]); const modes: EditorMode[] = DEFAULT_EDITOR_MODES; + // Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне. + const planWorks = useMemo(() => planModesToWorks(planModes), [planModes]); + const displayWorks = useMemo(() => [...planWorks, ...draft.works], [planWorks, draft.works]); const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); - const selectedWork = draft.works.find((work) => work.id === selectedWorkId); + const selectedWork = displayWorks.find((work) => work.id === selectedWorkId); const activeLabel = platformLabel(platforms, selectedSpacecraftId); const [shootPasses, setShootPasses] = useState([]); const [shootPassesLoading, setShootPassesLoading] = useState(false); @@ -187,6 +195,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); const addedCount = draft.works.filter((work) => work.isNew).length; + // Реальный состав плана берём из включений (plan modes), а не из черновика: + // черновик сидируется заглушкой, когда у плана нет встроенных works. + const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]); + const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]); useEffect(() => { setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); @@ -559,7 +571,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const el = centerRef.current; if (!el) return; const dy = ev.clientY - startY; - setMapRatio(Math.max(0.15, Math.min(0.80, startRatio + dy / el.clientHeight))); + setMapRatio(Math.max(0.15, Math.min(0.86, startRatio + dy / el.clientHeight))); }; const onUp = () => { @@ -618,7 +630,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, selectedSpacecraftId={selectedSpacecraftId} selectedPlan={selectedPlan} platforms={platforms} - draft={draft} + surveyCount={planSurveyCount} + downlinkCount={planDownlinkCount} conflictCount={conflictCount(conflicts)} contextSpacecraftIds={contextSpacecraftIds} onToggleContext={(spacecraftId) => { @@ -681,10 +694,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
@@ -711,6 +724,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, modes={modes} stations={stations} conflicts={conflicts[selectedWork.id] ?? []} + readOnly={selectedWork.origin === "plan"} onUpdate={onChangeWork} onDelete={(workId) => { changeDraft((current) => deleteWork(current, workId)); @@ -810,7 +824,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { - const timeMs = Date.parse(param.time); + const timeMs = parseUtc(param.time); const half = SHOOT_WINDOW_MS / 2; return { startMs: timeMs - half, @@ -851,8 +865,7 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG * зоны ЗРВ приходят без таймзоны, а времена съёмок — с offset; приводим к одной шкале. */ function localDateTimeMs(s: string): number { - const hasZone = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(s); - return Date.parse(hasZone ? s : `${s}Z`); + return parseUtc(s); } function buildVisibilityTracks( diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index f6ec4ec..56401a8 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -6,11 +6,13 @@ import { deleteWork, duplicateWork, mutate, + planModesToWorks, redo, seedDraft, undo, updateWork } from "./editorDraft"; +import type { PlanMode } from "./editorApi"; import type { DraftHistory } from "./model/editorTypes"; const plan: TguPlanUi = { @@ -25,24 +27,81 @@ const plan: TguPlanUi = { }; describe("editorDraft", () => { - it("seeds a draft from the current API plan envelope when embedded works are absent", () => { + it("seeds an empty draft when the plan has no embedded works", () => { + // Существующие включения грузятся отдельно (planModesToWorks); черновик хранит только правки. expect(seedDraft(plan)).toEqual({ planId: "plan-1", spacecraftId: "56756", - works: [ - { - id: "plan-1:plan-window", - kind: "downlink", - label: "План ТГУ · KPP-1", - startMs: plan.startMs, - endMs: plan.endMs, - stationId: "KPP-1", - origin: "plan" - } - ] + works: [] }); }); + it("maps plan modes to read-only timeline works", () => { + const modes: PlanMode[] = [ + { + id: 11, + planId: 1, + timeStart: "2026-05-29T01:10:00", + revolution: 4321, + type: "SURVEY", + status: "PLANNED", + lat: 55.7, + longitude: 37.6, + duration: 180, + contourWkt: null, + roll: 0 + }, + { + id: 22, + planId: 1, + timeStart: "2026-05-29T01:40:00", + revolution: 4322, + type: "DROP", + station: "102", + duration: 120, + surveys: [11] + }, + { + id: 33, + planId: 1, + timeStart: null, + revolution: 4323, + type: "SURVEY", + status: "PLANNED", + lat: 0, + longitude: 0, + duration: 60, + contourWkt: null, + roll: 0 + } + ]; + + const works = planModesToWorks(modes); + + // Включение без времени старта пропускается, оба валидных — read-only (origin: "plan"). + expect(works).toEqual([ + { + id: "survey-11", + kind: "shoot", + label: "Съёмка · виток 4321", + startMs: Date.parse("2026-05-29T01:10:00Z"), + endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000, + origin: "plan", + targetLat: 55.7, + targetLon: 37.6 + }, + { + id: "drop-22", + kind: "downlink", + label: "Сброс · 102", + startMs: Date.parse("2026-05-29T01:40:00Z"), + endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000, + origin: "plan", + stationId: "102" + } + ]); + }); + it("adds shoot and downlink works with deterministic ids", () => { const draft = seedDraft(undefined, "56756"); const withShoot = addShoot(draft, { @@ -88,10 +147,16 @@ describe("editorDraft", () => { }); it("updates, duplicates and deletes works without generating ids internally", () => { - const draft = seedDraft(plan); - const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 }); - const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000); - const deleted = deleteWork(duplicated, "plan-1:plan-window"); + const draft = addDownlink(seedDraft(plan), { + id: "downlink-1", + label: "Сброс на НКПОР", + startMs: plan.startMs, + endMs: plan.startMs + 120_000, + station: { id: "KPP-1", name: "KPP-1" } + }); + const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 }); + const duplicated = duplicateWork(updated, "downlink-1", "copy-1", 120_000); + const deleted = deleteWork(duplicated, "downlink-1"); expect(updated.works[0].label).toBe("Сеанс"); expect(duplicated.works[1]).toMatchObject({ diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 0153d3e..d3b1b4e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -1,4 +1,6 @@ import type { TguPlanUi } from "../../model/timelineTypes"; +import { parseUtc } from "../../utils/utcTime"; +import type { PlanMode } from "./editorApi"; import type { Draft, DraftHistory, @@ -35,14 +37,53 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { return { spacecraftId, works: [] }; } - const embeddedWorks = readEmbeddedWorks(plan); return { planId: plan.planId, spacecraftId: plan.spacecraftId, - works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)] + // Черновик содержит только правки, внесённые в редакторе. Существующие включения + // плана грузятся отдельно (fetchPlanModes) и показываются как read-only. + works: readEmbeddedWorks(plan) }; } +/** + * Преобразует включения плана (plan modes из pcp-tgu-service) в работы для отображения + * на таймлайне редактора. Это read-only представление фактического состава плана: + * SURVEY → съёмка, DROP → сброс на НКПОР. + */ +export function planModesToWorks(modes: PlanMode[]): EditorWork[] { + return modes.flatMap((mode): EditorWork[] => { + if (mode.timeStart == null) return []; + const startMs = parseUtc(mode.timeStart); + if (!Number.isFinite(startMs)) return []; + const endMs = startMs + Math.max(1, mode.duration) * 1000; + + if (mode.type === "SURVEY") { + return [{ + id: `survey-${mode.id}`, + kind: "shoot", + label: `Съёмка · виток ${mode.revolution}`, + startMs, + endMs, + origin: "plan", + targetLat: mode.lat, + targetLon: mode.longitude + }]; + } + + return [{ + id: `drop-${mode.id}`, + kind: "downlink", + label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР", + startMs, + endMs, + origin: "plan", + stationId: mode.station ?? undefined + }]; + }); +} + + export function mutate( history: DraftHistory, transform: (draft: Draft) => Draft, @@ -154,18 +195,6 @@ export function duplicateWork( return { ...draft, works: [...draft.works, duplicated] }; } -function fallbackPlanWork(plan: TguPlanUi): EditorDownlinkWork { - return { - id: `${plan.planId}:plan-window`, - kind: "downlink", - label: `План ТГУ · ${plan.kppId}`, - startMs: plan.startMs, - endMs: plan.endMs, - stationId: plan.kppId, - origin: "plan" - }; -} - function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] { const rawWorks = (plan as TguPlanUi & { works?: unknown }).works; if (!Array.isArray(rawWorks)) { @@ -217,7 +246,7 @@ function readTime(record: Record, key: string): number | undefi return value; } if (typeof value === "string") { - const parsed = new Date(value).getTime(); + const parsed = parseUtc(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 0361f45..ab64a8e 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -147,6 +147,25 @@ font-weight: 700; } +.tgu-editor-rail__plan-dates { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2rem 0.6rem; + margin: 0; +} + +.tgu-editor-rail__plan-dates dt { + color: var(--text-dim); + font-size: 0.66rem; +} + +.tgu-editor-rail__plan-dates dd { + margin: 0; + color: var(--text); + font-size: 0.7rem; + text-align: right; +} + .tgu-editor-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -553,26 +572,34 @@ border-left: 1px solid var(--grid); } -.tgu-editor-timeline__now { +.tgu-editor-timeline__bound { position: absolute; bottom: 0; z-index: 8; - border-left: 1.5px dashed var(--now); + border-left: 1.5px solid var(--accent); pointer-events: none; } -.tgu-editor-timeline__now span { +.tgu-editor-timeline__bound span { position: absolute; - left: 0.18rem; top: 0; border-radius: 3px; padding: 0 0.24rem; - background: var(--now); + background: var(--accent); color: var(--bg-base); font-family: var(--mono); font-size: 0.55rem; font-weight: 700; text-transform: uppercase; + white-space: nowrap; +} + +.tgu-editor-timeline__bound--start span { + left: 0.18rem; +} + +.tgu-editor-timeline__bound--end span { + right: 0.18rem; } .tgu-editor-timeline__lane { @@ -622,11 +649,15 @@ gap: 0.28rem; height: calc(100% - 1.5rem); overflow: hidden; + appearance: none; border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 5px; padding: 0.25rem 0.45rem; - cursor: grab; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; user-select: none; } @@ -649,20 +680,43 @@ pointer-events: none; } -.tgu-editor-work__resize { +/* Активная дорожка разбита на два ряда: верхний — съёмка, нижний — сброс. + Бар центрируется по своему ряду и растёт вместе с ним, но не выше потолка — + чтобы узкие включения не растягивались в непропорционально высокие полоски. */ +.tgu-editor-work--shoot, +.tgu-editor-work--downlink { + height: min(56px, calc(50% - 0.55rem)); + transform: translateY(-50%); +} + +.tgu-editor-work--shoot { + top: 25%; +} + +.tgu-editor-work--downlink { + top: 75%; +} + +.tgu-editor-timeline__row-hint { position: absolute; - top: 0; - bottom: 0; - width: 0.45rem; - cursor: ew-resize; + right: 0.65rem; + font-style: normal; + font-size: 0.56rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; } -.tgu-editor-work__resize--left { - left: 0; +.tgu-editor-timeline__row-hint--shoot { + top: 25%; + transform: translateY(-50%); + color: var(--t-optical); } -.tgu-editor-work__resize--right { - right: 0; +.tgu-editor-timeline__row-hint--downlink { + top: 75%; + transform: translateY(-50%); + color: var(--accent); } .tgu-editor-work__dot { From 260583b134705d28ce41ecc065362b49dc6d1394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 21:51:38 +0300 Subject: [PATCH 20/79] =?UTF-8?q?pcp:=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=B8=D0=B5=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=20(UTC)=20=D0=BF=D0=BE=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фронт (pcp-tgu-ops-ui): - тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) / toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc. Бэкенд: - UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах. - Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе): - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime); - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime); - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC); - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()). - Контрактные сериализационные тесты формата на проводе (naive без зоны / Z): PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui). - KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ). Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z), docs/standards/CHECKLIST.md (пункт про UTC на API). --- docs/standards/CHECKLIST.md | 1 + docs/standards/DATETIME_UTC.md | 92 +++++++++++++++++++ .../entity/AscNodeEntity.kt | 3 +- .../entity/EarthCellViewsEntity.kt | 5 +- .../entity/EarthCoverageEntity.kt | 5 +- .../entity/FlightLineEntity.kt | 3 +- .../pcp_request_service/entity/PDCMEntity.kt | 3 +- .../entity/SatelliteInitialConditionEntity.kt | 7 +- .../service/SatelliteService.kt | 13 +-- .../entity/ComplexPlanRunEntity.kt | 7 +- .../entity/SatelliteModeEntity.kt | 5 +- .../entity/SatelliteModeSnapshotEntity.kt | 7 +- .../model/mode/SurveyMode.kt | 5 +- .../db/entity/DynamicPlanRouteEntity.kt | 5 +- .../complan/db/entity/DynamicPlanRunEntity.kt | 3 +- .../complan/service/DynamicPlanRunService.kt | 11 ++- .../complan/service/RequestServiceClient.kt | 7 +- .../pcp/complan/spacecraft/SpaceCraft.kt | 5 +- .../service/RequestServiceClientTest.kt | 24 ++++- .../service/BookedSlotsEventPublisher.kt | 3 +- .../service/MissionPlaningService.kt | 24 +++-- .../service/MissionPlaningServiceTest.kt | 35 +++++++ .../service/OutboxPublisherService.kt | 3 +- .../requestservice/service/RequestService.kt | 4 +- .../service/RouteMatchingService.kt | 3 +- .../dto/RequestResponseDtoContractTest.kt | 47 ++++++++++ .../RequestGridProjectionServiceTest.kt | 9 +- .../service/RouteMatchingServiceTest.kt | 13 +-- .../service/RoutePassportIngestionService.kt | 3 +- .../service/RoutePassportOutboxService.kt | 7 +- .../RoutePassportIngestionServiceTest.kt | 7 +- .../features/tgu-editor/editorDraft.test.ts | 13 +-- .../tgu-map-2d/tgu2DMapInterval.test.ts | 13 +-- .../tgu-planning/tguTimelineLayout.test.ts | 13 +-- .../src/utils/utcTime.guard.test.ts | 62 +++++++++++++ services/pcp-tgu-ops-ui/tsconfig.app.json | 4 +- .../pcp_tgu_service/dto/PlanResponse.kt | 9 +- .../AcceptedPlanSlotsUpdateJobEntity.kt | 5 +- .../entity/PlanIssueAttemptEntity.kt | 5 +- .../entity/PlannedPlanEntity.kt | 5 +- .../ProcessedPlanDecisionEventEntity.kt | 3 +- .../entity/SpacecraftPlanningStateEntity.kt | 3 +- .../entity/SpacecraftSnapshotEntity.kt | 5 +- .../SpacecraftVisibilitySnapshotEntity.kt | 5 +- .../entity/VisibilityRefreshJobEntity.kt | 5 +- .../integration/api/ExternalPointsClient.kt | 3 +- .../api/VisibilityPayloadParser.kt | 10 +- .../AcceptedPlanSlotsUpdateJobService.kt | 3 +- .../service/AcceptedPlanSlotsUpdateWorker.kt | 7 +- .../service/HandlePlanDecisionUseCase.kt | 11 ++- .../HandleStationParamsChangedUseCase.kt | 3 +- .../service/IssueDuePlanWorker.kt | 9 +- .../service/PlanQueryService.kt | 3 +- .../SpacecraftVisibilitySnapshotService.kt | 3 +- .../service/SyncSpacecraftFromNsiUseCase.kt | 10 +- .../service/TestPlanDecisionService.kt | 3 +- .../service/VisibilityRefreshJobService.kt | 3 +- .../service/VisibilityRefreshWorker.kt | 13 +-- .../dto/PlanResponseContractTest.kt | 45 +++++++++ .../api/VisibilityPayloadParserTest.kt | 31 +++++++ .../HandleStationParamsChangedUseCaseTest.kt | 3 +- .../service/IssueDuePlanWorkerOnDemandTest.kt | 5 +- .../service/PlanIssueAndDecisionFlowTest.kt | 11 ++- .../service/PlanProjectionServiceTest.kt | 5 +- .../service/PlanQueryServiceTest.kt | 3 +- .../SyncSpacecraftFromNsiUseCaseTest.kt | 90 ++++++++++++++++++ .../service/TestPlanDecisionServiceTest.kt | 5 +- .../service/VisibilityFlowTest.kt | 5 +- .../controller/CatalogRestController.kt | 3 +- .../slots_service/dto/bookings/BookingsDTO.kt | 2 + .../dto/groupstate/GroupStateDTO.kt | 4 + .../dto/pdcm/SatellitePdcmDTO.kt | 4 + .../slots_service/dto/tgu/TguPlanningDTO.kt | 5 + .../dto/tleanalysis/TleAnalysisDTO.kt | 4 + .../dto/tlecomparison/TleComparisonDTO.kt | 4 + .../pcp/slots_service/service/CZMLService.kt | 2 +- .../service/CoverageSchemeService.kt | 5 +- .../service/SatellitePdcmService.kt | 5 +- .../dto/tgu/TguPlanningDtoContractTest.kt | 62 +++++++++++++ .../slots_service/entity/SatelliteIcEntity.kt | 3 +- .../pcp/slots_service/entity/SlotEntity.kt | 5 +- .../nstart/pcp/slots_service/model/Mar.kt | 5 +- .../repository/SlotRepositoryImpl.kt | 7 +- .../service/CelesTrakService.kt | 3 +- 84 files changed, 760 insertions(+), 166 deletions(-) create mode 100644 docs/standards/DATETIME_UTC.md create mode 100644 services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt create mode 100644 services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt create mode 100644 services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 27445e9..3be28ae 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -21,6 +21,7 @@ - существующие публичные контракты не сломаны без явного запроса; - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; +- время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md new file mode 100644 index 0000000..c8f6c15 --- /dev/null +++ b/docs/standards/DATETIME_UTC.md @@ -0,0 +1,92 @@ +# DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов `pcp` + +## Назначение + +Этот документ фиксирует соглашение по работе со временем во всей группе сервисов `pcp` +и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде. +Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе, +которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч). + +## 1. Соглашение (canonical) + +1. **Всё время — UTC.** На любом API времена представляют момент в UTC. В БД — тоже UTC. +2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис исторически + отдаёт naive `LocalDateTime` (ISO без зоны, напр. `"2026-05-29T01:00:00"`), это допустимо + **только** при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к `Z`. +3. **Backend:** + - «сейчас» — только UTC: `Instant.now()` или `LocalDateTime.now(ZoneOffset.UTC)`; **не** `LocalDateTime.now()`; + - конвертация `Instant`/`OffsetDateTime` → `LocalDateTime` для naive-полей — только через UTC + (`LocalDateTime.ofInstant(instant, ZoneOffset.UTC)`, `withOffsetSameInstant(ZoneOffset.UTC)`), + **никогда** через `ZoneId.systemDefault()` / `atZone(systemDefault())`; + - Jackson во всех сервисах настроен с `JavaTimeModule` + `disable(WRITE_DATES_AS_TIMESTAMPS)`, + поэтому `LocalDateTime` уходит как ISO без зоны, а `OffsetDateTime`/`Instant` — с `Z`/офсетом. +4. **Frontend (`pcp-tgu-ops-ui`):** + - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких «голых» + `new Date(string)` / `Date.parse(string)`; + - отображение — только UTC (`formatUtcDateTime/Date/Time` либо `toLocale*` с `timeZone: "UTC"`); + - `` — значение через `toUtcInputValue`, чтение через `parseUtc`, + отправка на offset-бэк — через `toUtcIso`. + - Инвариант закреплён тестом-стражем `src/utils/utcTime.guard.test.ts` (падает на запрещённых паттернах). +5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка. + +## 2. Эталоны переиспользования + +- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования. +- Backend: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход + через `OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()` — корректный образец. + +## 3. Состояние сервисов (срез аудита) + +Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC), +`OffsetDateTime`/`Instant` → ISO с `Z`. «Потребители» — кто читает время этого сервиса. + +| Сервис | Преобладает на границе API | Naive? | Вердикт | +|---|---|---|---| +| pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе | +| pcp-tgu-service | `LocalDateTime` (planId/plan times) | да | задокументирован «=UTC» (`PlanResponse`); баги конвертаций исправлены | +| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries) | да | задокументировать «=UTC»; кандидат на `Z` | +| pcp-complex-mission-service | `LocalDateTime` (+ часть `Instant`) | смешано | проверить смешение; задокументировать | +| pcp-dynamic-plan-service | `LocalDateTime` (+ offset на входе клиента) | да | задокументировать «=UTC» | +| pcp-ballistics-service | `LocalDateTime` | да | задокументировать «=UTC» | +| pcp-mission-planing-service | `LocalDateTime` (вход — `OffsetDateTime`) | да | задокументировать «=UTC» | +| slots-service | `LocalDateTime` | да | задокументировать «=UTC»; проверить `rs.getTimestamp().toLocalDateTime()` | +| pcp-coverage-scheme-service | `LocalDateTime` | да | задокументировать «=UTC» | +| pcp-route-processing-service | нет времени на границе API | — | — | +| pcp-satellite-catalog-service | нет времени на границе API | — | — | +| pcp-stations-service | нет времени на границе API | — | — | +| tle-monitoring-service | нет времени на границе API | — | — | + +## 4. Известные расхождения и под-задачи (миграция к `Z` — не «большим взрывом») + +Систематический класс: `LocalDateTime.now()` без UTC-зоны (~80 мест: entity-дефолты `createdAt/updatedAt`, +воркеры, use case'ы). На не-UTC JVM пишет локальное «сейчас» в UTC-поля. Низкий риск для audit-колонок, +но формально неверно. Рекомендованные под-задачи по сервисам: + +- **pcp-tgu-service** — заменить оставшиеся `LocalDateTime.now()` (воркеры выдачи/решений, `PlanQueryService` + cutoff, entity-дефолты) на `LocalDateTime.now(ZoneOffset.UTC)`; тест на cutoff истории. +- **slots-service** — проверить `SlotRepositoryImpl.rs.getTimestamp("tn").toLocalDateTime()` (зависит от + зоны драйвера); зафиксировать чтение в UTC. +- **pcp-mission-planing-service / pcp-dynamic-plan-service** — `OffsetDateTime.toLocalDateTime()` теряет + офсет; убедиться, что вход всегда UTC, иначе конвертировать через `withOffsetSameInstant(UTC)`. +- **Все сервисы с naive `LocalDateTime` на границе** — добавить сериализационный тест, фиксирующий формат + на проводе (есть `Z` / нет зоны), затем по согласованию мигрировать DTO к `OffsetDateTime`/`Instant` (UTC). + Фронтовый `parseUtc` уже совместим с обоими форматами, поэтому миграция безопасна при пер-сервисном раскате. + +### Контрактные тесты формата (зафиксировано) + +Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot — +JavaTimeModule + `disable(WRITE_DATES_AS_TIMESTAMPS)`): + +- `PlanResponseContractTest` (pcp-tgu-service) — naive `LocalDateTime` без зоны. +- `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон). +- `TguPlanningDtoContractTest` (pcp-ui-service) — оба формата: naive `LocalDateTime` + `Instant` с `Z`. + +Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, +mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании +пер-сервисный страж копируется по образцу `PlanResponseContractTest`. + +## 5. Правило для нового кода + +- Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю). +- Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`). +- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча). diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt index a7673b2..74b021a 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/AscNodeEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.KeplersDTO import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.math.PI @@ -26,7 +27,7 @@ class AscNodeEntity( @Column(nullable = false) val satelliteId : Long = 0, @Column(name = "t", nullable = false) - val time : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revolution : Long = 0, val longitude : Double = 0.0, diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt index 0e84c33..a2efa41 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCellViewsEntity.kt @@ -10,6 +10,7 @@ import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.math.PI @Entity @@ -26,13 +27,13 @@ class EarthCellViewsEntity( @Column(name = "rev_begin", nullable = false) val revolutionBegin : Long = 0, @Column(nullable = false) - val timeBegin : LocalDateTime = LocalDateTime.now(), + val timeBegin : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revSignBegin : Int = 0, @Column(name = "rev_end", nullable = false) val revolutionEnd: Long = 0, @Column(nullable = false) - val timeEnd : LocalDateTime = LocalDateTime.now(), + val timeEnd : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revSignEnd : Int = 0, @Column(name = "roll_min") diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt index 2a924d5..e7d9cf7 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/EarthCoverageEntity.kt @@ -9,6 +9,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "earth_coverage") @@ -22,9 +23,9 @@ class EarthCoverageEntity( @Column(nullable = false) val cellNumber : Long = 0, @Column(nullable = false) - val tStart : LocalDateTime = LocalDateTime.now(), + val tStart : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) - val tStop : LocalDateTime = LocalDateTime.now(), + val tStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), ) { constructor(fl : FleghtLineSector, sector : Int, sat : Long) : this( null, diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt index 7d72c1e..d9fbd15 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/FlightLineEntity.kt @@ -6,6 +6,7 @@ import jakarta.persistence.* import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.math.PI @@ -19,7 +20,7 @@ class FlightLineEntity( @Column(nullable = false) val satelliteId : Long = 0, @Column(name = "t", nullable = false) - val time : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revolution : Long = 0, @Column(nullable = false) diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt index afe03da..0da1e4c 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/PDCMEntity.kt @@ -7,6 +7,7 @@ import ballistics.utils.toDateTime import jakarta.persistence.* import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "pdcm") @@ -18,7 +19,7 @@ class PDCMEntity ( @Column(nullable = false) val satelliteId : Long = 0, @Column(name = "t", nullable = false) - val time : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) val revolution : Long = 0, val x: Double = 0.0, diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt index 044f7de..916e2b8 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/entity/SatelliteInitialConditionEntity.kt @@ -13,6 +13,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.MovementModel import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "satellite_initial_conditions") @@ -27,7 +28,7 @@ class SatelliteInitialConditionEntity( @Column(name = "movement_model", nullable = false) val movementModel: MovementModel = MovementModel.FOTO, @Column(name = "orb_time", nullable = false) - val orbTime: LocalDateTime = LocalDateTime.now(), + val orbTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "orb_revolution", nullable = false) val orbRevolution: Long = 0, @Column(nullable = false) @@ -47,7 +48,7 @@ class SatelliteInitialConditionEntity( @Column(nullable = false) val f81: Double = 147.8, @Column(name = "received_at", nullable = false) - val receivedAt: LocalDateTime = LocalDateTime.now(), + val receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "calculated_at") var calculatedAt: LocalDateTime? = null, @Column(name = "last_calculated", nullable = false) @@ -68,7 +69,7 @@ class SatelliteInitialConditionEntity( f81 = request.ic.f81 ) - fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now()) { + fun markLastCalculated(calculatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)) { this.calculatedAt = calculatedAt this.lastCalculated = true } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt index 74d027b..8e40d7e 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt @@ -63,6 +63,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.TLEDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.TargetPositionDTO import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.ZoneOffset import kotlin.collections.forEach import kotlin.math.PI import kotlin.math.abs @@ -761,8 +762,8 @@ class SatelliteService { fun getOrbPoints(id : Long, tn : LocalDateTime?, tk : LocalDateTime?) = pdcmRepository.findBySatelliteIdAndTimeBetween( id, - tn?: LocalDateTime.now(), - tk?: LocalDateTime.now().plusDays(1)) + tn?: LocalDateTime.now(ZoneOffset.UTC), + tk?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1)) .map { it.toDTO() } @@ -775,8 +776,8 @@ class SatelliteService { fun getFL(id : Long, tn : LocalDateTime?, tk : LocalDateTime?, step : Double?) = flightLineRepository.findBySatelliteIdAndTimeBetween(id, - tn?: LocalDateTime.now(), - tk?: LocalDateTime.now().plusDays(1) + tn?: LocalDateTime.now(ZoneOffset.UTC), + tk?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1) ) .map { it.toDTO() } @@ -897,8 +898,8 @@ class SatelliteService { return ascNodeRepository.findBySatelliteIdAndTimeBetween( id, - tn ?: LocalDateTime.now(), - tk ?: LocalDateTime.now().plusDays(1) + tn ?: LocalDateTime.now(ZoneOffset.UTC), + tk ?: LocalDateTime.now(ZoneOffset.UTC).plusDays(1) ) .map { val keps = calculateKeplerParams( diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt index 874fb10..a7b599b 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/ComplexPlanRunEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.Index import jakarta.persistence.OneToMany import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table( @@ -34,7 +35,7 @@ class ComplexPlanRunEntity( var status: ComplexPlanRunStatus = ComplexPlanRunStatus.CREATED, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "started_at") var startedAt: LocalDateTime? = null, @@ -46,10 +47,10 @@ class ComplexPlanRunEntity( var durationMs: Long? = null, @Column(name = "interval_start", nullable = false) - var intervalStart: LocalDateTime = LocalDateTime.now(), + var intervalStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "interval_end", nullable = false) - var intervalEnd: LocalDateTime = LocalDateTime.now(), + var intervalEnd: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "requested_by") var requestedBy: String? = null, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt index 1c519d5..b090920 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt @@ -15,6 +15,7 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.Table import jakarta.persistence.Index import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table( @@ -43,10 +44,10 @@ class SatelliteModeEntity( var type: SatelliteModeType = SatelliteModeType.SURVEY, @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime = LocalDateTime.now(), + var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime = LocalDateTime.now(), + var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "revolution", nullable = false) var revolution: Long = 0, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt index 4d44d0e..1b3e037 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeSnapshotEntity.kt @@ -13,6 +13,7 @@ import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table( @@ -34,13 +35,13 @@ class SatelliteModeSnapshotEntity( var satelliteId: Long = 0, @Column(name = "interval_start", nullable = false) - var intervalStart: LocalDateTime = LocalDateTime.now(), + var intervalStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "interval_end", nullable = false) - var intervalEnd: LocalDateTime = LocalDateTime.now(), + var intervalEnd: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "is_active", nullable = false) var isActive: Boolean = false, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt index 05fc0bf..984f120 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/model/mode/SurveyMode.kt @@ -3,11 +3,12 @@ package space.nstart.pcp.pcp_satellites_service.model.mode import space.nstart.pcp.pcp_satellites_service.entity.SatelliteModeSource import space.nstart.pcp.pcp_types_lib.dto.satellite.modes.SurveyModeInfoDTO import java.time.LocalDateTime +import java.time.ZoneOffset class SurveyMode( val revolution : Long = 1, - val time : LocalDateTime = LocalDateTime.now(), - val timeStop : LocalDateTime = LocalDateTime.now(), + val time : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + val timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll : Double = 0.0, val latitude : Double = 0.0, val longitude : Double = 0.0, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt index 259202f..4f496ed 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRouteEntity.kt @@ -10,6 +10,7 @@ import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Table(name = "dynamic_plan_route") @@ -31,10 +32,10 @@ class DynamicPlanRouteEntity( var satelliteId: Long = 0, @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime = LocalDateTime.now(), + var startTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime = LocalDateTime.now(), + var endTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "duration", nullable = false) var duration: Double = 0.0, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt index a307bdb..6c1f32b 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/db/entity/DynamicPlanRunEntity.kt @@ -10,6 +10,7 @@ import space.nstart.pcp.complan.types.DynamicPlanCalculationMode import space.nstart.pcp.complan.types.DynamicPlanRevolutionMode import space.nstart.pcp.complan.types.DynamicPlanRunStatus import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Table(name = "dynamic_plan_run") @@ -47,7 +48,7 @@ class DynamicPlanRunEntity( var filterCoveredRoutes: Boolean = false, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "started_at") var startedAt: LocalDateTime? = null, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt index 3afa0da..d528a51 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/DynamicPlanRunService.kt @@ -25,6 +25,7 @@ import space.nstart.pcp.complan.types.DynamicPlanRunStatus import space.nstart.pcp.complan.types.IntervalItemDTO import space.nstart.pcp.complan.types.SurveyContour import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import java.util.concurrent.ExecutorService import kotlin.math.roundToLong @@ -59,7 +60,7 @@ class DynamicPlanRunService( calculationMode = request.calculationMode, revolutionMode = request.revolutionMode, filterCoveredRoutes = request.filterCoveredRoutes, - createdAt = LocalDateTime.now() + createdAt = LocalDateTime.now(ZoneOffset.UTC) ) ) } ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create calculation run") @@ -135,7 +136,7 @@ class DynamicPlanRunService( return@execute 0 } - val finishedAt = LocalDateTime.now() + val finishedAt = LocalDateTime.now(ZoneOffset.UTC) runs.forEach { run -> run.status = DynamicPlanRunStatus.FAILED run.finishedAt = finishedAt @@ -171,7 +172,7 @@ class DynamicPlanRunService( transactionTemplate.execute { val run = runRepository.findById(runId).orElse(null) ?: return@execute null run.status = DynamicPlanRunStatus.RUNNING - run.startedAt = LocalDateTime.now() + run.startedAt = LocalDateTime.now(ZoneOffset.UTC) run.errorMessage = null runRepository.save(run) run.toRequest() @@ -182,7 +183,7 @@ class DynamicPlanRunService( val run = runRepository.findById(runId).orElseThrow() val result = output.result run.status = DynamicPlanRunStatus.COMPLETED - run.finishedAt = LocalDateTime.now() + run.finishedAt = LocalDateTime.now(ZoneOffset.UTC) run.durationMs = result.durationMs run.resultStatus = result.status.name run.routesCount = result.routesCount @@ -200,7 +201,7 @@ class DynamicPlanRunService( transactionTemplate.execute { val run = runRepository.findById(runId).orElseThrow() run.status = DynamicPlanRunStatus.FAILED - run.finishedAt = LocalDateTime.now() + run.finishedAt = LocalDateTime.now(ZoneOffset.UTC) run.errorMessage = error.message ?: error::class.java.simpleName runRepository.save(run) } diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt index 020de0c..da31900 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt @@ -8,6 +8,7 @@ import org.springframework.web.client.RestClient import org.springframework.web.util.UriComponentsBuilder import space.nstart.pcp.complan.types.RequestItemDTO import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID /** Получение заявок из pcp-request-service /v1 API. */ @@ -43,8 +44,10 @@ class RequestServiceClient { id = id, name = name, geometry = geometry, - intervalBegin = beginDateTime.toLocalDateTime(), - intervalEnd = endDateTime.toLocalDateTime(), + // Нормализуем к UTC: pcp-request-service шлёт `Z`, но голый toLocalDateTime() уехал бы + // при любом ненулевом офсете. Все naive LocalDateTime в системе — UTC (docs/standards/DATETIME_UTC.md). + intervalBegin = beginDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), + intervalEnd = endDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), resolution = resolution(), importance = importance, ) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt index 853aeec..72334fe 100755 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/spacecraft/SpaceCraft.kt @@ -6,6 +6,7 @@ import org.locationtech.jts.geom.LineString import space.nstart.pcp.pcp_types_lib.dto.ballistics.FlightLineDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime +import java.time.ZoneOffset /** * Класс космического аппарата** @@ -17,9 +18,9 @@ class SpaceCraft ( val flightLine : MutableList = mutableListOf() /**Время начала планирования аппарата*/ - var timeStart: LocalDateTime = LocalDateTime.now() + var timeStart: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) /**Время конца планирования аппарата*/ - var timeStop : LocalDateTime = LocalDateTime.now() + var timeStop : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) /** * Создание линий полета восходящего и нисходящего витка * @param geomFactory фабрика геометрии diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt index 03c19b7..ad1fafd 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt @@ -62,9 +62,29 @@ class RequestServiceClientTest { assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) } + @Test + fun `getRequest normalizes non-UTC offsets to UTC LocalDateTime`() { + val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002") + // Источник прислал то же мгновение, но со смещением +03:00. Голый toLocalDateTime() оставил бы + // локальную стенку (06:04:05); нормализация к UTC обязана дать 03:04:05. + server = serverWithRequestResponse( + mutableListOf(), + requestId, + beginDateTime = "2026-01-02T06:04:05+03:00", + endDateTime = "2026-01-03T07:05:06+03:00", + ) + + val request = requestServiceClient().getRequest(requestId)!! + + assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin) + assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd) + } + private fun serverWithRequestResponse( requestedUris: MutableList, requestId: UUID, + beginDateTime: String = "2026-01-02T03:04:05Z", + endDateTime: String = "2026-01-03T04:05:06Z", ): HttpServer = HttpServer.create(InetSocketAddress(0), 0).apply { createContext("/v1/requests") { exchange -> @@ -77,8 +97,8 @@ class RequestServiceClientTest { "surveyType": "OPTICS", "geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "importance": 7.5, - "beginDateTime": "2026-01-02T03:04:05Z", - "endDateTime": "2026-01-03T04:05:06Z", + "beginDateTime": "$beginDateTime", + "endDateTime": "$endDateTime", "kpp": [1], "highPriorityTransmit": false, "optics": { diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt index 4587cc3..f144289 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/BookedSlotsEventPublisher.kt @@ -15,6 +15,7 @@ import space.nstart.pcp.pcp_types_lib.message.KafkaMessage import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent import java.nio.charset.StandardCharsets import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Component @@ -59,7 +60,7 @@ class BookedSlotsEventPublisher( val event = BookedSlotsStatusChangedEvent( bookedSlotIds = normalizedIds, status = status, - occurredAt = LocalDateTime.now(), + occurredAt = LocalDateTime.now(ZoneOffset.UTC), sourceService = applicationName, missionId = missionId, modeId = modeId, diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 051c96e..cfd8185 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -57,6 +57,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.Duration import java.time.LocalDateTime +import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI @@ -189,14 +190,23 @@ class MissionPlaningService( .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } } + /** + * Нормализует `OffsetDateTime` к UTC и отбрасывает зону. Все naive `LocalDateTime` в сервисе — UTC + * (см. docs/standards/DATETIME_UTC.md), поэтому сравнивать их можно только с UTC-стенкой времени мода, + * а не с локальной стенкой произвольного офсета. Голый `toLocalDateTime()` был корректен лишь пока + * источник слал офсет 0 (`Z`). + */ + private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = + withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { - val modeStart = timeStart?.toLocalDateTime() ?: return false + val modeStart = timeStart?.toUtcLocalDateTime() ?: return false val modeStop = modeStop() ?: return false return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { - val modeStart = timeStart?.toLocalDateTime() ?: return null + val modeStart = timeStart?.toUtcLocalDateTime() ?: return null val durationSeconds = when (this) { is SurveyModeEntity -> duration is DropModeEntity -> duration @@ -273,7 +283,7 @@ class MissionPlaningService( overrideIntervalEnd: LocalDateTime? = null, comPlanSnapshotId: Long? = null ): MissionSurveyCalculationResponseDTO { - val startedAt = LocalDateTime.now() + val startedAt = LocalDateTime.now(ZoneOffset.UTC) logger.info( "Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}", missionId, @@ -361,7 +371,7 @@ class MissionPlaningService( ) if (survs.isEmpty()) { - val finishedAt = LocalDateTime.now() + val finishedAt = LocalDateTime.now(ZoneOffset.UTC) return MissionSurveyCalculationResponseDTO( missionId = missionId, completed = true, @@ -406,7 +416,7 @@ class MissionPlaningService( modeBookings.size ) - val finishedAt = LocalDateTime.now() + val finishedAt = LocalDateTime.now(ZoneOffset.UTC) return MissionSurveyCalculationResponseDTO( missionId = missionId, completed = true, @@ -677,7 +687,7 @@ class MissionPlaningService( } val start = survey.timeStart ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") - val surveyEnd = start.toLocalDateTime() + val surveyEnd = start.toUtcLocalDateTime() .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(req.zoneStart)) { throw CustomErrorException( @@ -930,7 +940,7 @@ class MissionPlaningService( iterator.remove() continue } - val surveyStart = surveyStartOffset.toLocalDateTime() + val surveyStart = surveyStartOffset.toUtcLocalDateTime() val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(zoneStart)) { diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt index b4e7943..2991f1a 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt @@ -153,6 +153,41 @@ class MissionPlaningServiceTest { assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys) } + @Test + fun `modesBySatelliteAndInterval normalizes non-UTC mode offsets to UTC before matching`() { + val mission = MissionEntity(missionId = UUID.randomUUID(), satelliteId = 77) + val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1) + val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0) + val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0) + + // То же мгновение, что 10:10 UTC (внутри окна), но выражено со смещением +03:00 → 13:10. + // Голый toLocalDateTime() дал бы 13:10 и выкинул мод из окна; нормализация к UTC оставляет его. + val offsetSurvey = SurveyModeEntity( + id = 1L, + plan = plan, + timeStart = OffsetDateTime.of(2026, 3, 16, 13, 10, 0, 0, ZoneOffset.ofHours(3)), + revolution = 11, + lat = 56.0, + long = 38.0, + duration = 300.0, + contourWkt = "POLYGON((0 0,1 0,1 1,0 0))", + roll = 1.0, + source = SurveyModeSource.SLOT + ) + + `when`( + modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( + 77, + intervalStop.atOffset(ZoneOffset.UTC) + ) + ).thenReturn(listOf(offsetSurvey)) + `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L))).thenReturn(emptyList()) + + val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop) + + assertEquals(listOf(1L), result.map { it.id }) + } + @Test fun `modesBySatelliteAndInterval rejects inverted interval`() { val exception = assertThrows(ResponseStatusException::class.java) { diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt index 31627eb..55672f2 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.concurrent.ExecutionException @Service @@ -69,7 +70,7 @@ class OutboxPublisherService( .get() event.status = OutboxEventStatus.PUBLISHED - event.publishedAt = LocalDateTime.now() + event.publishedAt = LocalDateTime.now(ZoneOffset.UTC) event.errorMessage = null outboxEventRepository.save(event) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index 13df5cb..c4f3d73 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -66,7 +66,7 @@ class RequestService( "beginDateTime должен быть раньше endDateTime" } - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry) val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry) val geometryArea = geometryService.area(normalizedGeometry) @@ -154,7 +154,7 @@ class RequestService( @Transactional fun deleteRequest(id: UUID): DeleteRequestResponseDto? { val request = requestRepository.findById(id).orElse(null) ?: return null - val deletedAt = request.deletedAt ?: LocalDateTime.now() + val deletedAt = request.deletedAt ?: LocalDateTime.now(ZoneOffset.UTC) request.status = RequestStatus.DELETED request.deletedAt = deletedAt request.updatedAt = deletedAt diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt index 0fb6ed7..fbe5676 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt @@ -15,6 +15,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.math.max import kotlin.math.min @@ -43,7 +44,7 @@ class RouteMatchingService( val routeId = route.routeId val routeRollRange = route.rollAngle - val matchedAt: LocalDateTime = LocalDateTime.now() + val matchedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry) val candidateRequestIds = requestRepository.findRequestIdsForRoute( diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt new file mode 100644 index 0000000..86bb075 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt @@ -0,0 +1,47 @@ +package org.nstart.dep265.requestservice.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +/** + * Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime` + * сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф + * (смену типа поля на naive `LocalDateTime` или Jackson-конфига). + * + * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + */ +class RequestResponseDtoContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes request times as ISO with explicit Z zone`() { + val dto = RequestResponseDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000001"), + name = "request", + status = RequestStatusDto.ACTIVE, + surveyType = SurveyTypeDto.OPTICS, + geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + importance = 1.0, + beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), + endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + highPriorityTransmit = false, + coverage = CoverageStateDto(currentPercent = 0.0), + createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC), + updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC) + ) + + val node = mapper.readTree(mapper.writeValueAsString(dto)) + + assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText()) + assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText()) + assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z") + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt index 1a9f7cc..f4c68fe 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionServiceTest.kt @@ -13,6 +13,7 @@ import org.nstart.dep265.requestservice.repository.EarthCellRepository import org.nstart.dep265.requestservice.repository.RequestCellRepository import org.nstart.dep265.requestservice.repository.RequestRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -112,14 +113,14 @@ class RequestGridProjectionServiceTest { geometryArea = 1.0, remainingArea = 1.0, importance = importance, - beginDateTime = LocalDateTime.now(), - endDateTime = LocalDateTime.now().plusDays(1), + beginDateTime = LocalDateTime.now(ZoneOffset.UTC), + endDateTime = LocalDateTime.now(ZoneOffset.UTC).plusDays(1), highPriorityTransmit = false, surveyType = RequestSurveyType.OPTICS, status = RequestStatus.ACTIVE, matchCount = 0, - createdAt = LocalDateTime.now(), - updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(ZoneOffset.UTC), + updatedAt = LocalDateTime.now(ZoneOffset.UTC), ) ) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt index 6af3400..5aff657 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt @@ -29,6 +29,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -47,7 +48,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalEnd = intervalBegin.plusHours(1) val request = request( beginDateTime = intervalBegin, @@ -96,7 +97,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val intervalEnd = intervalBegin.plusHours(1) val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))" val request = request( @@ -146,7 +147,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), @@ -182,7 +183,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), @@ -230,7 +231,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), @@ -268,7 +269,7 @@ class RouteMatchingServiceTest { geometryService = geometryService, ) - val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0) + val intervalBegin = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(5).withNano(0) val request = request( beginDateTime = intervalBegin, endDateTime = intervalBegin.plusHours(1), diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt index fe4d09e..6a0c768 100644 --- a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionService.kt @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper as JpaJsonObjectMapper import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -38,7 +39,7 @@ class RoutePassportIngestionService( rawPayload: String, sourceRecord: RoutePassportSourceRecordMetadata, ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val routePassportDto = parsedMessage.routePassportDto val existingRoutePassport = routePassportRepository.findById(routePassportDto.routeId) val routeCreated = existingRoutePassport.isEmpty diff --git a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt index 052c820..ae0a3e9 100644 --- a/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt +++ b/services/pcp-route-processing-service/src/main/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportOutboxService.kt @@ -9,6 +9,7 @@ import space.nstart.pcp.routepassportconsumer.entity.RoutePassportOutboxStatus import space.nstart.pcp.routepassportconsumer.repository.RoutePassportOutboxRepository import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -32,7 +33,7 @@ class RoutePassportOutboxService( return emptyList() } - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val lockedUntil = now.plus(Duration.ofMillis(outboxProperties.lockTimeoutMs)) val claimedRows = routePassportOutboxRepository.findDueForUpdateSkipLocked(outboxProperties.batchSize) @@ -70,7 +71,7 @@ class RoutePassportOutboxService( */ @Transactional fun markPublished(outboxId: UUID) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val outbox = findOutbox(outboxId) outbox.status = RoutePassportOutboxStatus.PUBLISHED @@ -99,7 +100,7 @@ class RoutePassportOutboxService( */ @Transactional fun markRetryOrFailed(outboxId: UUID, exception: Exception) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val outbox = findOutbox(outboxId) outbox.attempts += 1 diff --git a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt index 2841ebc..e9db81c 100644 --- a/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt +++ b/services/pcp-route-processing-service/src/test/kotlin/space/nstart/pcp/routepassportconsumer/service/RoutePassportIngestionServiceTest.kt @@ -27,6 +27,7 @@ import tools.jackson.databind.json.JsonMapper import tools.jackson.module.kotlin.KotlinModule import java.math.BigDecimal import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -219,9 +220,9 @@ class RoutePassportIngestionServiceTest { status = RoutePassportOutboxStatus.PENDING, attempts = 0, maxAttempts = 10, - nextAttemptAt = LocalDateTime.now(), - createdAt = LocalDateTime.now(), - updatedAt = LocalDateTime.now(), + nextAttemptAt = LocalDateTime.now(ZoneOffset.UTC), + createdAt = LocalDateTime.now(ZoneOffset.UTC), + updatedAt = LocalDateTime.now(ZoneOffset.UTC), ) private fun fixture(): IngestionFixture { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index 56401a8..eedaae2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -14,6 +14,7 @@ import { } from "./editorDraft"; import type { PlanMode } from "./editorApi"; import type { DraftHistory } from "./model/editorTypes"; +import { parseUtc } from "../../utils/utcTime"; const plan: TguPlanUi = { planId: "plan-1", @@ -22,8 +23,8 @@ const plan: TguPlanUi = { endTime: "2026-05-29T02:00:00Z", kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse("2026-05-29T01:00:00Z"), - endMs: Date.parse("2026-05-29T02:00:00Z") + startMs: parseUtc("2026-05-29T01:00:00Z"), + endMs: parseUtc("2026-05-29T02:00:00Z") }; describe("editorDraft", () => { @@ -84,8 +85,8 @@ describe("editorDraft", () => { id: "survey-11", kind: "shoot", label: "Съёмка · виток 4321", - startMs: Date.parse("2026-05-29T01:10:00Z"), - endMs: Date.parse("2026-05-29T01:10:00Z") + 180_000, + startMs: parseUtc("2026-05-29T01:10:00Z"), + endMs: parseUtc("2026-05-29T01:10:00Z") + 180_000, origin: "plan", targetLat: 55.7, targetLon: 37.6 @@ -94,8 +95,8 @@ describe("editorDraft", () => { id: "drop-22", kind: "downlink", label: "Сброс · 102", - startMs: Date.parse("2026-05-29T01:40:00Z"), - endMs: Date.parse("2026-05-29T01:40:00Z") + 120_000, + startMs: parseUtc("2026-05-29T01:40:00Z"), + endMs: parseUtc("2026-05-29T01:40:00Z") + 120_000, origin: "plan", stationId: "102" } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index ec5cec8..fbddec9 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { TguPlanUi } from "../../model/timelineTypes"; import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes"; import { getTgu2DMapIntervalState } from "./tgu2DMapInterval"; +import { parseUtc } from "../../utils/utcTime"; function plan(startTime: string, endTime: string): TguPlanUi { return { @@ -11,16 +12,16 @@ function plan(startTime: string, endTime: string): TguPlanUi { endTime, kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse(startTime), - endMs: Date.parse(endTime) + startMs: parseUtc(startTime), + endMs: parseUtc(endTime) }; } describe("tgu2DMapInterval", () => { it("marks common map interval longer than 7 days as too large", () => { const state = getTgu2DMapIntervalState({ - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-06-05T00:00:01") + fromMs: parseUtc("2026-05-29T00:00:00"), + toMs: parseUtc("2026-06-05T00:00:01") }); expect(state.tooLarge).toBe(true); @@ -30,8 +31,8 @@ describe("tgu2DMapInterval", () => { const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00"); const state = getTgu2DMapIntervalState( { - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-06-10T00:00:00") + fromMs: parseUtc("2026-05-29T00:00:00"), + toMs: parseUtc("2026-06-10T00:00:00") }, selectedPlan ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts index ede46ee..4e22532 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes"; import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout"; +import { parseUtc } from "../../utils/utcTime"; const range = { - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-05-30T00:00:00") + fromMs: parseUtc("2026-05-29T00:00:00"), + toMs: parseUtc("2026-05-30T00:00:00") }; function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi { @@ -15,14 +16,14 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56 endTime, kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse(startTime), - endMs: Date.parse(endTime) + startMs: parseUtc(startTime), + endMs: parseUtc(endTime) }; } describe("tguTimelineLayout", () => { it("maps time to x inside the selected interval", () => { - expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600); + expect(timeToX(parseUtc("2026-05-29T12:00:00"), range, 1200)).toBe(600); }); it("clips partially visible plans to the selected interval", () => { @@ -30,7 +31,7 @@ describe("tguTimelineLayout", () => { expect(clipped).toEqual({ startMs: range.fromMs, - endMs: Date.parse("2026-05-29T02:00:00") + endMs: parseUtc("2026-05-29T02:00:00") }); }); diff --git a/services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts b/services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts new file mode 100644 index 0000000..d617fac --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/utcTime.guard.test.ts @@ -0,0 +1,62 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Страж инварианта «всё время — UTC» (см. docs/standards/DATETIME_UTC.md). + * + * Сканирует исходники и падает на паттернах, которые трактуют время в поясе + * браузера: «голый» разбор строки (`new Date("...")`, `Date.parse`) и показ через + * `toLocale*` без `timeZone`. Парсинг и форматирование времени бэка должны идти + * только через `utils/utcTime` (`parseUtc`, `formatUtc*`). Этот файл и сам + * `utcTime.ts` — единственные исключения: в них паттерны присутствуют легитимно + * (как реализация утилиты и как строки-описания запретов). + */ + +const SRC_DIR = join(process.cwd(), "src"); +// Исключения: сам страж (содержит паттерны как строки-описания) и каноническая +// утилита utcTime.ts (легитимная реализация parseUtc/formatUtc*). +const EXCLUDED = ["utcTime.ts", "utcTime.guard.test.ts"]; + +interface Rule { + readonly name: string; + readonly pattern: RegExp; +} + +const RULES: Rule[] = [ + { name: "Date.parse(...) — парсит строку в поясе браузера; используйте parseUtc", pattern: /\bDate\.parse\s*\(/ }, + { name: "new Date(\"...\") со строкой — трактует naive-строку как локальное время; используйте parseUtc", pattern: /new\s+Date\(\s*['"`]/ }, + { name: "formatDateTimeLocal — локальный хелпер пикеров; используйте toUtcInputValue/parseUtc", pattern: /\bformatDateTimeLocal\b/ }, + // toLocale* без опций или только с локалью → пояс браузера. + { name: "toLocale*() без timeZone — показывает в поясе браузера; используйте formatUtc* или { timeZone: \"UTC\" }", pattern: /\.toLocale(String|DateString|TimeString)\(\s*(['"`][^'"`]*['"`]\s*)?\)/ }, + // toLocale* с inline-объектом опций, в котором нет timeZone. + { name: "toLocale*({...}) без timeZone в опциях — показывает в поясе браузера; добавьте timeZone: \"UTC\"", pattern: /\.toLocale(String|DateString|TimeString)\(\s*['"`][^'"`]*['"`]\s*,\s*\{(?![\s\S]*?timeZone)[\s\S]*?\}/ } +]; + +function collectSourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collectSourceFiles(full)); + } else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.includes(entry.name)) { + out.push(full); + } + } + return out; +} + +describe("UTC time invariant", () => { + const files = collectSourceFiles(SRC_DIR); + + it("scans the source tree", () => { + expect(files.length).toBeGreaterThan(0); + }); + + for (const rule of RULES) { + it(`forbids: ${rule.name}`, () => { + const offenders = files.filter((file) => rule.pattern.test(readFileSync(file, "utf8"))); + expect(offenders.map((file) => relative(SRC_DIR, file))).toEqual([]); + }); + } +}); diff --git a/services/pcp-tgu-ops-ui/tsconfig.app.json b/services/pcp-tgu-ops-ui/tsconfig.app.json index 7a7a882..2526d92 100644 --- a/services/pcp-tgu-ops-ui/tsconfig.app.json +++ b/services/pcp-tgu-ops-ui/tsconfig.app.json @@ -17,5 +17,7 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"] + "include": ["src"], + "comment": "Страж utcTime.guard.test.ts использует node:fs/process и проверяется только в vitest (node-окружение), а не при типизации приложения — иначе потребовался бы @types/node.", + "exclude": ["src/**/*.guard.test.ts"] } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 9c4bacc..63740c0 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -9,6 +9,11 @@ import java.util.UUID /** * DTO ответа REST API для плана. + * + * Времена ([startTime]/[endTime]) — `LocalDateTime` и сериализуются как ISO-строка без зоны + * (напр. `"2026-05-29T01:00:00"`). По соглашению системы это **UTC** (см. `docs/standards/DATETIME_UTC.md`): + * потребители обязаны парсить эти строки как UTC (фронт — через `utils/utcTime.parseUtc`), а не как + * локальное время. Кандидат на миграцию к явному `Z` (`OffsetDateTime`/`Instant`) на границе API. */ data class PlanResponse( @@ -18,10 +23,10 @@ data class PlanResponse( /** Идентификатор космического аппарата */ val spacecraftId: String, - /** Время начала плана */ + /** Время начала плана (UTC, naive ISO без зоны). */ val startTime: LocalDateTime, - /** Время окончания плана */ + /** Время окончания плана (UTC, naive ISO без зоны). */ val endTime: LocalDateTime, /** Идентификатор пункта закладки */ diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt index 66d1cf5..025aafe 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -39,10 +40,10 @@ class AcceptedPlanSlotsUpdateJobEntity( var lastError: String? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(planId = UUID.randomUUID()) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt index 26cea9b..5e9c5c6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -45,10 +46,10 @@ class PlanIssueAttemptEntity( var finishedAt: LocalDateTime? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt index e96a5e6..f46acab 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -48,10 +49,10 @@ class PlannedPlanEntity( var acceptedAt: LocalDateTime? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt index b594c13..464290e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -18,7 +19,7 @@ class ProcessedPlanDecisionEventEntity( var eventId: UUID, @Column(name = "received_at", nullable = false) - var receivedAt: LocalDateTime = LocalDateTime.now() + var receivedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(eventId = UUID.randomUUID()) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt index b90bc82..b30385d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -6,6 +6,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID @Entity @@ -35,7 +36,7 @@ class SpacecraftPlanningStateEntity( var urgentReissueFrom: LocalDateTime? = null, @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt index bd8cf2a..ce70873 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset /** * Persisted snapshot КА из НСИ, используемый как вход для refresh jobs. @@ -44,10 +45,10 @@ class SpacecraftSnapshotEntity( var active: Boolean = true, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "", noradId = 0) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt index 9aaa543..1662b34 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset /** * Полный raw snapshot окон видимости КА из баллистики. @@ -40,10 +41,10 @@ class SpacecraftVisibilitySnapshotEntity( var windowsCount: Int? = null, @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), + var createdAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt index 272b2c5..a4eb635 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset /** * Job на перечитывание окон видимости одного КА у баллистики. @@ -41,10 +42,10 @@ class VisibilityRefreshJobEntity( var rerunRequested: Boolean = false, @Column(name = "requested_at", nullable = false) - var requestedAt: LocalDateTime = LocalDateTime.now(), + var requestedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() + var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) ) { /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ constructor() : this(spacecraftId = "") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt index 2e89f28..a7e3419 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt @@ -12,6 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClientException import org.springframework.web.reactive.function.client.WebClientResponseException import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.temporal.ChronoUnit /** @@ -36,7 +37,7 @@ class ExternalPointsClient( * которые разрешены для работы TGU. */ fun fetchAllSpacecraftPoints(): List { - val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) return platformService.loadAllowedPlatforms() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt index 5745153..e510dc3 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt @@ -8,7 +8,7 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit @@ -19,7 +19,6 @@ import java.time.temporal.ChronoUnit class VisibilityPayloadParser( private val objectMapper: ObjectMapper ) { - private val zoneId: ZoneId = ZoneId.systemDefault() /** Группирует RVA records по станциям и сортирует окна для алгоритма планирования. */ fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints { @@ -44,7 +43,10 @@ class VisibilityPayloadParser( } /** - * Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. + * Разбирает UTC/ISO строку времени баллистики в naive `LocalDateTime` (по соглашению системы — UTC). + * + * Момент из `Instant.parse` берём в UTC (а не в поясе JVM): naive-поля `planned_plan.start_time/end_time` + * по контракту хранят UTC, поэтому `atZone(systemDefault)` на не-UTC машине сдвигал бы время на офсет. * * Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time` * хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется. @@ -53,7 +55,7 @@ class VisibilityPayloadParser( */ private fun toLocalDateTime(value: String): LocalDateTime = try { - Instant.parse(value).atZone(zoneId).toLocalDateTime() + LocalDateTime.ofInstant(Instant.parse(value), ZoneOffset.UTC) } catch (_: DateTimeParseException) { LocalDateTime.parse(value) }.truncatedTo(ChronoUnit.MICROS) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt index 63652f3..3c94c40 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -16,6 +17,6 @@ class AcceptedPlanSlotsUpdateJobService( /** Idempotent enqueue: один planId получает максимум одну job. */ @Transactional fun enqueue(planId: UUID) { - acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now()) + acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now(ZoneOffset.UTC)) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt index d5a8078..4e6b67e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt @@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository import java.net.InetAddress import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -35,7 +36,7 @@ class AcceptedPlanSlotsUpdateWorker( /** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */ fun claimJobs(limit: Int): List = transactionTemplate.execute { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit) jobs.forEach { job -> job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING @@ -69,7 +70,7 @@ class AcceptedPlanSlotsUpdateWorker( private fun markDone(planId: UUID, processInstanceKey: Long) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult job.status = AcceptedPlanSlotsUpdateJobStatus.DONE job.processInstanceKey = processInstanceKey @@ -83,7 +84,7 @@ class AcceptedPlanSlotsUpdateWorker( private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS job.processInstanceKey = processInstanceKey ?: job.processInstanceKey diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt index 18d5a6f..e8973ca 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt @@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime +import java.time.ZoneOffset /** * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId. @@ -115,7 +116,7 @@ class HandlePlanDecisionUseCase( return } - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) plan.status = terminalStatus plan.updatedAt = now plannedPlanRepository.save(plan) @@ -127,7 +128,7 @@ class HandlePlanDecisionUseCase( message: PlanDecisionMessage, attempt: PlanIssueAttemptEntity ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) attempt.status = when (message.decision) { PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED @@ -144,7 +145,7 @@ class HandlePlanDecisionUseCase( plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) recordDecisionOnAttempt(message, attempt) plan.status = PlannedPlanStatus.AWAITING_LAYIN @@ -170,7 +171,7 @@ class HandlePlanDecisionUseCase( plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity ) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) recordDecisionOnAttempt(message, attempt) plan.status = PlannedPlanStatus.REJECTED @@ -203,7 +204,7 @@ class HandlePlanDecisionUseCase( } private fun claimEvent(eventId: java.util.UUID): Boolean = - processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0 + processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0 private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt index 7583db4..1fe2eab 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt @@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset /** * На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА. @@ -25,7 +26,7 @@ class HandleStationParamsChangedUseCase( /** Идемпотентно по eventId; ставит refresh job по всем активным КА. */ @Transactional fun handle(message: StationParamsChangedMessage) { - val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0 + val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now(ZoneOffset.UTC)) != 0 if (!claimed) { return } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index c361ee6..777d456 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -19,6 +19,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -56,7 +57,7 @@ class IssueDuePlanWorker( /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val state = getOrCreateLockedState(spacecraftId) if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) { return null @@ -132,7 +133,7 @@ class IssueDuePlanWorker( } private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null val state = getOrCreateLockedState(plan.spacecraftId) if (state.activeAttemptId != null) { @@ -207,7 +208,7 @@ class IssueDuePlanWorker( private fun markStarted(response: ProcessStartResponse) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult @@ -231,7 +232,7 @@ class IssueDuePlanWorker( private fun markAmbiguous(preparedAttempt: PreparedAttempt) { transactionTemplate.executeWithoutResult { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult attempt.status = PlanIssueAttemptStatus.AMBIGUOUS diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index c3980d7..4a2da61 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -62,7 +63,7 @@ class PlanQueryService( .sortedBy { response -> response.startTime } private fun historySince(historyDays: Long): LocalDateTime = - LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + LocalDateTime.now(ZoneOffset.UTC).minusDays(historyDays.coerceAtLeast(0)) private fun PlannedPlanEntity.businessKey(): String = "$spacecraftId|$startTime|$endTime|$kppId" diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt index 7dde8c8..7d3b755 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt @@ -7,6 +7,7 @@ import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.security.MessageDigest import java.time.LocalDateTime +import java.time.ZoneOffset /** * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота @@ -25,7 +26,7 @@ class SpacecraftVisibilitySnapshotService( val payloadHash = sha256(payloadJson) val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull() - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) if (existing == null) { spacecraftVisibilitySnapshotRepository.save( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt index 10f7d9a..58dc7d1 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt @@ -5,7 +5,7 @@ import org.springframework.transaction.support.TransactionTemplate import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime -import java.time.ZoneId +import java.time.ZoneOffset /** * Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs. @@ -26,7 +26,7 @@ class SyncSpacecraftFromNsiUseCase( } private fun persistAllowedSpacecraft(allowedPlatforms: List) { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() } allowedPlatforms.forEach { platform -> @@ -40,8 +40,10 @@ class SyncSpacecraftFromNsiUseCase( name = platform.name, status = platform.status, mission = platform.mission, - validFrom = platform.validFrom?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), - validTo = platform.validTo?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), + // validFrom/validTo приходят как Instant; в naive-поля БД пишем UTC-стенку, а не пояс JVM + // (по соглашению системы naive LocalDateTime = UTC) — иначе на не-UTC машине время уезжало бы. + validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) }, + validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneOffset.UTC) }, now = now ) visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt index fdd8b56..6b77ee9 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt @@ -11,6 +11,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID /** @@ -66,7 +67,7 @@ class TestPlanDecisionService( planId = plan.planId, attemptId = attempt.attemptId, decision = decision, - decisionTime = LocalDateTime.now(), + decisionTime = LocalDateTime.now(ZoneOffset.UTC), reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON ) handlePlanDecisionUseCase.handle(message) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt index 79dd8fc..f0b3191 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository import java.time.LocalDateTime +import java.time.ZoneOffset /** * Coalesce-сервис для visibility_refresh_job по spacecraftId. @@ -15,6 +16,6 @@ class VisibilityRefreshJobService( /** Ставит или переоткрывает refresh job без дублей. */ @Transactional fun enqueue(spacecraftId: String, noradId: Long?, reason: String) { - visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now()) + visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now(ZoneOffset.UTC)) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt index 8c62d17..34fa8ea 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt @@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository import java.net.InetAddress import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.temporal.ChronoUnit /** @@ -38,7 +39,7 @@ class VisibilityRefreshWorker( private fun claimJobs(limit: Int): List = transactionTemplate.execute { - val now = LocalDateTime.now() + val now = LocalDateTime.now(ZoneOffset.UTC) val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit) jobs.forEach { job -> job.status = JobStatus.RUNNING @@ -61,10 +62,10 @@ class VisibilityRefreshWorker( } try { - val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + val timeStart = LocalDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.MINUTES) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) - spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now()) + spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now(ZoneOffset.UTC)) markDone(spacecraftId) } catch (ex: Exception) { log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex) @@ -79,7 +80,7 @@ class VisibilityRefreshWorker( job.rerunRequested = false job.lockedBy = null job.lockedUntil = null - job.updatedAt = LocalDateTime.now() + job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) visibilityRefreshJobRepository.save(job) } } @@ -90,7 +91,7 @@ class VisibilityRefreshWorker( job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING job.lockedBy = null job.lockedUntil = null - job.updatedAt = LocalDateTime.now() + job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) visibilityRefreshJobRepository.save(job) } } @@ -102,7 +103,7 @@ class VisibilityRefreshWorker( job.reason = reason job.lockedBy = null job.lockedUntil = null - job.updatedAt = LocalDateTime.now() + job.updatedAt = LocalDateTime.now(ZoneOffset.UTC) visibilityRefreshJobRepository.save(job) } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt new file mode 100644 index 0000000..1a336fc --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -0,0 +1,45 @@ +package space.nstart.pcp_tgu_service.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для [PlanResponse]: naive ISO без зоны (= UTC, + * см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля + * (на `OffsetDateTime`/`Instant`) или Jackson-конфига, которые поломали бы потребителей. + * + * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + */ +class PlanResponseContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes plan times as naive ISO without zone`() { + val response = PlanResponse( + planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + kppId = "KPP-1", + status = PlannedPlanStatus.PLANNED + ) + + val node = mapper.readTree(mapper.writeValueAsString(response)) + + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + // Нет суффикса зоны (Z/±офсет) — иначе потребитель, читающий naive как UTC, ошибётся. + assertTrue( + node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")), + "ожидался naive ISO без зоны" + ) + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt index 87be7b4..87fe63d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt @@ -1,14 +1,45 @@ package space.nstart.pcp_tgu_service.integration.api import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.time.LocalDateTime +import java.util.TimeZone class VisibilityPayloadParserTest { private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) + private val originalTimeZone: TimeZone = TimeZone.getDefault() + + @AfterEach + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun `parses UTC instant as UTC LocalDateTime regardless of JVM default zone`() { + // planned_plan.start_time/end_time по контракту хранят UTC. Раньше парсер конвертировал момент + // через ZoneId.systemDefault(), и на не-UTC JVM (напр. МСК, +3) время уезжало на офсет. + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) + val payload = """ + [{ + "noradId": 20, + "stationId": 102, + "onStart": {"time": "2026-06-02T14:14:08Z", "elevation": 5.0}, + "onStop": {"time": "2026-06-02T14:20:00Z", "elevation": 5.0}, + "onMaximum": {"time": "2026-06-02T14:17:00Z", "elevation": 45.0} + }] + """.trimIndent() + + val window = parser.parse("20", payload).points.single().observationWindows.single() + + // Значение совпадает с UTC-стенкой инстанта (14:14), а не со сдвигом на пояс JVM (17:14). + assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), window.startTime) + assertEquals(LocalDateTime.parse("2026-06-02T14:20:00"), window.endTime) + } + @Test fun `truncates window times to microseconds to match persisted timestamp precision`() { // Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt index 6c4d131..9197f0a 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt @@ -10,6 +10,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID class HandleStationParamsChangedUseCaseTest { @@ -40,7 +41,7 @@ class HandleStationParamsChangedUseCaseTest { StationParamsChangedMessage( eventId = UUID.randomUUID(), stationId = "ST-7", - occurredAt = LocalDateTime.now(), + occurredAt = LocalDateTime.now(ZoneOffset.UTC), reason = "OUT_OF_SERVICE" ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index 1604334..a14c597 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -27,6 +27,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -258,7 +259,9 @@ class IssueDuePlanWorkerOnDemandTest { private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") - private fun now(): LocalDateTime = LocalDateTime.now() + // Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now(ZoneOffset.UTC)), + // иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет. + private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) private fun planningProperties(): PlanningProperties = PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 87e1b88..93cd737 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -31,6 +31,7 @@ import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventReposit import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -86,7 +87,7 @@ class PlanIssueAndDecisionFlowTest { @Test fun `rejected decision marks plan REJECTED and arms urgent reissue`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) + val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION) val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) @@ -293,7 +294,7 @@ class PlanIssueAndDecisionFlowTest { endTime = end, kppId = "KPP-1", status = status, - issueNotBefore = LocalDateTime.now().minusMinutes(1) + issueNotBefore = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(1) ) private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity = @@ -316,7 +317,7 @@ class PlanIssueAndDecisionFlowTest { planId = planId, attemptId = attemptId, decision = decision, - decisionTime = LocalDateTime.now(), + decisionTime = LocalDateTime.now(ZoneOffset.UTC), reason = null ) @@ -325,11 +326,11 @@ class PlanIssueAndDecisionFlowTest { eventId = UUID.randomUUID(), spacecraftId = spacecraftId, planId = planId, - occurredAt = LocalDateTime.now(), + occurredAt = LocalDateTime.now(ZoneOffset.UTC), reason = null ) - private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt index c40d53e..0071b0e 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -176,13 +177,13 @@ class PlanProjectionServiceTest { noradId = 25544L, payloadJson = "[]", payloadHash = "hash", - fetchedAt = LocalDateTime.now() + fetchedAt = LocalDateTime.now(ZoneOffset.UTC) ) private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") - private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) @Suppress("UNCHECKED_CAST") private fun anyNonNull(): T { diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index 602de33..e97a4f5 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -12,6 +12,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID class PlanQueryServiceTest { @@ -74,5 +75,5 @@ class PlanQueryServiceTest { private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") - private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt new file mode 100644 index 0000000..caf456a --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt @@ -0,0 +1,90 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.Instant +import java.time.LocalDateTime +import java.util.TimeZone +import java.util.function.Consumer + +class SyncSpacecraftFromNsiUseCaseTest { + + private val originalTimeZone: TimeZone = TimeZone.getDefault() + + @AfterEach + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun `converts NSI validity instants to UTC LocalDateTime regardless of JVM default zone`() { + // validFrom/validTo пишутся в naive-поля БД, которые по контракту хранят UTC. Раньше конвертация + // шла через ZoneId.systemDefault(), и на не-UTC JVM (МСК, +3) значение уезжало на офсет. + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) + + val platformService = mock(PlatformService::class.java) + val repository = mock(SpacecraftSnapshotRepository::class.java) + val refreshJobService = mock(VisibilityRefreshJobService::class.java) + val transactionTemplate = mock(TransactionTemplate::class.java) + // executeWithoutResult просто прогоняет переданный блок без реальной транзакции. + doAnswer { invocation -> + invocation.getArgument>(0).accept(mock(TransactionStatus::class.java)) + null + }.`when`(transactionTemplate).executeWithoutResult(anyNonNull()) + + // loadAllowedPlatforms имеет дефолтный аргумент Instant.now(); матчер нужен, иначе stub не совпадёт. + `when`(platformService.loadAllowedPlatforms(anyNonNull())).thenReturn( + listOf( + PlatformCatalogItem( + nsiId = "nsi-1", + businessKey = "bk-1", + name = "SAT", + status = "OPERATIONAL", + noradId = 25544L, + mission = "obs", + validFrom = Instant.parse("2026-06-02T14:14:08Z"), + validTo = Instant.parse("2026-12-31T23:00:00Z") + ) + ) + ) + + SyncSpacecraftFromNsiUseCase(platformService, repository, refreshJobService, transactionTemplate) + .syncAllowedSpacecraft() + + val validFrom = ArgumentCaptor.forClass(LocalDateTime::class.java) + val validTo = ArgumentCaptor.forClass(LocalDateTime::class.java) + verify(repository).upsertFromNsi( + spacecraftId = anyNonNull(), + noradId = org.mockito.Mockito.anyLong(), + nsiId = anyNullable(), + businessKey = anyNullable(), + name = anyNullable(), + status = anyNullable(), + mission = anyNullable(), + validFrom = validFrom.capture(), + validTo = validTo.capture(), + now = anyNonNull() + ) + + assertEquals(LocalDateTime.parse("2026-06-02T14:14:08"), validFrom.value) + assertEquals(LocalDateTime.parse("2026-12-31T23:00:00"), validTo.value) + } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + private fun anyNullable(): T? = org.mockito.ArgumentMatchers.any() +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt index 79ae4a6..317d30d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt @@ -21,6 +21,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -91,8 +92,8 @@ class TestPlanDecisionServiceTest { PlannedPlanEntity( planId = UUID.randomUUID(), spacecraftId = "25544", - startTime = LocalDateTime.now().plusHours(1), - endTime = LocalDateTime.now().plusHours(2), + startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(1), + endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(2), kppId = "KPP-1", status = PlannedPlanStatus.WAITING_DECISION ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt index 576c1b1..25ea45b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt @@ -13,9 +13,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.security.MessageDigest -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId import java.util.Optional class VisibilityFlowTest { @@ -28,8 +26,9 @@ class VisibilityFlowTest { assertEquals("25544", points.spacecraftId) assertEquals(listOf("7", "9"), points.points.map { point -> point.pointId }) + // Парсер трактует UTC-инстант баллистики как UTC-стенку (naive LocalDateTime = UTC), без сдвига на пояс JVM. assertEquals( - Instant.parse("2026-05-26T10:00:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime(), + LocalDateTime.parse("2026-05-26T10:00:00"), points.points[1].observationWindows.single().startTime ) } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt index fba882f..8f7fc6a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/CatalogRestController.kt @@ -28,6 +28,7 @@ import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatellitePdcmService import space.nstart.pcp.slots_service.service.SlotService import java.time.LocalDateTime +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import java.time.format.ResolverStyle @@ -103,7 +104,7 @@ class CatalogRestController( private fun parseSendInitialConditionsTime(time: String?): LocalDateTime { val normalizedTime = time?.trim()?.takeIf { it.isNotEmpty() } - ?: return LocalDateTime.now() + ?: return LocalDateTime.now(ZoneOffset.UTC) return try { LocalDateTime.parse(normalizedTime, sendInitialConditionsTimeFormatter) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt index e7e3e96..0604cd6 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt @@ -6,6 +6,8 @@ import java.time.LocalDateTime /** * UI DTO for the bookings page. It intentionally lives in ui-service because the page combines * data from slots-service, request-service and satellite-catalog-service without changing their APIs. + * + * Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). */ data class BookedSlotViewDTO( val bookedSlotId: Long, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt index ee50115..dcc3a23 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.groupstate import java.time.LocalDateTime +/* + * Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + data class GroupStateAvailabilityDTO( val timeStart: LocalDateTime, val timeStop: LocalDateTime diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt index 41870ce..b779fa3 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt @@ -3,6 +3,10 @@ package space.nstart.pcp.slots_service.dto.pdcm import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import java.time.LocalDateTime +/* + * Времена ([LocalDateTime]) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + data class SatellitePdcmAvailabilityDTO( val timeStart: LocalDateTime, val timeStop: LocalDateTime diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index 42fb510..b426c55 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -4,6 +4,11 @@ import java.time.Instant import java.time.LocalDateTime import java.util.UUID +/* + * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md): `LocalDateTime` сериализуется как + * naive ISO без зоны (= UTC), `Instant` — с `Z`. Потребители (фронт через utils/utcTime) парсят как UTC. + */ + data class TguPlatformResponse( val id: String?, val businessKey: String?, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt index 1d55f43..49ad619 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tleanalysis import java.time.LocalDateTime +/* + * Времена ([LocalDateTime], в т.ч. epoch) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + data class TleAnalysisSatelliteDTO( val id: Long, val catalogId: Long? = null, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt index 238b312..be8feab 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tlecomparison import java.time.LocalDateTime +/* + * Времена ([LocalDateTime], epoch/time) — UTC, naive ISO без зоны (см. docs/standards/DATETIME_UTC.md). + */ + class TleTextRequestDTO( var tle: String = "" ) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt index cd97520..c246d53 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CZMLService.kt @@ -426,7 +426,7 @@ private val docReqCovName ="req_cov_" timeStop: LocalDateTime? = null ): Iterable { - val tstart = timeStart ?: LocalDateTime.now() + val tstart = timeStart ?: LocalDateTime.now(ZoneOffset.UTC) val tstop = timeStop ?: tstart.plusDays(1) var vuz = ballisticsService.ascNodes(id, tstart, tstop).collectList().block() vuz = vuz?.filter { it.time >= tstart && it.time <= tstop }?.sortedBy { it.time } diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt index fb77839..2d56bed 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/CoverageSchemeService.kt @@ -20,6 +20,7 @@ import space.nstart.pcp.slots_service.czml.CZMLPolygon import space.nstart.pcp.slots_service.czml.CZMLPosition import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime +import java.time.ZoneOffset @Service class CoverageSchemeService(webClientBuilderProvider: ObjectProvider) { @@ -185,8 +186,8 @@ class CoverageSchemeModeRowDTO( val entityId: String = "", val sequence: Int = 0, val satelliteId: Long = 0, - val tn: LocalDateTime = LocalDateTime.now(), - val tk: LocalDateTime = LocalDateTime.now(), + val tn: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + val tk: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll: Double? = null, val revolutionSign: RevolutionSign? = null, val contour: String = "", diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt index acf13a5..36553a1 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatellitePdcmService.kt @@ -7,6 +7,7 @@ import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO import java.time.LocalDateTime +import java.time.ZoneOffset @Service class SatellitePdcmService( @@ -14,7 +15,7 @@ class SatellitePdcmService( private val ballisticsService: BallisticsService ) { - fun satelliteSummaries(): List = satelliteSummaries(LocalDateTime.now()) + fun satelliteSummaries(): List = satelliteSummaries(LocalDateTime.now(ZoneOffset.UTC)) fun satelliteSummaries(referenceTime: LocalDateTime): List { val satellites = satelliteCatalogService.allSatellites() @@ -39,7 +40,7 @@ class SatellitePdcmService( } fun satelliteDetails(satelliteId: Long): SatellitePdcmDetailsDTO = - satelliteDetails(satelliteId, LocalDateTime.now()) + satelliteDetails(satelliteId, LocalDateTime.now(ZoneOffset.UTC)) fun satelliteDetails(satelliteId: Long, referenceTime: LocalDateTime): SatellitePdcmDetailsDTO { val satellites = satelliteCatalogService.allSatellites() diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt new file mode 100644 index 0000000..8e0a67d --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -0,0 +1,62 @@ +package space.nstart.pcp.slots_service.dto.tgu + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: + * naive `LocalDateTime` → ISO без зоны (= UTC), `Instant` → ISO с `Z` + * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта. + * + * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + */ +class TguPlanningDtoContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes plan times as naive ISO without zone`() { + val plan = TguPlanResponse( + planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + kppId = "KPP-1", + status = "PLANNED" + ) + + val node = mapper.readTree(mapper.writeValueAsString(plan)) + + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertTrue( + node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")), + "ожидался naive ISO без зоны" + ) + } + + @Test + fun `serializes platform validity instants with explicit Z zone`() { + val platform = TguPlatformResponse( + id = "25544", + businessKey = "bk-1", + name = "SAT", + status = "OPERATIONAL", + noradId = 25544L, + mission = "obs", + validFrom = Instant.parse("2026-05-29T01:00:00Z"), + validTo = Instant.parse("2026-12-31T23:00:00Z") + ) + + val node = mapper.readTree(mapper.writeValueAsString(platform)) + + assertEquals("2026-05-29T01:00:00Z", node.get("validFrom").asText()) + assertTrue(node.get("validTo").asText().endsWith("Z"), "ожидалась явная зона Z") + } +} diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt index 0eab57e..9709366 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SatelliteIcEntity.kt @@ -5,6 +5,7 @@ import jakarta.persistence.Entity import jakarta.persistence.Id import jakarta.persistence.Table import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @Table(name = "satellite_ic") @@ -13,7 +14,7 @@ class SatelliteIcEntity( @Column(name = "satellite_id", nullable = false) val satelliteId: Long = 0, @Column(name = "orb_time", nullable = false) - val orbTime: LocalDateTime = LocalDateTime.now(), + val orbTime: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(name = "orb_revolution", nullable = false) val orbRevolution: Long = 0, @Column(nullable = false) diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt index 757b134..ecbfd8e 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/SlotEntity.kt @@ -9,6 +9,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser import space.nstart.pcp.slots_service.model.Mar import java.time.LocalDateTime +import java.time.ZoneOffset @Entity @@ -26,9 +27,9 @@ class SlotEntity( @Column(nullable = false) val coveringType : Int = 0, @Column(nullable = false) - var tn : LocalDateTime = LocalDateTime.now(), + var tn : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), @Column(nullable = false) - var tk : LocalDateTime = LocalDateTime.now(), + var tk : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll : Double = 0.0, @Column(name = "contour_wkt", columnDefinition = "TEXT", nullable = false) val contour : String = "", diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt index 7ea770f..0a3933f 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/Mar.kt @@ -3,11 +3,12 @@ package space.nstart.pcp.slots_service.model import org.locationtech.jts.geom.Geometry import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import java.time.LocalDateTime +import java.time.ZoneOffset class Mar ( val sat : Long = 0, - val tn : LocalDateTime = LocalDateTime.now(), - val tk : LocalDateTime = LocalDateTime.now(), + val tn : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), + val tk : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC), val roll : Double = 0.0, val contour : String = "", var poly : Geometry? = null, diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt index 7b02854..f790bf7 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt @@ -33,8 +33,11 @@ class SlotRepositoryImpl( cycle = rs.getLong("cycle"), satelliteId = rs.getLong("satellite_id"), coveringType = rs.getInt("covering_type"), - tn = rs.getTimestamp("tn").toLocalDateTime(), - tk = rs.getTimestamp("tk").toLocalDateTime(), + // getObject(LocalDateTime) читает `timestamp without time zone` без участия + // зоны JVM. getTimestamp().toLocalDateTime() зоно-нейтрален лишь для фикс-офсета; + // на зонах с DST граничные значения сдвигались бы. Времена в системе — UTC. + tn = rs.getObject("tn", java.time.LocalDateTime::class.java), + tk = rs.getObject("tk", java.time.LocalDateTime::class.java), roll = rs.getDouble("roll"), contour = rs.getString("contour_wkt"), revolution = rs.getLong("revolution"), diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt index 67997d5..f709391 100644 --- a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/service/CelesTrakService.kt @@ -24,6 +24,7 @@ import space.nstart.pcp.tle_monitoring_service.message.KafkaProducer import space.nstart.pcp.tle_monitoring_service.repository.TLERepository import java.time.Duration import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI import kotlin.math.abs @@ -303,7 +304,7 @@ class CelesTrakService( .sortedBy { tle -> tle.timeTle } var first = true var vit = 0 - var tVuz = LocalDateTime.now() + var tVuz = LocalDateTime.now(ZoneOffset.UTC) var lVuz = 0.0 for(t in tles){ var s = t.tle.split("\r\n") From c2e2b2922f5fb35e5af5a9933817e77b521aa797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 22:02:13 +0300 Subject: [PATCH 21/79] =?UTF-8?q?pcp:=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20=D0=A2=D0=93=D0=A3=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20Z=20(OffsetDateTime)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanResponse (pcp-tgu-service) и зависящий от него TguPlanResponse (pcp-ui-service) переведены с naive LocalDateTime на OffsetDateTime в UTC → на проводе явная зона Z. Домен/БД (PlannedPlanEntity/CalculatedPlan) остаются naive LocalDateTime (= UTC); конвертация только на границе API (atOffset(ZoneOffset.UTC)). Потребитель один (ui-service.TguPlanningService), обновлён синхронно; фронт parseUtc совместим с Z. - контрактные тесты обновлены на Z (PlanResponseContractTest, TguPlanningDtoContractTest); - тестовые фикстуры/ассерты приведены к OffsetDateTime/Z (PlanQueryServiceTest, CatalogControllerTest, TguPlanningServiceTest); - docs/standards/DATETIME_UTC.md: отражена миграция и обновлена таблица сервисов. --- docs/standards/DATETIME_UTC.md | 21 ++++++++++---- .../pcp_tgu_service/dto/PlanResponse.kt | 29 ++++++++++--------- .../dto/PlanResponseContractTest.kt | 25 +++++++--------- .../service/PlanQueryServiceTest.kt | 5 ++-- .../slots_service/dto/tgu/TguPlanningDTO.kt | 10 ++++--- .../controller/CatalogControllerTest.kt | 6 ++-- .../dto/tgu/TguPlanningDtoContractTest.kt | 22 +++++++------- .../service/TguPlanningServiceTest.kt | 2 +- 8 files changed, 68 insertions(+), 52 deletions(-) diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index c8f6c15..0e18509 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -43,8 +43,8 @@ | Сервис | Преобладает на границе API | Naive? | Вердикт | |---|---|---|---| | pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе | -| pcp-tgu-service | `LocalDateTime` (planId/plan times) | да | задокументирован «=UTC» (`PlanResponse`); баги конвертаций исправлены | -| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries) | да | задокументировать «=UTC»; кандидат на `Z` | +| pcp-tgu-service | REST `PlanResponse` — `OffsetDateTime` (`Z`); Kafka/прочие DTO — `LocalDateTime` | частично | `PlanResponse` **мигрирован на `Z`**; баги конвертаций исправлены; Kafka-DTO остаются naive (=UTC) | +| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries); план ТГУ — `OffsetDateTime` (`Z`) | частично | `TguPlanResponse` **мигрирован на `Z`** вслед за tgu; прочие naive DTO — задокументировать | | pcp-complex-mission-service | `LocalDateTime` (+ часть `Instant`) | смешано | проверить смешение; задокументировать | | pcp-dynamic-plan-service | `LocalDateTime` (+ offset на входе клиента) | да | задокументировать «=UTC» | | pcp-ballistics-service | `LocalDateTime` | да | задокументировать «=UTC» | @@ -77,13 +77,24 @@ Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot — JavaTimeModule + `disable(WRITE_DATES_AS_TIMESTAMPS)`): -- `PlanResponseContractTest` (pcp-tgu-service) — naive `LocalDateTime` без зоны. +- `PlanResponseContractTest` (pcp-tgu-service) — `OffsetDateTime` с `Z` (после миграции, см. ниже). - `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон). -- `TguPlanningDtoContractTest` (pcp-ui-service) — оба формата: naive `LocalDateTime` + `Instant` с `Z`. +- `TguPlanningDtoContractTest` (pcp-ui-service) — план ТГУ `OffsetDateTime` с `Z` + `Instant` с `Z`. Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании -пер-сервисный страж копируется по образцу `PlanResponseContractTest`. +пер-сервисный страж копируется по образцу `RequestResponseDtoContractTest`. + +### Миграция к `Z` — выполнено + +- **План ТГУ (tgu → ui-service → фронт).** `PlanResponse` (pcp-tgu-service) и зависящий от него + `TguPlanResponse` (pcp-ui-service) переведены с naive `LocalDateTime` на `OffsetDateTime` в UTC → + на проводе `Z`. Домен/БД (`PlannedPlanEntity`/`CalculatedPlan`) остались naive `LocalDateTime` (=UTC); + конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён + синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. + +Следующие кандидаты на миграцию (по согласованию, по сервису): остальные naive-DTO ui-service, +ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. ## 5. Правило для нового кода diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 63740c0..6d746f0 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -4,16 +4,17 @@ import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.nio.charset.StandardCharsets -import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID /** * DTO ответа REST API для плана. * - * Времена ([startTime]/[endTime]) — `LocalDateTime` и сериализуются как ISO-строка без зоны - * (напр. `"2026-05-29T01:00:00"`). По соглашению системы это **UTC** (см. `docs/standards/DATETIME_UTC.md`): - * потребители обязаны парсить эти строки как UTC (фронт — через `utils/utcTime.parseUtc`), а не как - * локальное время. Кандидат на миграцию к явному `Z` (`OffsetDateTime`/`Instant`) на границе API. + * Времена ([startTime]/[endTime]) — `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной + * зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее + * хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC); + * конвертация в `Z` происходит только на границе API. */ data class PlanResponse( @@ -23,11 +24,11 @@ data class PlanResponse( /** Идентификатор космического аппарата */ val spacecraftId: String, - /** Время начала плана (UTC, naive ISO без зоны). */ - val startTime: LocalDateTime, + /** Время начала плана (UTC, ISO с `Z`). */ + val startTime: OffsetDateTime, - /** Время окончания плана (UTC, naive ISO без зоны). */ - val endTime: LocalDateTime, + /** Время окончания плана (UTC, ISO с `Z`). */ + val endTime: OffsetDateTime, /** Идентификатор пункта закладки */ val kppId: String, @@ -45,8 +46,9 @@ data class PlanResponse( PlanResponse( planId = plan.planId, spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, + // naive LocalDateTime в БД = UTC → отдаём с явной зоной Z. + startTime = plan.startTime.atOffset(ZoneOffset.UTC), + endTime = plan.endTime.atOffset(ZoneOffset.UTC), kppId = plan.kppId, status = plan.status ) @@ -61,8 +63,9 @@ data class PlanResponse( PlanResponse( planId = deterministicPlanId(plan), spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, + // naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z. + startTime = plan.startTime.atOffset(ZoneOffset.UTC), + endTime = plan.endTime.atOffset(ZoneOffset.UTC), kppId = plan.kppId, status = PlannedPlanStatus.PLANNED ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt index 1a336fc..cf2a44b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -6,13 +6,14 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID /** - * Контракт формата времени на проводе для [PlanResponse]: naive ISO без зоны (= UTC, - * см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля - * (на `OffsetDateTime`/`Instant`) или Jackson-конфига, которые поломали бы потребителей. + * Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC → ISO с явной зоной `Z` + * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля + * (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей. * * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). */ @@ -22,24 +23,20 @@ class PlanResponseContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes plan times as naive ISO without zone`() { + fun `serializes plan times as ISO with explicit Z zone`() { val response = PlanResponse( planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), spacecraftId = "25544", - startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), - endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), + endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), kppId = "KPP-1", status = PlannedPlanStatus.PLANNED ) val node = mapper.readTree(mapper.writeValueAsString(response)) - assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) - assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) - // Нет суффикса зоны (Z/±офсет) — иначе потребитель, читающий naive как UTC, ошибётся. - assertTrue( - node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")), - "ожидался naive ISO без зоны" - ) + assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) + assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index e97a4f5..355b86b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -39,9 +39,10 @@ class PlanQueryServiceTest { val result = service.getAllPlans(historyDays = 7) assertEquals(2, result.size) - val persistedResponse = result.first { it.startTime == persisted.startTime } + // PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC). + val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) } assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) - val projectedResponse = result.first { it.startTime == freshProjected.startTime } + val projectedResponse = result.first { it.startTime == freshProjected.startTime.atOffset(ZoneOffset.UTC) } assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status) // Отсортировано по startTime. assertTrue(result[0].startTime <= result[1].startTime) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index b426c55..a0be157 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -2,11 +2,13 @@ package space.nstart.pcp.slots_service.dto.tgu import java.time.Instant import java.time.LocalDateTime +import java.time.OffsetDateTime import java.util.UUID /* - * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md): `LocalDateTime` сериализуется как - * naive ISO без зоны (= UTC), `Instant` — с `Z`. Потребители (фронт через utils/utcTime) парсят как UTC. + * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md). Времена плана ([TguPlanResponse]) — + * `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` — тоже с `Z`. `LocalDateTime` + * (напр. [TguPlanDecisionMessage.decisionTime]) — naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime. */ data class TguPlatformResponse( @@ -23,8 +25,8 @@ data class TguPlatformResponse( data class TguPlanResponse( val planId: UUID, val spacecraftId: String, - val startTime: LocalDateTime, - val endTime: LocalDateTime, + val startTime: OffsetDateTime, + val endTime: OffsetDateTime, val kppId: String, val status: String ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index ac6e271..13d99ca 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -45,6 +45,8 @@ import space.nstart.pcp.slots_service.service.SlotService import space.nstart.pcp.slots_service.service.StationService import space.nstart.pcp.slots_service.service.TguPlanningService import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -210,8 +212,8 @@ class CatalogControllerTest { TguPlanResponse( planId = planId, spacecraftId = "56756", - startTime = LocalDateTime.of(2026, 5, 29, 10, 0), - endTime = LocalDateTime.of(2026, 5, 29, 10, 15), + startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC), + endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC), kppId = "KPP-1", status = "WAITING_DECISION" ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt index 8e0a67d..63225ec 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -6,13 +6,15 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.time.Instant -import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset import java.util.UUID /** * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: - * naive `LocalDateTime` → ISO без зоны (= UTC), `Instant` → ISO с `Z` - * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта. + * времена плана ([TguPlanResponse]) — `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service), + * `Instant` ([TguPlatformResponse.validFrom]) — тоже с `Z` (см. docs/standards/DATETIME_UTC.md). + * Тест ловит молчаливый дрейф контракта. * * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). */ @@ -22,23 +24,21 @@ class TguPlanningDtoContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes plan times as naive ISO without zone`() { + fun `serializes plan times as ISO with explicit Z zone`() { val plan = TguPlanResponse( planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), spacecraftId = "25544", - startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), - endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), + endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), kppId = "KPP-1", status = "PLANNED" ) val node = mapper.readTree(mapper.writeValueAsString(plan)) - assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) - assertTrue( - node.get("startTime").asText().matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}""")), - "ожидался naive ISO без зоны" - ) + assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) + assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") } @Test diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt index 9cc4328..0fc5d44 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt @@ -116,7 +116,7 @@ class TguPlanningServiceTest { exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond( exchange, 200, - """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00","endTime":"2026-05-29T10:15:00","kppId":"KPP-1","status":"WAITING_DECISION"}]""" + """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00Z","endTime":"2026-05-29T10:15:00Z","kppId":"KPP-1","status":"WAITING_DECISION"}]""" ) exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond( exchange, From 240e12f442d31d2cbfacf15ae4ae4cc64949be95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 22:06:06 +0300 Subject: [PATCH 22/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=A0=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=E2=80=94=20=D1=88=D0=B0=D0=B3=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B0=20=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D1=82=20=D0=B4=D0=BB=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouteTimeStepper и доработки EditorPanels/TguEditorTab по вводу времени; - formatDuration: секунды/минуты для коротких интервалов; - стили tgu-editor.css. --- .../src/features/tgu-editor/EditorPanels.tsx | 149 +++++++++++++++--- .../src/features/tgu-editor/TguEditorTab.tsx | 92 ++++++++--- .../features/tgu-editor/editorPresentation.ts | 10 ++ .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 40 ++++- 4 files changed, 248 insertions(+), 43 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 6219284..ead6a14 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -2,7 +2,8 @@ import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kind import { statusStyle } from "../tgu-planning/tguStatus"; import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; import type { RvaZone } from "../../api/ballisticsApi"; -import type { SurveyRoute, SurveyRouteAnchor } from "../../api/missionPlanningApi"; +import type { ChangeEvent } from "react"; +import type { SurveyRoute } from "../../api/missionPlanningApi"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { RequestMapItem } from "../../model/requestTypes"; import type { @@ -45,11 +46,27 @@ type ShootPanelProps = { selectedPass?: EditorVisibilityWindow; modeId?: string; modes: EditorMode[]; - routeAnchor: SurveyRouteAnchor; + /** Окно строящегося маршрута (UTC, мс). undefined пока не выбран проход. */ + routeWindow?: { startMs: number; endMs: number }; + /** Текущий крен (gamma) маршрута, градусы. */ + routeRoll?: number; + /** Окно видимости выбранного прохода — для подписи длительности. */ + passStartMs?: number; + passEndMs?: number; + /** Допустимый диапазон времени окна маршрута (±30 мин от окна видимости), мс. */ + routeMinMs?: number; + routeMaxMs?: number; routeLoading?: boolean; routeError?: string; route?: SurveyRoute; - onAnchorChange: (anchor: SurveyRouteAnchor) => void; + onRouteStartChange: (startMs: number) => void; + onRouteEndChange: (endMs: number) => void; + /** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */ + onRouteShift: (deltaMs: number) => void; + /** Изменение крена влево/вправо (градусы, дельта от кнопок). */ + onRouteRollChange: (deltaDeg: number) => void; + /** Установка крена напрямую (ручной ввод), градусы. */ + onRouteRollSet: (deg: number) => void; onSelectPass: (pass?: EditorVisibilityWindow) => void; onModeChange: (modeId: string) => void; onAdd: () => void; @@ -57,6 +74,9 @@ type ShootPanelProps = { onClearTarget: () => void; }; +/** Минимальная длительность маршрута, мс (нижняя граница окна). */ +const MIN_ROUTE_DURATION_MS = 5_000; + type DownlinkPanelProps = { stationOptions: DownlinkStationOption[]; selected?: DownlinkSelection; @@ -204,11 +224,20 @@ export function ShootPanel({ selectedPass, modeId, modes, - routeAnchor, + routeWindow, + routeRoll, + passStartMs, + passEndMs, + routeMinMs, + routeMaxMs, routeLoading, routeError, route, - onAnchorChange, + onRouteStartChange, + onRouteEndChange, + onRouteShift, + onRouteRollChange, + onRouteRollSet, onSelectPass, onModeChange, onAdd, @@ -287,25 +316,57 @@ export function ShootPanel({ )} - {target && selectedPass && ( + {target && selectedPass && routeWindow && (
-
4 · маршрут съёмки
-
- - +
4 · окно маршрута
+ + +
+ длительность: {formatDuration(routeWindow.endMs - routeWindow.startMs)} + {passStartMs != null && passEndMs != null && ` · окно видимости: ${formatDuration(passEndMs - passStartMs)}`}
+ +
+ сдвиг — ◀ назад · вперёд ▶ +
+ + + + +
+
+ +
+ крен — ◀ влево · вправо ▶ +
+ + + { + const deg = Number.parseFloat(event.target.value); + if (Number.isFinite(deg)) onRouteRollSet(deg); + }} + /> + + +
+
+ {routeLoading ? (
Построение маршрута…
) : routeError ? ( @@ -628,3 +689,47 @@ function TimeStepper({ ); } + +/** + * Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной + * ввод времени `чч:мм:сс` (UTC, без даты — дата берётся из текущего значения). + * Значение зажимается в [min, max]. + */ +function RouteTimeStepper({ + label, + value, + min, + max, + onChange +}: { + label: string; + value: number; + min?: number; + max?: number; + onChange: (value: number) => void; +}) { + const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next)); + const change = (deltaMs: number) => onChange(clamp(value + deltaMs)); + const onInput = (event: ChangeEvent) => { + const raw = event.target.value; + if (!raw) return; + // Ввод — только время суток; день оставляем прежним (UTC). + const datePart = new Date(value).toISOString().slice(0, 10); + const ms = parseUtc(`${datePart}T${raw}`); + if (Number.isFinite(ms)) onChange(clamp(ms)); + }; + + return ( + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 0919a4a..2ecc09a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -5,7 +5,7 @@ import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; import type { RequestMapItem } from "../../model/requestTypes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; -import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute, type SurveyRouteAnchor } from "../../api/missionPlanningApi"; +import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi"; import { parseUtc } from "../../utils/utcTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -58,6 +58,12 @@ const SHOOT_WINDOW_MS = 6 * 60 * 1000; * это значение будет определять бэкенд по профилю наблюдения КА. */ const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; +/** Дефолтная длительность строящегося маршрута съёмки (мс). */ +const DEFAULT_ROUTE_DURATION_MS = 30_000; +/** Предел крена (gamma) маршрута при ручной правке, градусы. */ +const MAX_ROUTE_ROLL_DEG = 89; +/** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */ +const ROUTE_TIME_MARGIN_MS = 30 * 60_000; const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { tracks: true, swath: true, @@ -144,7 +150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [shootPasses, setShootPasses] = useState([]); const [shootPassesLoading, setShootPassesLoading] = useState(false); const [shootPassesError, setShootPassesError] = useState(); - const [routeAnchor, setRouteAnchor] = useState("CENTERED"); + // Окно строящегося маршрута съёмки: задаётся вручную в правой панели (начало/конец, UTC). + const [routeWindow, setRouteWindow] = useState<{ startMs: number; endMs: number }>(); + // Крен (gamma) строящегося маршрута, градусы. По умолчанию — из прохода, дальше правится вручную. + const [routeRoll, setRouteRoll] = useState(); const [surveyRoute, setSurveyRoute] = useState(); const [routeLoading, setRouteLoading] = useState(false); const [routeError, setRouteError] = useState(); @@ -292,29 +301,44 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }; }, [rightMode, target, noradId, window.fromMs, window.toMs]); + // При выборе нового прохода задаём дефолтное окно маршрута: 30 c вокруг момента + // видимости, обрезанное окном видимости прохода. Дальше пользователь правит вручную. + useEffect(() => { + if (rightMode !== "shoot" || !selectedShootPass) { + setRouteWindow(undefined); + setRouteRoll(undefined); + return; + } + const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs; + const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2; + const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; + setRouteWindow({ startMs: mid - half, endMs: mid + half }); + setRouteRoll(selectedShootPass.rollDeg ?? 0); + }, [rightMode, selectedShootPass]); + useEffect(() => { const numericNorad = noradId ? Number(noradId) : NaN; - if (rightMode !== "shoot" || !target || !selectedShootPass || !Number.isFinite(numericNorad)) { + if (rightMode !== "shoot" || !target || !selectedShootPass || !routeWindow || routeRoll == null || !Number.isFinite(numericNorad)) { setSurveyRoute(undefined); setRouteError(undefined); setRouteLoading(false); return; } - const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; - const durationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + const durationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); let cancelled = false; setRouteLoading(true); setRouteError(undefined); void buildSurveyRoute({ satelliteId: numericNorad, - time: new Date(momentMs).toISOString().slice(0, 19), + // Окно задано явно (начало/конец) — строим вперёд от начала на выбранную длительность. + time: new Date(routeWindow.startMs).toISOString().slice(0, 19), durationSeconds, - roll: selectedShootPass.rollDeg ?? 0, + roll: routeRoll, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, revolution: selectedShootPass.revolution ?? 0, - anchor: routeAnchor + anchor: "FORWARD" }) .then((route) => { if (!cancelled) setSurveyRoute(route); @@ -330,7 +354,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, return () => { cancelled = true; }; - }, [rightMode, target, selectedShootPass, noradId, routeAnchor]); + }, [rightMode, target, selectedShootPass, noradId, routeWindow, routeRoll]); // Зоны радиовидимости (ЗРВ) для ручного сброса — грузим при входе в режим «Сброс». useEffect(() => { @@ -396,21 +420,20 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. const numericNorad = noradId ? Number(noradId) : NaN; - if (selectedPlan && surveyRoute && Number.isFinite(numericNorad)) { + if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) { const planId = selectedPlan.planId; - const momentMs = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; - const routeDurationSeconds = Math.max(1, (selectedShootPass.endMs - selectedShootPass.startMs) / 1000); + const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); setRouteLoading(true); setRouteError(undefined); try { const saved = await saveSurveyRoute(planId, { satelliteId: numericNorad, - time: new Date(momentMs).toISOString().slice(0, 19), + time: new Date(routeWindow.startMs).toISOString().slice(0, 19), durationSeconds: routeDurationSeconds, - roll: selectedShootPass.rollDeg ?? 0, + roll: routeRoll, captureAngle: DEFAULT_CAPTURE_ANGLE_DEG, revolution: selectedShootPass.revolution ?? 0, - anchor: routeAnchor, + anchor: "FORWARD", // Окно выбранного плана — чтобы при автосоздании миссии/плана задать корректный интервал. planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) @@ -424,8 +447,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } } - const durationMs = Math.min(selectedShootPass.endMs - selectedShootPass.startMs, 30 * 60 * 1000); - const startMs = selectedShootPass.startMs + Math.floor((selectedShootPass.endMs - selectedShootPass.startMs - durationMs) / 2); + // Окно включения в черновике — заданное вручную окно маршрута (фолбэк — окно прохода). + const startMs = routeWindow?.startMs ?? selectedShootPass.startMs; + const endMs = routeWindow?.endMs ?? selectedShootPass.endMs; const mode = modes.find((item) => item.id === modeId); const id = nextWorkId(idCounterRef); @@ -433,7 +457,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, addShoot(current, { id, startMs, - endMs: startMs + durationMs, + endMs, label: mode?.label ?? "Съёмка", modeId, target @@ -752,11 +776,39 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, selectedPass={selectedShootPass} modeId={modeId} modes={modes} - routeAnchor={routeAnchor} + routeWindow={routeWindow} + routeRoll={routeRoll} + passStartMs={selectedShootPass?.startMs} + passEndMs={selectedShootPass?.endMs} + routeMinMs={selectedShootPass != null ? selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS : undefined} + routeMaxMs={selectedShootPass != null ? selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS : undefined} routeLoading={routeLoading} routeError={routeError} route={surveyRoute} - onAnchorChange={setRouteAnchor} + onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))} + onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))} + onRouteShift={(deltaMs) => + setRouteWindow((current) => { + if (!current || selectedShootPass == null) return current; + // Двигаем окно целиком, сохраняя длительность; не выходим за ±30 мин от окна видимости. + const minMs = selectedShootPass.startMs - ROUTE_TIME_MARGIN_MS; + const maxMs = selectedShootPass.endMs + ROUTE_TIME_MARGIN_MS; + let shift = deltaMs; + if (current.endMs + shift > maxMs) shift = maxMs - current.endMs; + if (current.startMs + shift < minMs) shift = minMs - current.startMs; + return { startMs: current.startMs + shift, endMs: current.endMs + shift }; + }) + } + onRouteRollChange={(deltaDeg) => + setRouteRoll((current) => + Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg)) + ) + } + onRouteRollSet={(deg) => + setRouteRoll((current) => + Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current + ) + } onSelectPass={setSelectedShootPass} onModeChange={setModeId} onAdd={addShootWork} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index 17a93f2..e387e45 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -37,6 +37,16 @@ export function formatDateTime(ms: number): string { } export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds} с`; + } + if (totalSeconds < 3600) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`; + } + const hours = ms / 3_600_000; if (hours < 24) { return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index ab64a8e..01f341e 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -910,6 +910,18 @@ text-align: left; } +/* Узкая правая панель: строка прохода не помещается в один ряд — раскладываем + подпись и параметры видимости в столбец, чтобы текст не обрезался. */ +.tgu-editor-pass-list button { + flex-direction: column; + align-items: stretch; + gap: 0.2rem; +} + +.tgu-editor-pass-list small { + white-space: normal; +} + .tgu-editor-pass-list span, .tgu-editor-station span { color: var(--text); @@ -975,7 +987,8 @@ font-size: 0.62rem; } -.tgu-editor-time-stepper output { +.tgu-editor-time-stepper output, +.tgu-editor-time-stepper input { flex: 1; min-width: 0; border: 1px solid var(--line); @@ -988,6 +1001,31 @@ text-align: center; } +.tgu-editor-time-stepper input::-webkit-calendar-picker-indicator { + filter: invert(1); + opacity: 0.5; + cursor: pointer; +} + +/* Секундный степпер окна маршрута: поле ввода на своей строке, кнопки ± — ниже, + чтобы помещаться в узкую правую панель. */ +.tgu-editor-time-stepper--seconds { + gap: 0.3rem; +} + +.tgu-editor-time-stepper--seconds > input { + width: 100%; +} + +.tgu-editor-time-stepper__steps { + display: flex; + gap: 0.25rem; +} + +.tgu-editor-time-stepper__steps button { + flex: 1; +} + .tgu-editor-panel__color { position: absolute; top: 0.2rem; From 405f02aa86dc0a1f59e81a4c2431e28cc366cd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 22:35:50 +0300 Subject: [PATCH 23/79] =?UTF-8?q?pcp:=20=D1=81=D0=BB=D0=BE=D0=B9=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=D1=80=D0=B0=D0=BD=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=87=D0=B8=D1=82=D0=B0=D1=82=D1=8C=20naive(=3DUTC?= =?UTF-8?q?)=20=D0=B8=20Z=20=D0=B2=D0=BE=20=D0=B2=D1=81=D1=91=D0=BC=20?= =?UTF-8?q?=D1=84=D0=BB=D0=BE=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PcpTimeModule (libs/pcp-types-lib): толерантные Jackson-десериализаторы LocalDateTime/OffsetDateTime/Instant — любой потребитель читает оба формата на проводе и нормализует к UTC. Только чтение, формат записи не меняется. Подключение фронтом по флоту: - Spring Boot auto-configuration + AutoConfiguration.imports — Boot-маппер всех 12 сервисов на pcp-types-lib (без правок в сервисах); - Jackson SPI (META-INF/services) — ручные мапперы с findAndRegisterModules(); - точечно: dynamic-plan/JacksonConfig (ручной ObjectMapper); - tgu (WebFlux, не зависит от либы) — локальное зеркало модуля в CamundaJacksonConfig (Jackson-2 путь). Зачем: микросервисы деплоятся независимо; с толерантностью продюсера можно флипать LocalDateTime -> OffsetDateTime (Z) в любом порядке без синхронного деплоя — потребитель не падает на парсинге офсета. Тесты: PcpTimeModuleTest (оба формата -> один UTC-момент + авто-подхват SPI). Доки: DATETIME_UTC.md (раздел 2bis + найденные баги формата), CHECKLIST.md. --- docs/standards/CHECKLIST.md | 1 + docs/standards/DATETIME_UTC.md | 61 ++++++++- .../PcpTimeJacksonAutoConfiguration.kt | 23 ++++ .../pcp_types_lib/utils/json/PcpTimeModule.kt | 124 ++++++++++++++++++ .../com.fasterxml.jackson.databind.Module | 1 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../utils/json/PcpTimeModuleTest.kt | 81 ++++++++++++ .../space/nstart/pcp/complan/JacksonConfig.kt | 4 + .../config/CamundaJacksonConfig.kt | 4 + .../pcp_tgu_service/config/PcpTimeModule.kt | 94 +++++++++++++ 10 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt create mode 100644 libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module create mode 100644 libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 3be28ae..88e0b19 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -22,6 +22,7 @@ - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; - время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); +- чтение времени на бэке толерантно к обоим форматам (naive=UTC и `Z`) — модуль наследуется из `pcp-types-lib` (`PcpTimeModule`) авто-конфигурацией; ручной `ObjectMapper`/не-pcp-types-lib сервис регистрирует модуль явно; - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index 0e18509..4cb52d3 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -32,9 +32,50 @@ ## 2. Эталоны переиспользования - Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования. -- Backend: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход +- Backend (чтение): `libs/pcp-types-lib` `utils/json/PcpTimeModule` — слой толерантности + (бэковый аналог `parseUtc`): любой потребитель читает и naive(=UTC), и `Z`/офсет (см. раздел ниже). +- Backend (запись): `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход через `OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()` — корректный образец. +## 2bis. Слой толерантности времени (включатель безопасного флипа) + +`PcpTimeModule` (`libs/pcp-types-lib/.../utils/json/PcpTimeModule.kt`) — Jackson-модуль с +толерантными **десериализаторами** для `LocalDateTime`, `OffsetDateTime`, `Instant`. Любой +потребитель с ним читает время в **обоих** форматах на проводе и нормализует к UTC: + +| Тип поля | naive `…T01:00:00` | с зоной `…T01:00:00Z` / `+03:00` | +|---|---|---| +| `LocalDateTime` | как есть (= UTC) | приводится к UTC, зона отбрасывается | +| `OffsetDateTime` | `atOffset(UTC)` | `withOffsetSameInstant(UTC)` | +| `Instant` | трактуется как момент UTC | штатно | + +Модуль трогает **только чтение** — формат записи определяется типом поля и не меняется, поэтому +подключение слоя само по себе не сдвигает ни один контракт. + +**Зачем.** Микросервисы деплоятся независимо. Раньше перевод продюсера `LocalDateTime` → `OffsetDateTime` +(на проводе появляется `Z`) ломал ещё не обновлённого потребителя — штатный `LocalDateTimeDeserializer` +кидает исключение на офсете. С толерантностью во всём флоте продюсера можно флипать **в любом порядке, +без синхронного деплоя**. Это снимает требование «продюсер + все потребители одним коммитом». + +**Подключение (три механизма, чтобы покрыть все мапперы):** +- Spring Boot auto-configuration `PcpTimeJacksonAutoConfiguration` (`@Bean Module`) + + `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` — Boot-managed + `ObjectMapper` каждого из 12 сервисов, зависящих от pcp-types-lib (правок в сервисах не требуется). +- Jackson SPI `META-INF/services/com.fasterxml.jackson.databind.Module` — ручные мапперы с + `findAndRegisterModules()` (напр. `pcp-request-service`). +- Точечная регистрация в мапперах, что обходят оба: `pcp-dynamic-plan-service/JacksonConfig`. + +**Особый случай — pcp-tgu-service.** Он не зависит от pcp-types-lib и работает на WebFlux +(подключение либы притянуло бы `spring-boot-starter-web`/MVC и переключило тип приложения на +сервлетный). Поэтому в tgu лежит **локальное зеркало** `config/PcpTimeModule.kt` (поведение обязано +совпадать с библиотечным), зарегистрированное в Jackson-2 бине `CamundaJacksonConfig` — покрывает +Camunda/внутренний парсинг. Остаётся под-задача: REST-codec WebFlux на **Jackson 3** +(`tools.jackson.*`, иной API) ещё не толерантен — нужен Jackson-3 вариант модуля + регистрация в +кодеках. + +Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент, +плюс проверка авто-подхвата через SPI. + ## 3. Состояние сервисов (срез аудита) Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC), @@ -93,8 +134,22 @@ mission-planing) используют тот же дефолтный Jackson-к конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. -Следующие кандидаты на миграцию (по согласованию, по сервису): остальные naive-DTO ui-service, -ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. +- **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib + получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — локальным зеркалом (Jackson-2 + путь). Это включатель: дальнейший флип продюсеров на `Z` больше не требует синхронного деплоя. + +После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO +ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. + +### Найденные баги формата (теперь безопасно чинить — потребители толерантны) + +- **`KafkaMessage.time` (общий Kafka-конверт, pcp-types-lib).** `time = LocalDateTime.now()` (локальное!) + + сериализуется `LocalDateTimeTimeZoneSerializer` через `ZoneId.systemDefault()` → конверт несёт + МСК-стенные часы с `+03:00`, а потребитель (формат `…SSS[xxx]` в `LocalDateTime`) отбрасывает зону → + сдвиг. Под-задача: `now()` → UTC и сериализатор конверта → UTC/`Z`. Раньше блокировалось риском + поломки потребителей; со слоем толерантности — безопасно. +- **pcp-tgu-service REST-codec на Jackson 3** — ещё не толерантен (см. 2bis); под-задача на Jackson-3 + вариант модуля. ## 5. Правило для нового кода diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt new file mode 100644 index 0000000..6d1ae8f --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp.pcp_types_lib.configuration + +import com.fasterxml.jackson.databind.Module +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.context.annotation.Bean +import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule + +/** + * Регистрирует [PcpTimeModule] в Boot-managed `ObjectMapper` каждого сервиса, который подключает + * pcp-types-lib. Spring Boot собирает все бины типа [Module] из контекста в авто-конфигурируемый + * маппер, поэтому отдельный `@Bean` доезжает до всех сервисов без явного component-scan пакета либы. + * + * Ручные мапперы (вызывающие `findAndRegisterModules()`) подхватывают модуль через Jackson SPI — + * см. META-INF/services/com.fasterxml.jackson.databind.Module. + * + * Слой толерантности — см. [PcpTimeModule] и docs/standards/DATETIME_UTC.md. + */ +@AutoConfiguration +class PcpTimeJacksonAutoConfiguration { + + @Bean + fun pcpTimeModule(): Module = PcpTimeModule() +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt new file mode 100644 index 0000000..3ad19eb --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt @@ -0,0 +1,124 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +/** + * Слой толерантности времени для всей группы `pcp` (бэковый аналог фронтового `parseUtc`). + * + * Любой потребитель с этим модулем читает время в **обоих** форматах на проводе: + * naive ISO без зоны (исторический формат, трактуется как UTC по соглашению + * docs/standards/DATETIME_UTC.md) и ISO с явной зоной/`Z` (формат после миграции). + * Все значения нормализуются к UTC. + * + * Зачем: микросервисы деплоятся независимо. Если включить толерантность во всём флоте, + * продюсер можно перевести `LocalDateTime` → `OffsetDateTime` (на проводе появляется `Z`) + * в любом порядке — потребитель не упадёт на парсинге офсета. Без этого слоя миграция + * формата требовала бы синхронного деплоя продюсера и всех потребителей. + * + * Модуль трогает **только десериализацию** (чтение). Формат записи определяется типом поля + * (`LocalDateTime` → naive, `OffsetDateTime`/`Instant` → `Z`) и здесь не меняется, чтобы + * подключение слоя само по себе не сдвигало ни один контракт. + * + * Подключается через Spring Boot auto-configuration ([PcpTimeJacksonAutoConfiguration]) для + * Boot-managed `ObjectMapper` и через Jackson SPI + * (`META-INF/services/com.fasterxml.jackson.databind.Module`) для ручных мапперов, + * вызывающих `findAndRegisterModules()`. + */ +class PcpTimeModule : SimpleModule(NAME) { + + init { + addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) + addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) + addDeserializer(Instant::class.java, TolerantInstantDeserializer()) + } + + companion object { + const val NAME = "PcpTimeModule" + } +} + +/** + * Читает [LocalDateTime] из обоих форматов: строка с зоной/`Z` нормализуется к UTC и теряет зону, + * naive-строка парсится как есть (= UTC). Нестроковые токены (массивы/таймстемпы) делегируются + * штатному [LocalDateTimeDeserializer], чтобы не сломать прочие представления. + */ +class TolerantLocalDateTimeDeserializer : JsonDeserializer() { + + private val default = LocalDateTimeDeserializer.INSTANCE + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + tryParseToUtc(text)?.let { return it.toLocalDateTime() } + } + return default.deserialize(p, ctxt) + } + + /** Возвращает момент в UTC, если строка несёт зону/офсет; иначе null (строка naive). */ + private fun tryParseToUtc(text: String): OffsetDateTime? = + try { + OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + null + } +} + +/** + * Читает [OffsetDateTime] из обоих форматов: naive-строка получает зону UTC, + * строка с зоной приводится к UTC. + */ +class TolerantOffsetDateTimeDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} + +/** + * Читает [Instant] из обоих форматов: строка с `Z`/офсетом — штатно, naive-строка трактуется + * как момент в UTC. + */ +class TolerantInstantDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.INSTANT as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).toInstant() + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module new file mode 100644 index 0000000..6e96a8a --- /dev/null +++ b/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module @@ -0,0 +1 @@ +space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..32f1b41 --- /dev/null +++ b/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt new file mode 100644 index 0000000..c14c4fe --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt @@ -0,0 +1,81 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * Контракт слоя толерантности времени ([PcpTimeModule]): любой потребитель читает время + * в обоих форматах на проводе (naive=UTC и `Z`/офсет), всё нормализуется к UTC. + * Это и есть то, что делает безопасным пер-сервисный флип продюсера на `Z` + * без синхронного деплоя (см. docs/standards/DATETIME_UTC.md). + */ +class PcpTimeModuleTest { + + private val mapper = jacksonObjectMapper().registerModule(PcpTimeModule()) + + private data class LdtHolder(val t: LocalDateTime) + private data class OdtHolder(val t: OffsetDateTime) + private data class InstantHolder(val t: Instant) + + @Test + fun `LocalDateTime field reads naive and Z as the same UTC moment`() { + val naive: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") + val zoned: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + + val expected = LocalDateTime.of(2026, 5, 29, 1, 0, 0) + assertEquals(expected, naive.t) + assertEquals(expected, zoned.t, "Z-строка должна дать тот же UTC-момент, что naive") + } + + @Test + fun `LocalDateTime field converts explicit offset to UTC`() { + // +03:00 01:00 == 22:00 UTC предыдущего дня + val moscow: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00+03:00"}""") + + assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), moscow.t) + } + + @Test + fun `LocalDateTime field accepts Z without throwing (the regression the layer prevents)`() { + // Штатный LocalDateTimeDeserializer кинул бы исключение на офсете/Z. + assertDoesNotThrow { + mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + } + } + + @Test + fun `OffsetDateTime field reads naive as UTC and normalizes offset to UTC`() { + val naive: OdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") + val moscow: OdtHolder = mapper.readValue("""{"t":"2026-05-29T04:00:00+03:00"}""") + + assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), naive.t) + assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), moscow.t) + } + + @Test + fun `Instant field reads naive as UTC and Z as is`() { + val naive: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") + val zoned: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + + assertEquals(Instant.parse("2026-05-29T01:00:00Z"), naive.t) + assertEquals(Instant.parse("2026-05-29T01:00:00Z"), zoned.t) + } + + @Test + fun `module is auto-discovered via Jackson SPI (findAndRegisterModules)`() { + // Подтверждает, что META-INF/services/com.fasterxml.jackson.databind.Module работает: + // ручной маппер с findAndRegisterModules() получает толерантность без явной регистрации. + val spiMapper = jacksonObjectMapper().findAndRegisterModules() + + val holder: LdtHolder = spiMapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") + + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), holder.t) + } +} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt index eb0667d..b7e6727 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule @Configuration @@ -28,6 +29,9 @@ class JacksonConfig { // ) mapper.registerModule(JavaTimeModule()) .registerKotlinModule() + // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. + // Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI. + .registerModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) return mapper diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt index ecc1537..25d4278 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -24,6 +24,10 @@ class CamundaJacksonConfig { return JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .addModule(JavaTimeModule()) + // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. + // Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3 + // (WebFlux auto-config) — отдельный tgu-follow-up в DATETIME_UTC.md. + .addModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build() } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt new file mode 100644 index 0000000..e359d09 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt @@ -0,0 +1,94 @@ +package space.nstart.pcp_tgu_service.config + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +/** + * Локальное зеркало слоя толерантности времени из libs/pcp-types-lib + * (`space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`). + * + * Канонический источник — там; здесь копия, потому что pcp-tgu-service работает на WebFlux, + * а pcp-types-lib тянет `spring-boot-starter-web` (MVC/Tomcat) и переключил бы тип приложения + * на сервлетный. Поведение обязано совпадать с библиотечным; правки синхронизировать. + * + * Любой потребитель с этим модулем читает время в обоих форматах на проводе (naive=UTC и `Z`/офсет), + * нормализуя к UTC. Трогает только десериализацию. См. docs/standards/DATETIME_UTC.md. + */ +class PcpTimeModule : SimpleModule(NAME) { + + init { + addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) + addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) + addDeserializer(Instant::class.java, TolerantInstantDeserializer()) + } + + companion object { + const val NAME = "PcpTimeModule" + } +} + +private class TolerantLocalDateTimeDeserializer : JsonDeserializer() { + + private val default = LocalDateTimeDeserializer.INSTANCE + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + } + } + return default.deserialize(p, ctxt) + } +} + +private class TolerantOffsetDateTimeDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} + +private class TolerantInstantDeserializer : JsonDeserializer() { + + @Suppress("UNCHECKED_CAST") + private val default = InstantDeserializer.INSTANT as JsonDeserializer + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.text.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).toInstant() + } catch (_: DateTimeParseException) { + return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} From 84e70350b9cc3b314b9266975dea4ec1f9ea84a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 22:48:13 +0300 Subject: [PATCH 24/79] =?UTF-8?q?pcp-types-lib:=20Kafka-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=82=20time=20=D0=B2=20UTC=20(=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=20=D1=81=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=8F=D1=81=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KafkaMessage.time: LocalDateTime.now() -> now(ZoneOffset.UTC); LocalDateTimeTimeZoneSerializer: atZone(systemDefault()) -> atOffset(UTC). Было: локальное now() + systemDefault давали на проводе "...+03:00", и потребитель (паттерн ...SSS[xxx] в LocalDateTime) дропал зону -> сдвиг на пояс. Стало: "...+00:00" — инстант тот же, но стенные часы совпадают с UTC, сдвиг ушёл. Тест LocalDateTimeTimeZoneSerializerTest: UTC-офсет без сдвига + round-trip. DATETIME_UTC.md: баг перенесён в «Исправленные». --- docs/standards/DATETIME_UTC.md | 18 ++++--- .../pcp/pcp_types_lib/message/KafkaMessage.kt | 6 ++- .../json/LocalDateTimeTimeZoneSerializer.kt | 14 ++++-- .../LocalDateTimeTimeZoneSerializerTest.kt | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index 4cb52d3..0fc7bc5 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -141,15 +141,19 @@ mission-planing) используют тот же дефолтный Jackson-к После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. -### Найденные баги формата (теперь безопасно чинить — потребители толерантны) +### Исправленные баги формата + +- **`KafkaMessage.time` (общий Kafka-конверт, pcp-types-lib) — исправлено.** Было `time = LocalDateTime.now()` + (локальное!) + сериализация `LocalDateTimeTimeZoneSerializer` через `ZoneId.systemDefault()`: конверт + нёс МСК-стенные часы с `+03:00`, а потребитель (формат `…SSS[xxx]` в `LocalDateTime`) отбрасывал зону + → сдвиг на пояс. Стало `now(ZoneOffset.UTC)` + сериализация `atOffset(ZoneOffset.UTC)` → на проводе + `…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому + потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`. + +### Остаток (под-задачи) -- **`KafkaMessage.time` (общий Kafka-конверт, pcp-types-lib).** `time = LocalDateTime.now()` (локальное!) - + сериализуется `LocalDateTimeTimeZoneSerializer` через `ZoneId.systemDefault()` → конверт несёт - МСК-стенные часы с `+03:00`, а потребитель (формат `…SSS[xxx]` в `LocalDateTime`) отбрасывает зону → - сдвиг. Под-задача: `now()` → UTC и сериализатор конверта → UTC/`Z`. Раньше блокировалось риском - поломки потребителей; со слоем толерантности — безопасно. - **pcp-tgu-service REST-codec на Jackson 3** — ещё не толерантен (см. 2bis); под-задача на Jackson-3 - вариант модуля. + вариант модуля + регистрация в кодеках WebFlux. ## 5. Правило для нового кода diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt index 0e55531..55a7589 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.databind.annotation.JsonSerialize import space.nstart.pcp.pcp_types_lib.utils.json.LocalDateTimeTimeZoneSerializer import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.* @@ -20,9 +21,12 @@ class KafkaMessage ( val id = UUID.randomUUID().toString() var source = "Example" + // Время конверта в UTC: naive LocalDateTime трактуется как UTC (см. docs/standards/DATETIME_UTC.md), + // сериализатор помечает его офсетом +00:00. Раньше now() + systemDefault давали локальные часы и + // несовпадение момента у потребителей, дропающих зону. @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) - val time: LocalDateTime = LocalDateTime.now() + val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val dataContentType = "application/json" val specVersion = "1.0" diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt index 42b54ad..65b5373 100755 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt @@ -4,9 +4,16 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializerProvider import java.time.LocalDateTime -import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter +/** + * Сериализует naive [LocalDateTime] как момент в **UTC** (офсет `+00:00`). + * По соглашению группы `pcp` naive LocalDateTime = UTC (см. docs/standards/DATETIME_UTC.md), + * поэтому зона навешивается через [ZoneOffset.UTC], а не через пояс машины. + * Прежняя реализация (`ZoneId.systemDefault()`) помечала те же часы локальным офсетом, из-за чего + * потребитель, дропающий зону, получал сдвиг на пояс. + */ class LocalDateTimeTimeZoneSerializer: JsonSerializer() { companion object { @@ -14,9 +21,6 @@ class LocalDateTimeTimeZoneSerializer: JsonSerializer() { } override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { - - //todo: проверить корректность зоны по нормальным правилам -// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt)) - p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt)) + p1.writeString(p0?.atOffset(ZoneOffset.UTC)?.format(fmt)) } } \ No newline at end of file diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt new file mode 100644 index 0000000..69de0d6 --- /dev/null +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +/** + * Контракт сериализатора времени Kafka-конверта ([LocalDateTimeTimeZoneSerializer]): + * naive LocalDateTime помечается офсетом UTC (`+00:00`), часы не сдвигаются. + * Закрывает баг прежней реализации через `ZoneId.systemDefault()` + * (см. docs/standards/DATETIME_UTC.md). + */ +class LocalDateTimeTimeZoneSerializerTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + + // Зеркалит аннотации реального поля KafkaMessage.time (паттерн с опциональным офсетом). + private data class Holder( + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") + @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) + val t: LocalDateTime + ) + + @Test + fun `serializes naive LocalDateTime with UTC offset, no wall-clock shift`() { + val json = mapper.writeValueAsString(Holder(LocalDateTime.of(2026, 5, 29, 1, 0, 0))) + + assertTrue( + json.contains("2026-05-29T01:00:00.000+00:00"), + "ожидался UTC-офсет без сдвига часов, получено: $json" + ) + } + + @Test + fun `round-trips through the tolerance layer to the same UTC moment`() { + val original = LocalDateTime.of(2026, 5, 29, 1, 0, 0) + + val json = mapper.writeValueAsString(Holder(original)) + val back: Holder = mapper.readValue(json) + + assertEquals(original, back.t) + } +} From cde4ffa9b6ed526113497f69c46cea27b50cf16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 22:57:49 +0300 Subject: [PATCH 25/79] =?UTF-8?q?pcp-tgu-service:=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BD=D0=B0=20Jac?= =?UTF-8?q?kson-3=20=D0=BF=D1=83=D1=82=D0=B8=20(REST-=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D0=B8=20WebFlux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/standards/DATETIME_UTC.md | 30 ++++-- .../config/CamundaJacksonConfig.kt | 2 +- .../pcp_tgu_service/config/PcpTimeModuleV3.kt | 93 +++++++++++++++++++ .../config/WebFluxJacksonConfig.kt | 22 +++++ .../config/PcpTimeModuleV3Test.kt | 55 +++++++++++ 5 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index 0fc7bc5..c457f42 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -67,11 +67,15 @@ **Особый случай — pcp-tgu-service.** Он не зависит от pcp-types-lib и работает на WebFlux (подключение либы притянуло бы `spring-boot-starter-web`/MVC и переключило тип приложения на -сервлетный). Поэтому в tgu лежит **локальное зеркало** `config/PcpTimeModule.kt` (поведение обязано -совпадать с библиотечным), зарегистрированное в Jackson-2 бине `CamundaJacksonConfig` — покрывает -Camunda/внутренний парсинг. Остаётся под-задача: REST-codec WebFlux на **Jackson 3** -(`tools.jackson.*`, иной API) ещё не толерантен — нужен Jackson-3 вариант модуля + регистрация в -кодеках. +сервлетный). Поэтому в tgu лежат **два локальных зеркала** (поведение обязано совпадать с +библиотечным, правки синхронизировать), покрывающих обе Jackson-среды сервиса: +- `config/PcpTimeModule.kt` (Jackson 2) — зарегистрирован в `CamundaJacksonConfig`, покрывает + Camunda/внутренний парсинг; +- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST-кодеки WebFlux, которые + Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином + `tools.jackson.databind.JacksonModule` (`WebFluxJacksonConfig`); `JacksonAutoConfiguration` + собирает все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Тем самым входящий + REST-JSON tgu тоже читается толерантно к обоим форматам. Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент, плюс проверка авто-подхвата через SPI. @@ -135,8 +139,9 @@ mission-planing) используют тот же дефолтный Jackson-к синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. - **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib - получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — локальным зеркалом (Jackson-2 - путь). Это включатель: дальнейший флип продюсеров на `Z` больше не требует синхронного деплоя. + получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — двумя локальными зеркалами + (Jackson-2 для Camunda и Jackson-3 для REST-кодеков WebFlux). Это включатель: дальнейший флип + продюсеров на `Z` больше не требует синхронного деплоя. После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. @@ -150,10 +155,17 @@ ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. `…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`. +### Исправленные баги формата + +- **pcp-tgu-service REST-codec на Jackson 3** — теперь толерантен (см. 2bis). Добавлен Jackson-3 + вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule` + (`config/WebFluxJacksonConfig.kt`); входящий REST-JSON WebFlux читается в обоих форматах + (naive=UTC и `Z`). Закреплено `PcpTimeModuleV3Test`. + ### Остаток (под-задачи) -- **pcp-tgu-service REST-codec на Jackson 3** — ещё не толерантен (см. 2bis); под-задача на Jackson-3 - вариант модуля + регистрация в кодеках WebFlux. +- Обе Jackson-среды tgu теперь толерантны; отдельных остатков по чтению времени нет. При миграции + оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта. ## 5. Правило для нового кода diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt index 25d4278..fb8da9c 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -26,7 +26,7 @@ class CamundaJacksonConfig { .addModule(JavaTimeModule()) // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. // Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3 - // (WebFlux auto-config) — отдельный tgu-follow-up в DATETIME_UTC.md. + // (WebFlux auto-config) покрыт близнецом PcpTimeModuleV3 (см. WebFluxJacksonConfig). .addModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt new file mode 100644 index 0000000..316b836 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt @@ -0,0 +1,93 @@ +package space.nstart.pcp_tgu_service.config + +import tools.jackson.core.JsonParser +import tools.jackson.core.JsonToken +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.ValueDeserializer +import tools.jackson.databind.ext.javatime.deser.InstantDeserializer +import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer +import tools.jackson.databind.module.SimpleModule +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +/** + * Слой толерантности времени для **Jackson 3** (`tools.jackson.*`). + * + * Близнец [PcpTimeModule] (Jackson 2): то же поведение — читать время в обоих форматах на проводе + * (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. Нужен отдельно, потому + * что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST-кодеков WebFlux, а он не видит + * Jackson-2 модули. Регистрируется как бин `tools.jackson.databind.JacksonModule` + * ([WebFluxJacksonConfig]) — `JacksonAutoConfiguration` собирает такие бины в общий `JsonMapper`. + * + * Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`; + * правки синхронизировать. См. docs/standards/DATETIME_UTC.md. + */ +class PcpTimeModuleV3 : SimpleModule(NAME) { + + init { + addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3()) + addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3()) + addDeserializer(Instant::class.java, TolerantInstantDeserializerV3()) + } + + companion object { + const val NAME = "PcpTimeModuleV3" + } +} + +private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer() { + + private val default: ValueDeserializer = LocalDateTimeDeserializer.INSTANCE + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.string.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + } catch (_: DateTimeParseException) { + // naive-строка без зоны — трактуем как UTC + } + } + return default.deserialize(p, ctxt) + } +} + +private class TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer() { + + private val default: ValueDeserializer = InstantDeserializer.OFFSET_DATE_TIME + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.string.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} + +private class TolerantInstantDeserializerV3 : ValueDeserializer() { + + private val default: ValueDeserializer = InstantDeserializer.INSTANT + + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { + if (p.currentToken() == JsonToken.VALUE_STRING) { + val text = p.string.trim() + if (text.isEmpty()) return null + try { + return OffsetDateTime.parse(text).toInstant() + } catch (_: DateTimeParseException) { + return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) + } + } + return default.deserialize(p, ctxt) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt new file mode 100644 index 0000000..343f848 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt @@ -0,0 +1,22 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import tools.jackson.databind.JacksonModule + +/** + * Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3 + * `JsonMapper`, который Boot 4 использует для REST-кодеков WebFlux. + * + * `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста + * и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин + * этого типа. Закрывает tgu-follow-up из docs/standards/DATETIME_UTC.md: входящий REST-JSON теперь + * читается толерантно к обоим форматам времени (naive=UTC и `Z`), как и Jackson-2 путь + * ([CamundaJacksonConfig]). + */ +@Configuration +class WebFluxJacksonConfig { + + @Bean + fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3() +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt new file mode 100644 index 0000000..4679474 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt @@ -0,0 +1,55 @@ +package space.nstart.pcp_tgu_service.config + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tools.jackson.databind.json.JsonMapper +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset + +/** + * Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает + * время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец + * библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md. + */ +class PcpTimeModuleV3Test { + + private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build() + + @Test + fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() { + val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java) + val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java) + + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive) + assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ) + } + + @Test + fun `LocalDateTime normalizes an explicit offset to UTC`() { + // 01:00+03:00 == 28-го 22:00 UTC + val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java) + + assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset) + } + + @Test + fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() { + val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java) + val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java) + + assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive) + assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset) + } + + @Test + fun `Instant reads naive(=UTC) and Z to the same moment`() { + val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java) + val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java) + + val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant() + assertEquals(expected, fromNaive) + assertEquals(expected, fromZ) + } +} From e68c17e7920a70ce6f639c714ffd092ab2a60dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 23:14:12 +0300 Subject: [PATCH 26/79] =?UTF-8?q?pcp-tgu-service:=20=D0=BC=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=20WebFlux=20=D0=BD?= =?UTF-8?q?=D0=B0=20servlet/MVC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebFlux в tgu был фиктивным: блокирующая JPA, контроллеры оборачивали блокирующие вызовы в Mono.fromCallable{}.subscribeOn(boundedElastic()), WebClient .block()-ался сразу. Единственный WebFlux-only сервис флота. - build: подключён pcp-types-lib (тянет starter-web → servlet), springdoc webflux-ui → webmvc-ui; starter-webflux оставлен только ради WebClient - контроллеры возвращают значения напрямую (без Mono-обёрток) - удалено локальное Jackson-2 зеркало PcpTimeModule.kt → модуль из либы в CamundaJacksonConfig; Jackson-3 зеркало остаётся (Boot 4 держит Jackson 3 на HTTP-границе и для MVC), WebFluxJacksonConfig → RestJacksonConfig --- docs/standards/DATETIME_UTC.md | 46 +++++---- services/pcp-tgu-service/build.gradle.kts | 8 +- .../config/CamundaJacksonConfig.kt | 5 +- .../pcp_tgu_service/config/PcpTimeModule.kt | 94 ------------------- .../pcp_tgu_service/config/PcpTimeModuleV3.kt | 11 ++- ...xJacksonConfig.kt => RestJacksonConfig.kt} | 9 +- .../controller/PlanController.kt | 14 +-- .../controller/PlatformController.kt | 8 +- .../controller/TestTguController.kt | 65 +++++-------- 9 files changed, 78 insertions(+), 182 deletions(-) delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt rename services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/{WebFluxJacksonConfig.kt => RestJacksonConfig.kt} (68%) diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md index c457f42..de1aaac 100644 --- a/docs/standards/DATETIME_UTC.md +++ b/docs/standards/DATETIME_UTC.md @@ -65,17 +65,16 @@ `findAndRegisterModules()` (напр. `pcp-request-service`). - Точечная регистрация в мапперах, что обходят оба: `pcp-dynamic-plan-service/JacksonConfig`. -**Особый случай — pcp-tgu-service.** Он не зависит от pcp-types-lib и работает на WebFlux -(подключение либы притянуло бы `spring-boot-starter-web`/MVC и переключило тип приложения на -сервлетный). Поэтому в tgu лежат **два локальных зеркала** (поведение обязано совпадать с -библиотечным, правки синхронизировать), покрывающих обе Jackson-среды сервиса: -- `config/PcpTimeModule.kt` (Jackson 2) — зарегистрирован в `CamundaJacksonConfig`, покрывает - Camunda/внутренний парсинг; -- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST-кодеки WebFlux, которые - Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином - `tools.jackson.databind.JacksonModule` (`WebFluxJacksonConfig`); `JacksonAutoConfiguration` - собирает все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Тем самым входящий - REST-JSON tgu тоже читается толерантно к обоим форматам. +**Особый случай — pcp-tgu-service.** Сервис мигрирован с WebFlux на **servlet/MVC** (см. ниже +«Миграция tgu на MVC») и теперь зависит от pcp-types-lib, как остальной флот. Но из-за Boot 4 на +HTTP-границе всё равно работает **Jackson 3**, поэтому время покрывается двумя путями: +- Jackson-2 модуль **из либы** (`pcp_types_lib.utils.json.PcpTimeModule`) — явно зарегистрирован в + ручном маппере `CamundaJacksonConfig` (Camunda/внутренний парсинг); своего зеркала больше нет. +- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST HTTP message converters (MVC), + которые Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином + `tools.jackson.databind.JacksonModule` (`RestJacksonConfig`); `JacksonAutoConfiguration` собирает + все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Лежит локально, т.к. либа даёт + только Jackson-2 модуль; поведение обязано совпадать с библиотечным, правки синхронизировать. Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент, плюс проверка авто-подхвата через SPI. @@ -139,8 +138,8 @@ mission-planing) используют тот же дефолтный Jackson-к синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. - **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib - получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — двумя локальными зеркалами - (Jackson-2 для Camunda и Jackson-3 для REST-кодеков WebFlux). Это включатель: дальнейший флип + получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — модулем из либы (Camunda, + Jackson 2) плюс локальное Jackson-3 зеркало для REST-границы MVC. Это включатель: дальнейший флип продюсеров на `Z` больше не требует синхронного деплоя. После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO @@ -155,16 +154,25 @@ ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. `…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`. -### Исправленные баги формата - -- **pcp-tgu-service REST-codec на Jackson 3** — теперь толерантен (см. 2bis). Добавлен Jackson-3 +- **pcp-tgu-service REST-граница на Jackson 3** — теперь толерантна (см. 2bis). Добавлен Jackson-3 вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule` - (`config/WebFluxJacksonConfig.kt`); входящий REST-JSON WebFlux читается в обоих форматах - (naive=UTC и `Z`). Закреплено `PcpTimeModuleV3Test`. + (`config/RestJacksonConfig.kt`); входящий REST-JSON читается в обоих форматах (naive=UTC и `Z`). + Закреплено `PcpTimeModuleV3Test`. + +### Миграция tgu на MVC + +- **pcp-tgu-service: WebFlux → servlet/MVC.** Сервис не имел реального реактивного кода — JPA + блокирующая, контроллеры оборачивали блокирующие вызовы в `Mono.fromCallable{}.subscribeOn( + boundedElastic())`, WebClient-вызовы `.block()`-ались сразу. Это был единственный WebFlux-only + сервис флота. Переведён на MVC: подключён pcp-types-lib (тянет `starter-web` → тип приложения + servlet), `springdoc` → `webmvc-ui`, контроллеры возвращают значения напрямую (без `Mono`). + `starter-webflux` оставлен только ради WebClient. Побочный эффект для времени: убрано локальное + Jackson-2 зеркало (`config/PcpTimeModule.kt`) в пользу модуля из либы; Jackson-3 зеркало остаётся + (Boot 4 держит Jackson 3 на HTTP-границе и для MVC). ### Остаток (под-задачи) -- Обе Jackson-среды tgu теперь толерантны; отдельных остатков по чтению времени нет. При миграции +- Обе Jackson-среды tgu толерантны; отдельных остатков по чтению времени нет. При миграции оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта. ## 5. Правило для нового кода diff --git a/services/pcp-tgu-service/build.gradle.kts b/services/pcp-tgu-service/build.gradle.kts index 05636e5..733dc7b 100644 --- a/services/pcp-tgu-service/build.gradle.kts +++ b/services/pcp-tgu-service/build.gradle.kts @@ -17,11 +17,17 @@ java { dependencies { + // Общие типы/слой толерантности времени; тянет spring-boot-starter-web (servlet/MVC), + // из-за чего приложение запускается как сервлетное (MVC), а не WebFlux. + implementation(project(":libs:pcp-types-lib")) + // Spring Boot + // starter-webflux оставлен только ради WebClient (внешние HTTP-клиенты, см. integration/api); + // тип приложения — сервлетный (MVC), т.к. в classpath есть spring-webmvc из starter-web выше. implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-actuator") runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.0") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt index fb8da9c..0c04f21 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -10,6 +10,7 @@ import io.camunda.client.api.JsonMapper as CamundaJsonMapper import io.camunda.client.impl.CamundaObjectMapper import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule @Configuration class CamundaJacksonConfig { @@ -25,8 +26,8 @@ class CamundaJacksonConfig { .addModule(KotlinModule.Builder().build()) .addModule(JavaTimeModule()) // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. - // Покрывает Jackson-2 путь (Camunda/внутренний парсинг). REST-codec на Jackson 3 - // (WebFlux auto-config) покрыт близнецом PcpTimeModuleV3 (см. WebFluxJacksonConfig). + // Модуль из pcp-types-lib; покрывает Jackson-2 путь (этот ручной маппер для Camunda). + // REST-границу (Jackson 3) покрывает PcpTimeModuleV3 (см. RestJacksonConfig). .addModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt deleted file mode 100644 index e359d09..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModule.kt +++ /dev/null @@ -1,94 +0,0 @@ -package space.nstart.pcp_tgu_service.config - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.JsonToken -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeParseException - -/** - * Локальное зеркало слоя толерантности времени из libs/pcp-types-lib - * (`space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`). - * - * Канонический источник — там; здесь копия, потому что pcp-tgu-service работает на WebFlux, - * а pcp-types-lib тянет `spring-boot-starter-web` (MVC/Tomcat) и переключил бы тип приложения - * на сервлетный. Поведение обязано совпадать с библиотечным; правки синхронизировать. - * - * Любой потребитель с этим модулем читает время в обоих форматах на проводе (naive=UTC и `Z`/офсет), - * нормализуя к UTC. Трогает только десериализацию. См. docs/standards/DATETIME_UTC.md. - */ -class PcpTimeModule : SimpleModule(NAME) { - - init { - addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) - addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) - addDeserializer(Instant::class.java, TolerantInstantDeserializer()) - } - - companion object { - const val NAME = "PcpTimeModule" - } -} - -private class TolerantLocalDateTimeDeserializer : JsonDeserializer() { - - private val default = LocalDateTimeDeserializer.INSTANCE - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - } - } - return default.deserialize(p, ctxt) - } -} - -private class TolerantOffsetDateTimeDeserializer : JsonDeserializer() { - - @Suppress("UNCHECKED_CAST") - private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} - -private class TolerantInstantDeserializer : JsonDeserializer() { - - @Suppress("UNCHECKED_CAST") - private val default = InstantDeserializer.INSTANT as JsonDeserializer - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).toInstant() - } catch (_: DateTimeParseException) { - return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt index 316b836..bed6d43 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt @@ -16,11 +16,12 @@ import java.time.format.DateTimeParseException /** * Слой толерантности времени для **Jackson 3** (`tools.jackson.*`). * - * Близнец [PcpTimeModule] (Jackson 2): то же поведение — читать время в обоих форматах на проводе - * (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. Нужен отдельно, потому - * что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST-кодеков WebFlux, а он не видит - * Jackson-2 модули. Регистрируется как бин `tools.jackson.databind.JacksonModule` - * ([WebFluxJacksonConfig]) — `JacksonAutoConfiguration` собирает такие бины в общий `JsonMapper`. + * Близнец библиотечного `PcpTimeModule` (Jackson 2): то же поведение — читать время в обоих + * форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. + * Нужен отдельно, потому что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST HTTP message + * converters (MVC), а он не видит Jackson-2 модули. Регистрируется как бин + * `tools.jackson.databind.JacksonModule` ([RestJacksonConfig]) — `JacksonAutoConfiguration` собирает + * такие бины в общий `JsonMapper`. * * Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`; * правки синхронизировать. См. docs/standards/DATETIME_UTC.md. diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt similarity index 68% rename from services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt rename to services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt index 343f848..4dcb2e9 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/WebFluxJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt @@ -6,16 +6,15 @@ import tools.jackson.databind.JacksonModule /** * Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3 - * `JsonMapper`, который Boot 4 использует для REST-кодеков WebFlux. + * `JsonMapper`, который Boot 4 использует для REST HTTP message converters (MVC). * * `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста * и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин - * этого типа. Закрывает tgu-follow-up из docs/standards/DATETIME_UTC.md: входящий REST-JSON теперь - * читается толерантно к обоим форматам времени (naive=UTC и `Z`), как и Jackson-2 путь - * ([CamundaJacksonConfig]). + * этого типа. Входящий REST-JSON читается толерантно к обоим форматам времени (naive=UTC и `Z`), + * как и Jackson-2 путь Camunda ([CamundaJacksonConfig]). См. docs/standards/DATETIME_UTC.md. */ @Configuration -class WebFluxJacksonConfig { +class RestJacksonConfig { @Bean fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 96da5e6..b67a0bb 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -6,8 +6,6 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.service.PlanQueryService @@ -19,18 +17,14 @@ class PlanController( /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @GetMapping - fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono> = - Mono.fromCallable { - planQueryService.getAllPlans(historyDays) - }.subscribeOn(Schedulers.boundedElastic()) + fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List = + planQueryService.getAllPlans(historyDays) /** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */ @GetMapping("/{spacecraftId}") fun getPlansBySpacecraftId( @PathVariable spacecraftId: String, @RequestParam(defaultValue = "7") historyDays: Long - ): Mono> = - Mono.fromCallable { - planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) - }.subscribeOn(Schedulers.boundedElastic()) + ): List = + planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt index 0d17c5d..4cd59bd 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt @@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers /** * REST-контроллер для просмотра списка космических аппаратов. @@ -30,10 +28,8 @@ class PlatformController( @Operation(summary = "Получить список космических аппаратов") fun getPlatforms( @RequestParam(required = false) status: String? - ): Mono> = - Mono.fromCallable { - platformService.loadPlatforms(status).map { platform -> platform.toResponse() } - }.subscribeOn(Schedulers.boundedElastic()) + ): List = + platformService.loadPlatforms(status).map { platform -> platform.toResponse() } private fun PlatformCatalogItem.toResponse(): PlatformResponse = PlatformResponse( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt index 7f63d1f..be17d65 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt @@ -9,8 +9,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage import space.nstart.pcp_tgu_service.dto.PlanResponse @@ -49,31 +47,25 @@ class TestTguController( fun refreshVisibility( @PathVariable spacecraftId: String, @RequestParam(required = false) noradId: Long? - ): Mono = - Mono.fromRunnable { - visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") - }.subscribeOn(Schedulers.boundedElastic()) + ) { + visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") + } /** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */ @GetMapping("/spacecraft/{spacecraftId}/projection") - fun getProjection(@PathVariable spacecraftId: String): Mono> = - Mono.fromCallable { - planProjectionService.project(spacecraftId).map(PlanResponse::projected) - }.subscribeOn(Schedulers.boundedElastic()) + fun getProjection(@PathVariable spacecraftId: String): List = + planProjectionService.project(spacecraftId).map(PlanResponse::projected) /** Принудительно выдаёт конкретный persisted plan через новую attempt. */ @PostMapping("/plans/{planId}/issue") - fun issuePlan(@PathVariable planId: UUID): Mono = - Mono.fromCallable { - issueDuePlanWorker.issuePlanNow(planId) - }.subscribeOn(Schedulers.boundedElastic()) + fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse = + issueDuePlanWorker.issuePlanNow(planId) /** Инжектит decision event через тот же use case, что Kafka consumer. */ @PostMapping("/plan-decisions") - fun handleDecision(@RequestBody message: PlanDecisionMessage): Mono = - Mono.fromRunnable { - handlePlanDecisionUseCase.handle(message) - }.subscribeOn(Schedulers.boundedElastic()) + fun handleDecision(@RequestBody message: PlanDecisionMessage) { + handlePlanDecisionUseCase.handle(message) + } /** Генерирует ACCEPTED/REJECTED decision event для active attempt указанного persisted plan. */ @PostMapping("/plans/{planId}/decision") @@ -81,23 +73,20 @@ class TestTguController( @PathVariable planId: UUID, @RequestParam decision: PlanDecision, @RequestParam(required = false) reason: String? - ): Mono = - Mono.fromCallable { - testPlanDecisionService.generateDecision(planId, decision, reason) - }.subscribeOn(Schedulers.boundedElastic()) + ): PlanDecisionMessage = + testPlanDecisionService.generateDecision(planId, decision, reason) /** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */ @PostMapping("/platforms/load-from-file") - fun loadPlatformsFromFile(@RequestParam("path") filePath: String): Mono = - Mono.fromCallable { - val path = Path.of(filePath).toAbsolutePath().normalize() - val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path)) - TestPlatformsLoadResponse( - filePath = path.toString(), - loadedCount = platforms.size, - allowedCount = platformService.loadAllowedPlatforms().size - ) - }.subscribeOn(Schedulers.boundedElastic()) + fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse { + val path = Path.of(filePath).toAbsolutePath().normalize() + val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path)) + return TestPlatformsLoadResponse( + filePath = path.toString(), + loadedCount = platforms.size, + allowedCount = platformService.loadAllowedPlatforms().size + ) + } /** Загружает raw payload окон видимости из баллистики для диагностики. */ @GetMapping("/external-points/{noradId}") @@ -109,17 +98,13 @@ class TestTguController( @RequestParam("time_stop") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStop: LocalDateTime - ): Mono = - Mono.fromCallable { - externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) - }.subscribeOn(Schedulers.boundedElastic()) + ): String = + externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) /** Возвращает persisted plan для проверки результата ручных операций. */ @GetMapping("/plans/{planId}") - fun getPlan(@PathVariable planId: UUID): Mono = - Mono.fromCallable { - PlanResponse.from(planQueryService.getByPlanId(planId)) - }.subscribeOn(Schedulers.boundedElastic()) + fun getPlan(@PathVariable planId: UUID): PlanResponse = + PlanResponse.from(planQueryService.getByPlanId(planId)) } data class TestPlatformsLoadResponse( From f0e16aa4e89938d82aa22f67c29257876482c726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 2 Jun 2026 23:33:32 +0300 Subject: [PATCH 27/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D0=BF=D0=BE=D1=80=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20+=2024=D1=87=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D1=82=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Редактор: убрано накопление целей съёмки — на карте только одно перекрестие текущей точки (pickTarget заменяется при каждом клике), с перекрестия убрана подпись. Правая панель переименована в «Опорная точка прицеливания» с координатами. - 24-часовой формат: lang="ru-RU" на всех datetime-local инпутах (Планы, фильтры заявок на карте, создание/фильтр заявок), иначе браузер рендерил их по локали ОС в AM/PM. --- .../src/features/tgu-editor/EditorPanels.tsx | 4 ++-- .../src/features/tgu-editor/TguEditorTab.tsx | 19 +++---------------- .../features/tgu-map-2d/Tgu2DMapSidebar.tsx | 2 ++ .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 1 - .../src/features/tgu-planning/TguToolbar.tsx | 4 ++-- .../tgu-requests/RequestCreatePanel.tsx | 4 ++-- .../features/tgu-requests/TguRequestsTab.tsx | 2 ++ 7 files changed, 13 insertions(+), 23 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index ead6a14..af22317 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -250,7 +250,7 @@ export function ShootPanel({
-
1 · цель съёмки
+
1 · опорная точка прицеливания
{target && (
{!target ? ( -
Кликните точку на карте слева, чтобы выбрать цель съёмки.
+
Кликните точку на карте слева, чтобы задать опорную точку прицеливания.
) : (
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 2ecc09a..f486906 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -37,7 +37,6 @@ import type { DraftHistory, EditorGroundStation, EditorMode, - EditorShootWork, EditorTarget, EditorTimelineWindow, EditorVisibilityTrack, @@ -167,13 +166,6 @@ 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 shootTargets = useMemo( - () => - draft.works - .filter((w): w is EditorShootWork => w.kind === "shoot" && w.targetLat !== undefined && w.targetLon !== undefined) - .map((w) => ({ id: w.id, name: w.label, lat: w.targetLat!, lon: w.targetLon! })), - [draft.works] - ); const contextLanes = useMemo( () => Array.from(contextSpacecraftIds).map((spacecraftId) => ({ @@ -692,19 +684,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, layers={EDITOR_MAP_LAYERS} redrawToken={0} pickMode={rightMode === "shoot"} - targets={shootTargets} pickTarget={target ?? undefined} - selectedTargetId={selectedWork?.kind === "shoot" ? selectedWorkId : undefined} - onTargetSelect={(id) => { - setSelectedWorkId(id); - setRightMode("inspect"); - setNotice(undefined); - }} onPickPoint={(point) => { + // Опорная точка прицеливания — всегда одна: каждый клик заменяет + // предыдущее перекрестие, цели не накапливаются. targetCounterRef.current += 1; setTarget({ id: `target-${targetCounterRef.current}`, - name: `Цель ${targetCounterRef.current}`, + name: "Опорная точка прицеливания", lat: point.lat, lon: point.lon }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx index ecf26e4..5554ef2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx @@ -106,6 +106,7 @@ export function Tgu2DMapSidebar({
+
+
Параметры включения
+
+
Аппарат
+
{spacecraftLabel ?? "—"}
+
Виток
+
{work.revolution ?? "—"}
+ {work.kind === "shoot" && ( + <> +
Крен
+
{work.rollDeg != null ? `${work.rollDeg.toFixed(1)}°` : "—"}
+
Угол захвата
+
{work.captureAngleDeg != null ? `${work.captureAngleDeg.toFixed(1)}°` : "—"}
+
Центр контура
+
+ {work.centerLat != null && work.centerLon != null + ? `${work.centerLat.toFixed(2)}, ${work.centerLon.toFixed(2)}` + : "—"} +
+ + )} + {work.kind === "downlink" && ( + <> +
Станция
+
{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}
+ + )} +
+
+ + {work.kind === "shoot" && ( +
+
Задания под маршрутом
+
Привязка заданий к маршруту появится позже.
+
+ )} + + {work.kind === "downlink" && ( +
+
Сбрасываемые съёмки
+ {work.surveyIds && work.surveyIds.length > 0 ? ( +
    + {work.surveyIds.map((surveyId) => ( +
  • {surveyModeLabel ? surveyModeLabel(surveyId) : `Маршрут #${surveyId}`}
  • + ))} +
+ ) : ( +
Маршруты съёмки к сбросу не привязаны.
+ )} +
+ )} + {work.kind === "shoot" && (
Режим аппаратуры
@@ -183,20 +241,16 @@ export function WorkInspector({
)} - {work.kind === "downlink" && ( + {work.kind === "downlink" && !readOnly && (
Станция приёма
- {readOnly ? ( -
{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}
- ) : ( - - )} +
)} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index a3bfedd..1530538 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -453,7 +453,12 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, endMs, label: mode?.label ?? "Съёмка", modeId, - target + target, + revolution: surveyRoute?.revolution ?? selectedShootPass.revolution, + rollDeg: surveyRoute?.roll ?? routeRoll, + captureAngleDeg: surveyRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, + centerLat: surveyRoute?.lat, + centerLon: surveyRoute?.longitude }) ); setSelectedWorkId(id); @@ -468,6 +473,17 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, [stationDtos] ); + // Метка съёмки по id режима — для перечня сбрасываемых маршрутов в инспекторе сброса. + const surveyModeLabel = useCallback( + (surveyModeId: number) => { + const mode = planModes.find( + (m): m is PlanSurveyMode => m.type === "SURVEY" && m.id === surveyModeId + ); + return mode ? `Съёмка · виток ${mode.revolution}` : `Маршрут #${surveyModeId}`; + }, + [planModes] + ); + const addDropWork = async () => { if (!selectedPlan || !selectedZone || selectedDropSurveyIds.size === 0) return; const numericNorad = noradId ? Number(noradId) : NaN; @@ -735,6 +751,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, work={selectedWork} modes={modes} stations={stations} + spacecraftLabel={activeLabel} + surveyModeLabel={surveyModeLabel} conflicts={conflicts[selectedWork.id] ?? []} readOnly={selectedWork.origin === "plan"} onUpdate={onChangeWork} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index eedaae2..4eb494e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -88,6 +88,11 @@ describe("editorDraft", () => { startMs: parseUtc("2026-05-29T01:10:00Z"), endMs: parseUtc("2026-05-29T01:10:00Z") + 180_000, origin: "plan", + planModeId: 11, + revolution: 4321, + rollDeg: 0, + centerLat: 55.7, + centerLon: 37.6, targetLat: 55.7, targetLon: 37.6 }, @@ -98,7 +103,10 @@ describe("editorDraft", () => { startMs: parseUtc("2026-05-29T01:40:00Z"), endMs: parseUtc("2026-05-29T01:40:00Z") + 120_000, origin: "plan", - stationId: "102" + planModeId: 22, + revolution: 4322, + stationId: "102", + surveyIds: [11] } ]); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index d3b1b4e..54179fe 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -22,6 +22,12 @@ export type AddShootInput = { label: string; modeId?: string; target: EditorTarget; + /** Параметры построенного маршрута (если съёмка строилась с превью). */ + revolution?: number; + rollDeg?: number; + captureAngleDeg?: number; + centerLat?: number; + centerLon?: number; }; export type AddDownlinkInput = { @@ -66,6 +72,12 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] { startMs, endMs, origin: "plan", + planModeId: mode.id, + revolution: mode.revolution, + rollDeg: mode.roll, + centerLat: mode.lat, + centerLon: mode.longitude, + // Центр контура — он же опорная точка для проверки видимости. targetLat: mode.lat, targetLon: mode.longitude }]; @@ -78,7 +90,10 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] { startMs, endMs, origin: "plan", - stationId: mode.station ?? undefined + planModeId: mode.id, + revolution: mode.revolution, + stationId: mode.station ?? undefined, + surveyIds: mode.surveys }]; }); } @@ -136,6 +151,11 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft { targetName: input.target.name, targetLat: input.target.lat, targetLon: input.target.lon, + revolution: input.revolution, + rollDeg: input.rollDeg, + captureAngleDeg: input.captureAngleDeg, + centerLat: input.centerLat, + centerLon: input.centerLon, origin: "added", isNew: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts index c1bfa97..2a69a9d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts @@ -20,11 +20,28 @@ export type EditorShootWork = EditorWorkBase & { targetName?: string; targetLat?: number; targetLon?: number; + /** Идентификатор сохранённого режима съёмки в БД (есть у включений плана). */ + planModeId?: number; + /** Номер витка. */ + revolution?: number; + /** Крен (gamma), град. */ + rollDeg?: number; + /** Угол захвата (полуширина полосы), град. Есть только у свежепостроенного маршрута. */ + captureAngleDeg?: number; + /** Центр контура съёмки, град. */ + centerLat?: number; + centerLon?: number; }; export type EditorDownlinkWork = EditorWorkBase & { kind: "downlink"; stationId?: string; + /** Идентификатор сохранённого режима сброса в БД (есть у включений плана). */ + planModeId?: number; + /** Номер витка зоны радиовидимости. */ + revolution?: number; + /** Идентификаторы режимов съёмки (survey_modes), сбрасываемых в этом сеансе. */ + surveyIds?: number[]; }; export type EditorWork = EditorShootWork | EditorDownlinkWork; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 01f341e..0b8a24a 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -47,6 +47,15 @@ font-size: 0.68rem; } +.tgu-editor-panel__list { + margin: 0; + padding-left: 1.1rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.72rem; +} + .tgu-editor-toolbar__group { display: flex; gap: 0.35rem; From 0d69617420afa7e4851594ebb0640b0561686101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 12:53:57 +0300 Subject: [PATCH 36/79] =?UTF-8?q?pcp:=20=D0=A42=20=E2=80=94=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA-=D0=BA=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=20=D0=BC=D0=B8=D1=81=D1=81=D0=B8=D0=B8=20(replace=20+=20fork-f?= =?UTF-8?q?orward)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mission-planing: POST /api/missions/{id}/clone — полный клон (replace) или по перекрытию с окном (fork-forward, [start,stop) открытый правый конец); пустые drop-mode (все съёмки вне окна) не клонируются tgu-service: PlannedPlanStatus.DRAFT; POST /api/plans/{planId}/draft (CreateDraftUseCase: guard replace/fork-forward, clone mission → planId=newMissionId, компенсация при сбое); DRAFT отфильтрован из PlanQueryService read-пути; MissionPlaningClient + MissionPlaningProperties + mission-planing.base-url в yaml тесты: MissionCloneTest (6 граничных кейсов предиката перекрытия), PlanQueryServiceTest (DRAFT не видна в ответе) --- config-repo/pcp-tgu-service.yaml | 4 + ...TASK_tgu-plan-versioning-manual-reissue.md | 10 +- .../controller/MissionCloneDTOs.kt | 15 + .../controller/MissionController.kt | 12 + .../service/MissionPlaningService.kt | 114 ++++++- .../service/MissionCloneTest.kt | 280 ++++++++++++++++++ .../config/MissionPlaningProperties.kt | 9 + .../controller/PlanController.kt | 21 +- .../nstart/pcp_tgu_service/dto/DraftDTOs.kt | 17 ++ .../entity/PlannedPlanStatus.kt | 7 + .../integration/api/MissionPlaningClient.kt | 41 +++ .../service/CreateDraftUseCase.kt | 205 +++++++++++++ .../service/PlanQueryService.kt | 5 +- .../src/main/resources/application.yml | 3 + .../service/PlanQueryServiceTest.kt | 17 ++ 15 files changed, 754 insertions(+), 6 deletions(-) create mode 100644 services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt create mode 100644 services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 46208ed..9aec2ab 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -97,6 +97,10 @@ spring: external-api: base-url: ${pcp.services.ballistics} + +mission-planing: + base-url: ${pcp.services.mission} + server: port: ${pcp.ports.tgu:7011} diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 7e3f0f7..3007b87 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -231,8 +231,16 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** будущий не-`LAID_IN` план > порога → attempt создаётся, BPMN стартует; `now ≥ startTime−10мин` → отказ; `LAID_IN` → отказ; активная attempt по КА → отказ. - **Тесты:** unit-таблица граничных случаев (время × статус) по образцу `IssueDuePlanWorkerOnDemandTest`. -### Фаза 2 — Черновик-клон (replace + fork-forward) +### Фаза 2 — Черновик-клон (replace + fork-forward) — ✅ СДЕЛАНО **Модель: Sonnet+ревью Opus** (механика clone простая, но атомарность двух осей смотрит Opus). + +> **Реализовано:** +> - `POST /api/missions/{id}/clone` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt): replace — полный клон; fork-forward — только включения, перекрывающиеся с окном `(modeStop > windowStart && modeStart < windowStop)`. DropMode клонируется только если хотя бы одна из его съёмок вошла в клон (иначе пустой drop пропускается). Перекрытие: открытый правый конец `[start, stop)`. +> - `PlannedPlanStatus.DRAFT` в [PlannedPlanStatus](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt) — персистируется, не входит в проекцию и read-путь. +> - `POST /api/plans/{planId}/draft` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt): создаёт DRAFT-план с `planId = newMissionId` (инвариант planId==missionId сохраняется); при сбоге save — компенсация (delete клон-миссии). Для fork-forward валидирует, что окно есть в проекции КА. +> - DRAFT отфильтрован в [PlanQueryService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) (оба метода). +> - [MissionPlaningClient](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt) + `MissionPlaningProperties` + `mission-planing.base-url` в yaml. +> - Тесты: 6 кейсов в `MissionCloneTest` (граничные случаи предиката перекрытия); 1 кейс в `PlanQueryServiceTest` (DRAFT не виден). Оба модуля зелёные. - `pcp-mission-planing-service`: `POST /api/missions/{id}/clone` body `{ mode, windowStart?, windowStop? }` → `{ newMissionId }`. `replace` — полный клон `Plan`+`Mode`; `fork-forward` — только `Mode`, пересёкшиеся с окном `[start,stop)`. Валидировать, что окно `fork-forward` = существующая будущая ЗРВ. - `pcp-tgu-service`: `POST /api/plans/{planId}/draft` body `{ mode, windowStart?, windowStop? }` → создаёт `planned_plan(DRAFT, base_plan_id)`, триггерит clone миссии, возвращает `{ draftPlanId }`. **Атомарность:** оба создания координируются; при сбое clone — компенсация (не оставлять осиротевший `planned_plan`). - `PlannedPlanStatus.DRAFT`; [PlanProjectionService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt) и `PlanQueryService` — `DRAFT` не голова цепочки и не в проекции. diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt new file mode 100644 index 0000000..9e0573c --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import java.time.LocalDateTime +import java.util.UUID + +data class MissionCloneRequest( + /** "replace" — полный клон; "fork-forward" — только включения, перекрывающиеся с окном. */ + val mode: String, + /** Начало окна нового плана (UTC naive). Обязательно для fork-forward. */ + val windowStart: LocalDateTime? = null, + /** Конец окна нового плана (UTC naive). Обязательно для fork-forward. */ + val windowStop: LocalDateTime? = null +) + +data class MissionCloneResponse(val newMissionId: UUID) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index 241e79c..defe2fc 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -228,4 +228,16 @@ class MissionController( missionPlaningService.delete(id) return ResponseEntity.noContent().build() } + + @Operation( + summary = "Клонировать миссию", + description = "Создаёт полный (replace) или частичный (fork-forward) клон миссии с новым missionId. " + + "Для fork-forward клонируются только включения, перекрывающиеся с окном [windowStart, windowStop). " + + "Возвращает newMissionId, который tgu-service использует как planId черновика." + ) + @PostMapping("/{mission_id}/clone") + fun cloneMission( + @PathVariable("mission_id") id: UUID, + @RequestBody body: MissionCloneRequest + ): MissionCloneResponse = missionPlaningService.cloneMission(id, body) } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index cfd8185..616a024 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -29,6 +29,8 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDT import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity @@ -125,6 +127,112 @@ class MissionPlaningService( missionRepository.deleteById(id) } + /** + * Клонирует миссию и все её включения в новый `missionId`. + * + * Режим `replace` — полный глубокий клон: Mission + Plan + все Mode (SurveyMode/DropMode). + * Режим `fork-forward` — клонирует только включения, чей интервал `[timeStart, timeStart+duration)` + * пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна + * из его связанных съёмок тоже попала в клон (иначе сброс становится пустым). + * + * Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service + * (инвариант planId == missionId сохраняется). + */ + @Transactional + fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse { + val isForkForward = req.mode == "fork-forward" + if (isForkForward && (req.windowStart == null || req.windowStop == null)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "windowStart and windowStop are required for fork-forward clone" + ) + } + + val source = missionRepository.findById(sourceMissionId).orElseThrow { notFound(sourceMissionId) } + val allModes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(sourceMissionId) + + val modesToClone: List = if (isForkForward) { + allModes.filter { mode -> mode.intersects(req.windowStart!!, req.windowStop!!) } + } else { + allModes + } + + val (newMissionStart, newMissionStop) = if (isForkForward) { + req.windowStart!! to req.windowStop!! + } else { + source.missionStart to source.missionStop + } + + val newMission = missionRepository.save( + MissionEntity( + satelliteId = source.satelliteId, + station = source.station, + missionStart = newMissionStart, + missionStop = newMissionStop, + status = "NEW" + ) + ) + + val newPlan = planRepository.save( + PlanEntity( + mission = newMission, + planStart = newMissionStart, + planStop = newMissionStop, + planNumber = 1 + ) + ) + + // Клонируем съёмки и строим маппинг старый id → новая сущность для перевязки сбросов. + val surveyIdMapping = mutableMapOf() + modesToClone.filterIsInstance().forEach { src -> + val cloned = surveyModeRepository.save( + SurveyModeEntity( + plan = newPlan, + timeStart = src.timeStart, + revolution = src.revolution, + lat = src.lat, + long = src.long, + duration = src.duration, + contourWkt = src.contourWkt, + roll = src.roll, + status = SurveyModeStatus.PLANNED, + source = src.source + ) + ) + src.id?.let { surveyIdMapping[it] = cloned } + } + + // Клонируем сбросы; пропускаем если все их связанные съёмки вышли за окно (пустой сброс). + modesToClone.filterIsInstance().forEach { src -> + val srcId = src.id ?: return@forEach + val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(srcId) + val remappedSurveys = links.mapNotNull { link -> link.surveyMode?.id?.let { surveyIdMapping[it] } } + if (remappedSurveys.isEmpty()) { + return@forEach + } + val newDrop = dropModeRepository.save( + DropModeEntity( + plan = newPlan, + timeStart = src.timeStart, + revolution = src.revolution, + station = src.station, + duration = src.duration + ) + ) + remappedSurveys.forEach { survey -> + dropModeSurveyRepository.save(DropModeSurveyEntity(dropMode = newDrop, surveyMode = survey)) + } + } + + val clonedDropCount = modesToClone.count { it is DropModeEntity } + logger.info( + "Клонирована миссия {}: mode={}, новый id={}, съёмок={}, сбросов={}", + sourceMissionId, req.mode, newMission.missionId, surveyIdMapping.size, clonedDropCount + ) + + return MissionCloneResponse(newMissionId = newMission.missionId) + } + fun modesByMission(id: UUID): List { if (!missionRepository.existsById(id)) { throw notFound(id) @@ -199,10 +307,14 @@ class MissionPlaningService( private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + /** + * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт). + * Применяется при fork-forward-клоне: включения строго вне окна не клонируются. + */ private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { val modeStart = timeStart?.toUtcLocalDateTime() ?: return false val modeStop = modeStop() ?: return false - return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop) + return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt new file mode 100644 index 0000000..ef6f9ea --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -0,0 +1,280 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class MissionCloneTest { + + private val missionStore = linkedMapOf() + private val planStore = linkedMapOf() + private val surveyStore = linkedMapOf() + private val dropStore = linkedMapOf() + private val dropSurveyStore = mutableListOf() + + private var missionAutoId = 1L + private var planAutoId = 100L + private var modeAutoId = 10L + + private val missionRepository = mock(MissionRepository::class.java).also { repo -> + `when`(repo.findById(anyUUID())).thenAnswer { inv -> + Optional.ofNullable(missionStore[inv.arguments[0]]) + } + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val m = inv.arguments[0] as MissionEntity + missionStore[m.missionId] = m + m + } + } + + private val planRepository = mock(PlanRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val p = inv.arguments[0] as PlanEntity + val id = ++planAutoId + val saved = PlanEntity(id = id, mission = p.mission, planNumber = p.planNumber, planStart = p.planStart, planStop = p.planStop) + planStore[id] = saved + saved + } + } + + private val modeRepository = mock(ModeRepository::class.java).also { repo -> + `when`(repo.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(anyUUID())).thenAnswer { inv -> + val missionId = inv.arguments[0] as UUID + (surveyStore.values.filter { it.plan?.mission?.missionId == missionId } + + dropStore.values.filter { it.plan?.mission?.missionId == missionId }) + } + } + + private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val s = inv.arguments[0] as SurveyModeEntity + val id = ++modeAutoId + val saved = SurveyModeEntity(id = id, plan = s.plan, timeStart = s.timeStart, revolution = s.revolution, + lat = s.lat, long = s.long, duration = s.duration, contourWkt = s.contourWkt, roll = s.roll, + status = s.status, source = s.source) + surveyStore[id] = saved + saved + } + } + + private val dropModeRepository = mock(DropModeRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val d = inv.arguments[0] as DropModeEntity + val id = ++modeAutoId + val saved = DropModeEntity(id = id, plan = d.plan, timeStart = d.timeStart, revolution = d.revolution, + station = d.station, duration = d.duration) + dropStore[id] = saved + saved + } + } + + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo -> + `when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv -> + val dropId = inv.arguments[0] as Long + dropSurveyStore.filter { it.dropMode?.id == dropId } + } + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val link = inv.arguments[0] as DropModeSurveyEntity + dropSurveyStore.add(link) + link + } + } + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = planRepository, + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = mock(ModeBookingRepository::class.java), + bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java), + ballisticsService = mock(BallisticsService::class.java), + complexMissionService = mock(ComplexMissionService::class.java), + satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java), + satelliteMissionTypeFactory = SatelliteMissionTypeFactory( + listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()), + DefaultSatelliteMissionType() + ) + ) + + // --- replace: полный клон --- + + @Test + fun `replace clone copies all survey and drop modes with remapped survey links`() { + val (missionId, _, survey1, survey2, drop) = buildSourceMission(t(1), t(5)) + addDropLink(drop, survey1) + addDropLink(drop, survey2) + + val result = service.cloneMission(missionId, MissionCloneRequest(mode = "replace")) + + val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, clonedSurveys.size) + assertEquals(1, clonedDrops.size) + // Ссылки переведены на новые survey IDs + val clonedLinks = dropSurveyStore.filter { it.dropMode?.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, clonedLinks.size) + assertTrue(clonedLinks.all { it.surveyMode?.plan?.mission?.missionId == result.newMissionId }) + } + + // --- fork-forward: фильтрация по окну --- + + @Test + fun `fork-forward clone includes mode that starts before window but ends inside`() { + // Включение начинается за 5 мин до окна и заканчивается внутри → пересекается. + // buildSourceMission создаёт 2 survey (оба перекрывают окно) + 1 drop. + val (missionId) = buildSourceMission(t(0).minusMinutes(5), t(0).plusMinutes(10)) + val result = service.cloneMission( + missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, cloned.size) + } + + @Test + fun `fork-forward clone excludes mode whose stop equals window start (closed left is open right semantics)`() { + // Включение заканчивается ровно в windowStart → не пересекается (modeStop > windowStart is false). + // Явно создаём одну съёмку длительностью 30 минут, заканчивающуюся в t(0). + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + // duration 1800s = 30 min; начало: t(0) - 30min = строго до окна, конец ровно в t(0). + survey(plan, t(0).minusMinutes(30), duration = 1800.0) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(cloned.isEmpty()) + } + + @Test + fun `fork-forward clone excludes mode starting exactly at window stop (open upper bound)`() { + // Включение начинается ровно в windowStop → не пересекается (open right bound [start, stop)). + // Явно создаём одну съёмку: start = t(1) (= windowStop), duration 10min. + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + survey(plan, t(1), duration = 600.0) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(cloned.isEmpty()) + } + + @Test + fun `fork-forward drops drop mode when all its surveys are outside window`() { + // Две съёмки вне окна; сброс на них тоже не клонируется (пустой drop). + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val sourcePlan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[sourcePlan.id!!] = sourcePlan + val outSurvey = survey(sourcePlan, start = t(0).minusHours(2), duration = 60.0) + val drop = drop(sourcePlan, start = t(0).minusMinutes(30)) + addDropLink(drop, outSurvey) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2)) + ) + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(clonedDrops.isEmpty()) + } + + @Test + fun `fork-forward requires windowStart and windowStop`() { + val (missionId) = buildSourceMission(t(0), t(1)) + assertThrows { + service.cloneMission(missionId, MissionCloneRequest(mode = "fork-forward")) + } + } + + // --- helpers --- + + data class SourceFixture( + val missionId: UUID, + val plan: PlanEntity, + val survey1: SurveyModeEntity, + val survey2: SurveyModeEntity, + val drop: DropModeEntity + ) + + private fun buildSourceMission(surveyStart: LocalDateTime, surveyStop: LocalDateTime): SourceFixture { + val mission = MissionEntity(satelliteId = 100) + missionStore[mission.missionId] = mission + val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1) + planStore[plan.id!!] = plan + val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0 + val s1 = survey(plan, surveyStart.atOffset(ZoneOffset.UTC), duration) + val s2 = survey(plan, surveyStart.plusMinutes(1).atOffset(ZoneOffset.UTC), duration) + val d = drop(plan, surveyStart.plusMinutes(5)) + return SourceFixture(mission.missionId, plan, s1, s2, d) + } + + private fun survey(plan: PlanEntity, startOffset: OffsetDateTime, duration: Double = 300.0): SurveyModeEntity { + val id = ++modeAutoId + val s = SurveyModeEntity(id = id, plan = plan, timeStart = startOffset, revolution = id, + lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL, + status = SurveyModeStatus.PLANNED) + surveyStore[id] = s + return s + } + + private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0) = + survey(plan, start.atOffset(ZoneOffset.UTC), duration) + + private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity { + val id = ++modeAutoId + val d = DropModeEntity(id = id, plan = plan, timeStart = start.atOffset(ZoneOffset.UTC), + revolution = id, station = "102", duration = 60.0) + dropStore[id] = d + return d + } + + private fun addDropLink(drop: DropModeEntity, survey: SurveyModeEntity) { + dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey)) + } + + private fun t(hour: Int): LocalDateTime = LocalDateTime.of(2026, 6, 10, hour, 0, 0) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { org.mockito.ArgumentMatchers.any(); return null as T } + private fun anyUUID(): UUID { org.mockito.ArgumentMatchers.any(); return UUID.randomUUID() } + private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt new file mode 100644 index 0000000..7b6d145 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "mission-planing") +data class MissionPlaningProperties( + /** Базовый URL pcp-mission-planing-service. */ + val baseUrl: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 6439b41..35d79bb 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -1,14 +1,18 @@ package space.nstart.pcp_tgu_service.controller -/** Содержит методы API для чтения текущих планов и ручной выдачи плана. */ +/** Содержит методы API для чтения текущих планов, создания черновика и ручной выдачи плана. */ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.CreateDraftResponse import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.service.CreateDraftUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker import space.nstart.pcp_tgu_service.service.PlanQueryService import java.util.UUID @@ -17,7 +21,8 @@ import java.util.UUID @RequestMapping("/api/plans") class PlanController( private val planQueryService: PlanQueryService, - private val issueDuePlanWorker: IssueDuePlanWorker + private val issueDuePlanWorker: IssueDuePlanWorker, + private val createDraftUseCase: CreateDraftUseCase ) { /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @@ -33,6 +38,18 @@ class PlanController( ): List = planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) + /** + * Создаёт черновик плана (DRAFT) на основе basePlanId. + * Режим replace — черновик в том же слоте (база ещё правима). + * Режим fork-forward — черновик на следующей ЗРВ (база невозвратна). + * Возвращает draftPlanId, с которым работают Ф3–Ф5. + */ + @PostMapping("/{planId}/draft") + fun createDraft( + @PathVariable planId: UUID, + @RequestBody body: CreateDraftRequest + ): CreateDraftResponse = createDraftUseCase.createDraft(planId, body) + /** * Ручная выдача плана оператором («Применить / Отправить вручную»). Стартует новую attempt * и Camunda-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt new file mode 100644 index 0000000..0b02827 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +data class CreateDraftRequest( + /** "replace" — черновик в том же слоте; "fork-forward" — черновик на следующей ЗРВ. */ + val mode: String, + /** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ + val windowStart: LocalDateTime? = null, + /** Конец окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ + val windowStop: LocalDateTime? = null, + /** КПП целевой ЗРВ. Обязательно для fork-forward. */ + val windowKppId: String? = null +) + +data class CreateDraftResponse(val draftPlanId: UUID) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt index a974961..622384d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt @@ -6,6 +6,13 @@ package space.nstart.pcp_tgu_service.entity enum class PlannedPlanStatus { /** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */ PLANNED, + + /** + * Черновик: план в стадии правки оператором. Персистируется, но НЕ является головой цепочки + * и НЕ попадает в проекцию/read-путь как запланированный слот. Не выдаётся авто-воркером. + * Становится активным только после promote (Ф5) — смена статуса на PLANNED/ISSUING. + */ + DRAFT, ISSUING, WAITING_DECISION, ACCEPTED, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt new file mode 100644 index 0000000..4e3f963 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt @@ -0,0 +1,41 @@ +package space.nstart.pcp_tgu_service.integration.api + +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp_tgu_service.config.MissionPlaningProperties +import java.time.LocalDateTime +import java.util.UUID + +data class MissionCloneRequest( + val mode: String, + val windowStart: LocalDateTime? = null, + val windowStop: LocalDateTime? = null +) + +data class MissionCloneResponse(val newMissionId: UUID) + +@Component +class MissionPlaningClient(properties: MissionPlaningProperties) { + + private val webClient: WebClient = WebClient.builder() + .baseUrl(properties.baseUrl) + .build() + + fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse = + webClient.post() + .uri("/api/missions/{id}/clone", missionId) + .bodyValue(req) + .retrieve() + .bodyToMono(MissionCloneResponse::class.java) + .block() + ?: throw IllegalStateException("Empty response from mission-planing clone for missionId=$missionId") + + /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. */ + fun deleteMission(missionId: UUID) { + webClient.delete() + .uri("/api/missions/{id}", missionId) + .retrieve() + .toBodilessEntity() + .block() + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt new file mode 100644 index 0000000..44c3673 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -0,0 +1,205 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.CreateDraftResponse +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.UUID + +/** + * Создаёт черновик плана (DRAFT) на основе существующего базового плана. + * + * Режим **replace**: базовый план ещё правим (до точки невозврата) — черновик наследует + * его окно (startTime/endTime/kppId) и предзаполняется клоном его миссии целиком. + * + * Режим **fork-forward**: базовый план невозвратен (заложен или прошло время) — черновик + * создаётся на выбранной оператором будущей ЗРВ; клон миссии содержит только включения, + * пересёкшиеся с окном новой ЗРВ. + * + * Атомарность: DRAFT-план создаётся только после успешного клона миссии; при сбое клона + * план не сохраняется — осиротевших строк нет. Если сохранение плана падает после клона — + * выполняется компенсация (удаление клона миссии). + */ +@Service +class CreateDraftUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val planProjectionService: PlanProjectionService, + private val missionPlaningClient: MissionPlaningClient, + private val planningProperties: PlanningProperties +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun createDraft(basePlanId: UUID, req: CreateDraftRequest): CreateDraftResponse { + val base = plannedPlanRepository.findById(basePlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") } + + val now = LocalDateTime.now(ZoneOffset.UTC) + + return when (req.mode) { + "replace" -> createReplaceDraft(base, now) + "fork-forward" -> createForkForwardDraft(base, req, now) + else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown draft mode: ${req.mode}") + } + } + + private fun createReplaceDraft(base: PlannedPlanEntity, now: LocalDateTime): CreateDraftResponse { + if (base.status == PlannedPlanStatus.DRAFT) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Нельзя создать черновик от черновика") + } + if (base.status == PlannedPlanStatus.LAID_IN) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План ${base.planId} заложен (LAID_IN) — используйте fork-forward" + ) + } + val freezeAt = base.startTime.minusMinutes(planningProperties.layinFreezeMinutes) + if (!now.isBefore(freezeAt)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "До старта плана ${base.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин " + + "— окно выдачи заморожено, используйте fork-forward" + ) + } + + val cloneResponse = missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest(mode = "replace") + ) + + return saveDraftWithCompensation( + draftPlanId = cloneResponse.newMissionId, + base = base, + startTime = base.startTime, + endTime = base.endTime, + kppId = base.kppId, + clonedMissionId = cloneResponse.newMissionId + ) + } + + private fun createForkForwardDraft( + base: PlannedPlanEntity, + req: CreateDraftRequest, + now: LocalDateTime + ): CreateDraftResponse { + val windowStart = req.windowStart + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowStart обязателен для fork-forward") + val windowStop = req.windowStop + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowStop обязателен для fork-forward") + val windowKppId = req.windowKppId + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowKppId обязателен для fork-forward") + + if (!windowStart.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStart ($windowStart) должен быть строго в будущем" + ) + } + if (!windowStop.isAfter(windowStart)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStop должен быть позже windowStart" + ) + } + + // Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции. + assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId) + + val cloneResponse = missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest(mode = "fork-forward", windowStart = windowStart, windowStop = windowStop) + ) + + return saveDraftWithCompensation( + draftPlanId = cloneResponse.newMissionId, + base = base, + startTime = windowStart, + endTime = windowStop, + kppId = windowKppId, + clonedMissionId = cloneResponse.newMissionId + ) + } + + /** + * Проверяет, что тройка (windowStart, windowStop, kppId) соответствует хотя бы одному + * плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность + * сериализации ISO-строк на фронте). + */ + private fun assertWindowMatchesProjection( + spacecraftId: String, + windowStart: LocalDateTime, + windowStop: LocalDateTime, + kppId: String + ) { + val projection = planProjectionService.project(spacecraftId) + val matched = projection.any { plan -> + plan.kppId == kppId && + diffSeconds(plan.startTime, windowStart) < 1 && + diffSeconds(plan.endTime, windowStop) < 1 + } + if (!matched) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно [$windowStart – $windowStop, kpp=$kppId] не соответствует ни одной будущей ЗРВ из проекции КА $spacecraftId" + ) + } + } + + private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = + Math.abs(java.time.Duration.between(a, b).toSeconds()) + + /** + * Сохраняет DRAFT-план с переданным planId (= clonedMissionId, инвариант planId==missionId). + * При сбое сохранения логирует компенсацию; удаление клона миссии — best-effort (сбой логируется). + */ + private fun saveDraftWithCompensation( + draftPlanId: UUID, + base: PlannedPlanEntity, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String, + clonedMissionId: UUID + ): CreateDraftResponse { + return try { + val draft = plannedPlanRepository.save( + PlannedPlanEntity( + planId = draftPlanId, + spacecraftId = base.spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + status = PlannedPlanStatus.DRAFT, + basePlanId = base.planId + ) + ) + log.info( + "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}", + draft.planId, base.planId, base.spacecraftId + ) + CreateDraftResponse(draftPlanId = draft.planId) + } catch (ex: Exception) { + log.error( + "Сбой сохранения DRAFT-плана {} после клона миссии {}; попытка компенсации (удалить клон миссии)", + draftPlanId, clonedMissionId, ex + ) + try { + missionPlaningClient.deleteMission(clonedMissionId) + } catch (compEx: Exception) { + log.error( + "Компенсация не удалась: клон миссии {} остался осиротевшим", + clonedMissionId, compEx + ) + } + throw ex + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index 4a2da61..eb69581 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -7,6 +7,7 @@ import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime @@ -28,7 +29,7 @@ class PlanQueryService( fun getAllPlans(historyDays: Long): List { val historySince = historySince(historyDays) val persisted = plannedPlanRepository.findAllByOrderByStartTime() - .filter { plan -> plan.endTime >= historySince } + .filter { plan -> plan.endTime >= historySince && plan.status != PlannedPlanStatus.DRAFT } val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } val projected = spacecraftSnapshotRepository.findAllByActiveTrue() @@ -43,7 +44,7 @@ class PlanQueryService( fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { val historySince = historySince(historyDays) val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) - .filter { plan -> plan.endTime >= historySince } + .filter { plan -> plan.endTime >= historySince && plan.status != PlannedPlanStatus.DRAFT } val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } val projected = planProjectionService.project(spacecraftId) diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index da1e015..de0966c 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -34,6 +34,9 @@ topics: plan-not-accepted: pcp.tgu.plan-not-accepted.v1 station-params-changed: pcp.tgu.station-params-changed.v1 +mission-planing: + base-url: ${pcp.services.mission:http://localhost:7010} + tgu: test-controller: enabled: false diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index 355b86b..9adb4e7 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -63,6 +63,23 @@ class PlanQueryServiceTest { assertTrue(result.isEmpty()) } + @Test + fun `getAllPlans excludes DRAFT plans from the result`() { + val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT) + val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(emptyList()) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertEquals(1, result.size) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status) + } + private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(), From 85ce238f52cec292daf0145d0fa01222519f2403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:09:52 +0300 Subject: [PATCH 37/79] =?UTF-8?q?pcp:=20=D0=A42=20=E2=80=94=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(=D0=B2=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=BA=D0=BB=D0=BE=D0=BD=D0=B0,=20=D0=B8=D0=B4=D0=B5?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20DRAFT,=20=D1=82=D0=B5=D1=81=D1=82=D1=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MissionPlaningClient: маппинг ошибок mission-planing (4xx как есть, 5xx → 502) + таймаут 30с - CreateDraftUseCase: гард идемпотентности — второй открытый DRAFT на тот же план → 409 - cloneMission: явная валидация mode (неизвестный → 400 вместо тихого полного клона) - константы DraftMode/CloneMode вместо строковых литералов - kdoc: семантика fork-forward (границы-слот vs времена включений; съёмка без сброса) - тесты: CreateDraftUseCaseTest (13 кейсов), MissionCloneTest (+съёмка без сброса) --- .../controller/MissionCloneDTOs.kt | 8 +- .../service/MissionPlaningService.kt | 13 +- .../service/MissionCloneTest.kt | 22 ++ .../nstart/pcp_tgu_service/dto/DraftDTOs.kt | 8 +- .../integration/api/MissionPlaningClient.kt | 25 +- .../repository/PlannedPlanRepository.kt | 4 + .../service/CreateDraftUseCase.kt | 21 +- .../service/CreateDraftUseCaseTest.kt | 257 ++++++++++++++++++ 8 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt index 9e0573c..9c942aa 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt @@ -3,8 +3,14 @@ package space.nstart.pcp.pcp_mission_planing_service.controller import java.time.LocalDateTime import java.util.UUID +/** Допустимые значения [MissionCloneRequest.mode]. */ +object CloneMode { + const val REPLACE = "replace" + const val FORK_FORWARD = "fork-forward" +} + data class MissionCloneRequest( - /** "replace" — полный клон; "fork-forward" — только включения, перекрывающиеся с окном. */ + /** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */ val mode: String, /** Начало окна нового плана (UTC naive). Обязательно для fork-forward. */ val windowStart: LocalDateTime? = null, diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 616a024..1076648 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -29,6 +29,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDT import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity @@ -135,12 +136,22 @@ class MissionPlaningService( * пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна * из его связанных съёмок тоже попала в клон (иначе сброс становится пустым). * + * Намеренные особенности fork-forward: + * - Времена включений переносятся как есть. Включение, начавшееся до `windowStart` и закончившееся + * внутри окна, клонируется со своим исходным `timeStart` — он может быть раньше `missionStart` + * клона. Границы миссии описывают слот целевой ЗРВ, а не оболочку времён её включений. + * - Съёмка переносится, даже если её сброс не попал в окно: сброс будет перевыдан в новом плане. + * Обратное неверно — сброс без единой перенесённой съёмки отбрасывается. + * * Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service * (инвариант planId == missionId сохраняется). */ @Transactional fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse { - val isForkForward = req.mode == "fork-forward" + if (req.mode != CloneMode.REPLACE && req.mode != CloneMode.FORK_FORWARD) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown clone mode: ${req.mode}") + } + val isForkForward = req.mode == CloneMode.FORK_FORWARD if (isForkForward && (req.windowStart == null || req.windowStop == null)) { throw ResponseStatusException( HttpStatus.BAD_REQUEST, diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt index ef6f9ea..e0fcb57 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -217,6 +217,28 @@ class MissionCloneTest { assertTrue(clonedDrops.isEmpty()) } + @Test + fun `fork-forward keeps a survey even when its drop falls outside the window`() { + // Съёмка внутри окна [t(1), t(2)), но её сброс — до окна. Сброс не клонируется, + // съёмка остаётся (будет пересброшена в новом плане) — намеренная семантика fork-forward. + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + val inSurvey = survey(plan, start = t(1).plusMinutes(10), duration = 600.0) + val outDrop = drop(plan, start = t(0)) + addDropLink(outDrop, inSurvey) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2)) + ) + val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(1, clonedSurveys.size) + assertTrue(clonedDrops.isEmpty()) + } + @Test fun `fork-forward requires windowStart and windowStop`() { val (missionId) = buildSourceMission(t(0), t(1)) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt index 0b02827..daaacc2 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt @@ -3,8 +3,14 @@ package space.nstart.pcp_tgu_service.dto import java.time.LocalDateTime import java.util.UUID +/** Допустимые значения [CreateDraftRequest.mode]; та же строка уходит на mission-planing. */ +object DraftMode { + const val REPLACE = "replace" + const val FORK_FORWARD = "fork-forward" +} + data class CreateDraftRequest( - /** "replace" — черновик в том же слоте; "fork-forward" — черновик на следующей ЗРВ. */ + /** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */ val mode: String, /** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ val windowStart: LocalDateTime? = null, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt index 4e3f963..dd2f115 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt @@ -1,8 +1,11 @@ package space.nstart.pcp_tgu_service.integration.api +import org.springframework.http.HttpStatus import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.MissionPlaningProperties +import java.time.Duration import java.time.LocalDateTime import java.util.UUID @@ -21,21 +24,35 @@ class MissionPlaningClient(properties: MissionPlaningProperties) { .baseUrl(properties.baseUrl) .build() + private val timeout: Duration = Duration.ofSeconds(30) + fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse = webClient.post() .uri("/api/missions/{id}/clone", missionId) .bodyValue(req) .retrieve() + .onStatus({ it.isError }) { resp -> + resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body -> + // 4xx апстрима пробрасываем как есть (404 — нет базовой миссии и т.п.), + // 5xx сворачиваем в 502: проблема не в запросе оператора, а в mission-planing. + val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY + else HttpStatus.valueOf(resp.statusCode().value()) + ResponseStatusException(status, "Клон миссии $missionId не удался (${resp.statusCode()}): $body") + } + } .bodyToMono(MissionCloneResponse::class.java) - .block() - ?: throw IllegalStateException("Empty response from mission-planing clone for missionId=$missionId") + .block(timeout) + ?: throw ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "Пустой ответ mission-planing на клон миссии $missionId" + ) - /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. */ + /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */ fun deleteMission(missionId: UUID) { webClient.delete() .uri("/api/missions/{id}", missionId) .retrieve() .toBodilessEntity() - .block() + .block(timeout) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt index 62b594a..4a8abff 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.time.LocalDateTime import java.util.UUID @@ -12,6 +13,9 @@ interface PlannedPlanRepository : JpaRepository { /** Возвращает планы КА в порядке старта (для read-проекции и истории). */ fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List + /** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */ + fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List + /** Возвращает все планы в порядке старта (для read-проекции и истории). */ fun findAllByOrderByStartTime(): List diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt index 44c3673..506d027 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -7,6 +7,7 @@ import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.dto.CreateDraftRequest import space.nstart.pcp_tgu_service.dto.CreateDraftResponse +import space.nstart.pcp_tgu_service.dto.DraftMode import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest @@ -45,9 +46,21 @@ class CreateDraftUseCase( val now = LocalDateTime.now(ZoneOffset.UTC) + // Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик» + // не плодит вторую миссию-клон и вторую DRAFT-строку. + val existingDraft = plannedPlanRepository + .findAllByBasePlanIdAndStatus(base.planId, PlannedPlanStatus.DRAFT) + .firstOrNull() + if (existingDraft != null) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "У плана ${base.planId} уже есть черновик ${existingDraft.planId} — завершите или отмените его" + ) + } + return when (req.mode) { - "replace" -> createReplaceDraft(base, now) - "fork-forward" -> createForkForwardDraft(base, req, now) + DraftMode.REPLACE -> createReplaceDraft(base, now) + DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, now) else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown draft mode: ${req.mode}") } } @@ -73,7 +86,7 @@ class CreateDraftUseCase( val cloneResponse = missionPlaningClient.cloneMission( missionId = base.planId, - req = MissionCloneRequest(mode = "replace") + req = MissionCloneRequest(mode = DraftMode.REPLACE) ) return saveDraftWithCompensation( @@ -116,7 +129,7 @@ class CreateDraftUseCase( val cloneResponse = missionPlaningClient.cloneMission( missionId = base.planId, - req = MissionCloneRequest(mode = "fork-forward", windowStart = windowStart, windowStop = windowStop) + req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop) ) return saveDraftWithCompensation( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt new file mode 100644 index 0000000..6b167ff --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt @@ -0,0 +1,257 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.DraftMode +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class CreateDraftUseCaseTest { + + private val spacecraftId = "25544" + private val kppId = "KPP-1" + + // --- replace --- + + @Test + fun `replace creates DRAFT with cloned missionId as planId, base window and basePlanId`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val newMissionId = UUID.randomUUID() + val fixture = fixture(base, cloneResult = newMissionId) + + val response = fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(newMissionId, response.draftPlanId) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertEquals(base.planId, saved.basePlanId) + assertEquals(base.startTime, saved.startTime) + assertEquals(base.endTime, saved.endTime) + assertEquals(base.kppId, saved.kppId) + } + + @Test + fun `replace rejects a DRAFT base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `replace rejects a LAID_IN base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `replace rejects a base inside the freeze window`() { + // startTime через 5 минут, заморозка 10 минут → точка невозврата пройдена. + val base = plan(time(0).plusMinutes(5), time(3), PlannedPlanStatus.ACCEPTED) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + // --- fork-forward --- + + @Test + fun `fork-forward creates DRAFT on a window matching the projection`() { + val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN) + val windowStart = time(5) + val windowStop = time(6) + val newMissionId = UUID.randomUUID() + val fixture = fixture( + base, + cloneResult = newMissionId, + projection = listOf(CalculatedPlan(spacecraftId, windowStart, windowStop, kppId)) + ) + + val response = fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop, windowKppId = kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + val saved = fixture.lastSaved!! + assertEquals(windowStart, saved.startTime) + assertEquals(windowStop, saved.endTime) + assertEquals(kppId, saved.kppId) + assertEquals(base.planId, saved.basePlanId) + } + + @Test + fun `fork-forward requires windowStart, windowStop and windowKppId`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.FORK_FORWARD)) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + @Test + fun `fork-forward rejects a windowStart not strictly in the future`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(-1), windowStop = time(1), windowKppId = kppId) + ) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `fork-forward rejects a window that matches no future ZRV in the projection`() { + val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN) + val fixture = fixture( + base, + projection = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId)) + ) + val ex = assertThrows { + fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(5), windowStop = time(6), windowKppId = kppId) + ) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + // --- общие --- + + @Test + fun `unknown mode is rejected`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = "weird")) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + @Test + fun `unknown base plan yields 404`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createDraft(UUID.randomUUID(), CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `an existing open DRAFT on the same base yields 409`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base, existingDrafts = listOf(existing)) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + @Test + fun `a failed DRAFT save compensates by deleting the cloned mission and rethrows`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val newMissionId = UUID.randomUUID() + val fixture = fixture(base, cloneResult = newMissionId, failSave = true) + + assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + verify(fixture.client, times(1)).deleteMission(newMissionId) + } + + // --- helpers --- + + private class Fixture( + val useCase: CreateDraftUseCase, + val client: MissionPlaningClient, + val savedHolder: Array + ) { + val lastSaved: PlannedPlanEntity? get() = savedHolder[0] + } + + private fun fixture( + base: PlannedPlanEntity?, + cloneResult: UUID = UUID.randomUUID(), + projection: List = emptyList(), + existingDrafts: List = emptyList(), + failSave: Boolean = false + ): Fixture { + val repo = mock(PlannedPlanRepository::class.java) + val basePlanId = base?.planId ?: UUID.randomUUID() + `when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base)) + `when`(repo.findAllByBasePlanIdAndStatus(basePlanId, PlannedPlanStatus.DRAFT)).thenReturn(existingDrafts) + + val saved = arrayOfNulls(1) + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + if (failSave) throw RuntimeException("DB down") + val e = inv.arguments[0] as PlannedPlanEntity + saved[0] = e + e + } + + val projectionService = mock(PlanProjectionService::class.java) + `when`(projectionService.project(spacecraftId)).thenReturn(projection) + + val client = mock(MissionPlaningClient::class.java) + `when`(client.cloneMission(anyUuid(), anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) + + val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10)) + return Fixture(useCase, client, saved) + } + + private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status + ) + + private fun props(freeze: Long): PlanningProperties = + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, freeze) + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) + + // Kotlin non-null матчеры: ArgumentMatchers.any() возвращает null и роняет NPE на non-null параметрах. + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} From f5211fa5a024ac537c2e702f7862df3a43047b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:20:54 +0300 Subject: [PATCH 38/79] =?UTF-8?q?pcp-mission-planing:=20=D0=A43=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B2=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20(PATCH=20=D1=81=D1=8A?= =?UTF-8?q?=D1=91=D0=BC=D0=BA=D0=B8,=20DELETE=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=B1=D0=B5=D0=B7=20=D0=B0?= =?UTF-8?q?=D0=B2=D1=82=D0=BE-=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20=D0=BC=D0=B8=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 9 +- .../controller/MissionController.kt | 45 +++- .../repository/DropModeSurveyRepository.kt | 3 + .../service/MissionPlaningService.kt | 160 ++++++++---- .../service/MissionEditModesTest.kt | 236 ++++++++++++++++++ 5 files changed, 397 insertions(+), 56 deletions(-) create mode 100644 services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 3007b87..a33f765 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -248,8 +248,15 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша. - **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе. -### Фаза 3 — Правки контента в черновик +### Фаза 3 — Правки контента в черновик — ✅ СДЕЛАНО **Модель: Sonnet.** + +> **Реализовано:** +> - `PATCH /api/missions/survey-modes/{modeId}` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [updateSurveyRoute](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): перестраивает контур существующей съёмки тем же `buildContour`, что и `/survey-route/build`, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в `MANUAL` (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит `SurveyRouteBuildRequestDTO`. +> - `DELETE /api/missions/modes/{modeId}` → [deleteMode](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): диспатч SURVEY/DROP. Съёмка — снимаются брони слотов (`mode_booking`) и связи `drop_mode_survey`; сброс, оставшийся без единой съёмки, удаляется (осиротевший); другие съёмки того же сброса не трогаются. Сброс — удаляются только его связи и он сам, съёмки независимы. Нет режима → 404. +> - `saveSurveyRoute`/`saveDropRoute` больше **не создают живую миссию на лету** — адресуется только существующая миссия-черновик; нет миссии → 404. В `saveSurveyRoute` проверка миссии вынесена **до** дорогого `buildContour` (fail-fast). Swagger-описания и kdoc обновлены. +> - Репозиторий: `DropModeSurveyRepository.findAllBySurveyMode_Id` (поиск связей по съёмке). +> - Тесты: `MissionEditModesTest` (6) — каскады DELETE (осиротевший сброс удаляется; общий сброс с другой съёмкой остаётся; удаление сброса не трогает съёмки; 404 на неизвестный режим) + 404 на `saveSurveyRoute`/`saveDropRoute` без миссии. Весь модуль зелёный. - `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`. - Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария. - **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы. diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index defe2fc..c550475 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -12,6 +12,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping @@ -51,7 +52,7 @@ class MissionController( description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " + "и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " + "planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " + - "Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса." + "Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404." ) @ApiResponses( value = [ @@ -70,7 +71,7 @@ class MissionController( description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " + "и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " + "planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " + - "Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса." + "Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404." ) @ApiResponses( value = [ @@ -211,6 +212,46 @@ class MissionController( return ResponseEntity.noContent().build() } + @Operation( + summary = "Перестроить маршрут съёмки", + description = "Перестраивает контур существующего режима съёмки по новым параметрам (окно/крен/" + + "угол захвата) тем же алгоритмом, что и /survey-route/build, и сохраняет результат на месте. " + + "Используется для правки включения в плане-черновике." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Маршрут перестроен"), + ApiResponse(responseCode = "404", description = "Режим съёмки не найден") + ] + ) + @PatchMapping("/survey-modes/{mode_id}") + fun updateSurveyRoute( + @Parameter(description = "Идентификатор режима съёмки") + @PathVariable("mode_id") modeId: Long, + @Valid @RequestBody body: SurveyRouteBuildRequestDTO + ) = missionPlaningService.updateSurveyRoute(modeId, body) + + @Operation( + summary = "Удалить включение (съёмку или сброс)", + description = "Удаляет режим (SURVEY или DROP) из плана-черновика по его id. Для съёмки снимаются " + + "брони слотов и связи со сбросами, осиротевший сброс (без единой съёмки) тоже удаляется. " + + "Для сброса удаляются только его связи; съёмки сохраняются." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Включение удалено"), + ApiResponse(responseCode = "404", description = "Режим не найден") + ] + ) + @DeleteMapping("/modes/{mode_id}") + fun deleteMode( + @Parameter(description = "Идентификатор режима (survey или drop)") + @PathVariable("mode_id") modeId: Long + ): ResponseEntity { + missionPlaningService.deleteMode(modeId) + return ResponseEntity.noContent().build() + } + @Operation(summary = "Создать миссию") diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt index 45b3850..a14622f 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt @@ -7,4 +7,7 @@ import java.util.UUID interface DropModeSurveyRepository : JpaRepository { fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List + + /** Связи сброс↔съёмка по конкретной съёмке (для удаления съёмки и поиска осиротевших сбросов). */ + fun findAllBySurveyMode_Id(surveyModeId: Long): List } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 1076648..a8eac66 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -664,42 +664,18 @@ class MissionPlaningService( * [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии * (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана. * - * Если миссии/плана с таким идентификатором ещё нет (план не рассчитывался), они - * создаются на лету по параметрам запроса: `missionId = planId`, `satelliteId` из запроса - * (NORAD), интервал — окно построенного маршрута. При последующем штатном расчёте - * [loadSlots] интервал миссии будет уточнён. + * Адресуется только **существующая** миссия-черновик: правки редактора идут в DRAFT-клон, + * созданный заранее (`POST /api/missions/{id}/clone`). Авто-создание живой миссии на лету + * убрано — нельзя молча писать правки в несуществующий/живой план. Если миссии с таким id + * нет — 404. * * Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только * пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]). */ @Transactional fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { - val built = buildContour(req) - - val mission = missionRepository.findById(planId).orElseGet { - // Интервал плана берём из запроса (фронт знает окно выбранного плана). Если не передан — - // фолбэк на окно самого маршрута; при штатном расчёте [loadSlots] интервал будет уточнён. - val routeStop = built.timeStart.plusNanos((built.durationSeconds * 1_000_000_000L).toLong()) - val missionStart = req.planStart ?: built.timeStart - val missionStop = req.planEnd ?: routeStop - logger.info( - "Миссия/план {} не найдены — создаём под ручной маршрут: satelliteId={}, interval=[{} - {}] (источник интервала: {})", - planId, - req.satelliteId, - missionStart, - missionStop, - if (req.planStart != null && req.planEnd != null) "план" else "окно маршрута" - ) - missionRepository.save( - MissionEntity( - missionId = planId, - satelliteId = req.satelliteId, - missionStart = missionStart, - missionStop = missionStop, - status = "NEW" - ) - ) - } + // Проверяем существование миссии-черновика до дорогого расчёта контура (fail-fast на 404). + val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) } val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) .orElseGet { @@ -713,6 +689,8 @@ class MissionPlaningService( ) } + val built = buildContour(req) + val saved = surveyModeRepository.save( SurveyModeEntity( plan = plan, @@ -750,8 +728,8 @@ class MissionPlaningService( * ([calculateDrops]). * * Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute). - * Если миссии/плана с таким id ещё нет — создаются на лету по параметрам запроса (окно плана - * из `planStart/planEnd`, фолбэк — окно зоны). + * Адресуется только **существующая** миссия-черновик (DRAFT-клон, созданный заранее). Авто-создание + * живой миссии на лету убрано — если миссии с таким id нет, возвращается 404. * * Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все * сбросы плана ([ModeRepository.deleteDropModesByPlanId]). @@ -762,27 +740,7 @@ class MissionPlaningService( throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса") } - val mission = missionRepository.findById(planId).orElseGet { - val missionStart = req.planStart ?: req.zoneStart - val missionStop = req.planEnd ?: req.zoneStop - logger.info( - "Миссия/план {} не найдены — создаём под ручной сброс: satelliteId={}, interval=[{} - {}] (источник интервала: {})", - planId, - req.satelliteId, - missionStart, - missionStop, - if (req.planStart != null && req.planEnd != null) "план" else "окно зоны" - ) - missionRepository.save( - MissionEntity( - missionId = planId, - satelliteId = req.satelliteId, - missionStart = missionStart, - missionStop = missionStop, - status = "NEW" - ) - ) - } + val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) } val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) .orElseGet { @@ -856,6 +814,102 @@ class MissionPlaningService( ) } + /** + * Перестраивает контур существующего маршрута съёмки по новым параметрам (окно/крен/угол захвата) + * тем же алгоритмом [buildContour], что и [buildSurveyRoute]/[saveSurveyRoute], и сохраняет + * результат на месте (правка включения в черновике). + * + * Меняются: время начала, виток, центр (lat/long), длительность, контур (WKT) и крен. Статус + * сохраняется. Источник переводится в [SurveyModeSource.MANUAL] — отредактированный оператором + * маршрут переживает пересчёт плана ([loadSlots] удаляет только пересчитываемые, не MANUAL). + */ + @Transactional + fun updateSurveyRoute(modeId: Long, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { + val survey = surveyModeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found") + } + + val built = buildContour(req) + survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC) + survey.revolution = built.revolution + survey.lat = built.lat + survey.long = built.longitude + survey.duration = built.durationSeconds + survey.contourWkt = built.contourWkt + survey.roll = built.roll + survey.source = SurveyModeSource.MANUAL + val saved = surveyModeRepository.save(survey) + + logger.info( + "Перестроен маршрут съёмки (survey_mode id={}): revolution={}, timeStart={}, duration={}, roll={}", + saved.id, built.revolution, built.timeStart, built.durationSeconds, built.roll + ) + + return built.toResponse(surveyModeId = saved.id) + } + + /** + * Удаляет включение (SURVEY или DROP) из плана-черновика по его id. + * + * Съёмка: снимаются её брони слотов ([ModeBookingEntity]) и связи съёмка↔сброс + * ([DropModeSurveyEntity]); сброс, оставшийся без единой съёмки, тоже удаляется (осиротевший + * сеанс не имеет смысла). Другие съёмки того же сброса не трогаются. + * + * Сброс: удаляются только его связи и сам сброс; съёмки независимы и сохраняются. + */ + @Transactional + fun deleteMode(modeId: Long) { + val mode = modeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Mode with id $modeId not found") + } + when (mode) { + is SurveyModeEntity -> deleteSurveyMode(mode) + is DropModeEntity -> deleteDropMode(mode) + else -> throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported mode type for id $modeId: ${mode::class.qualifiedName}" + ) + } + } + + private fun deleteSurveyMode(survey: SurveyModeEntity) { + val surveyId = survey.id ?: throw IllegalStateException("Survey mode id is null") + + val bookings = modeBookingRepository.findAllByMode_IdIn(listOf(surveyId)) + if (bookings.isNotEmpty()) { + modeBookingRepository.deleteAll(bookings) + } + + val links = dropModeSurveyRepository.findAllBySurveyMode_Id(surveyId) + val affectedDropIds = links.mapNotNull { it.dropMode?.id }.distinct() + if (links.isNotEmpty()) { + dropModeSurveyRepository.deleteAll(links) + } + // Сброс, у которого после удаления этой съёмки не осталось ни одной другой, — осиротел. + affectedDropIds.forEach { dropId -> + val hasOtherSurvey = dropModeSurveyRepository + .findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId) + .any { it.surveyMode?.id != null && it.surveyMode?.id != surveyId } + if (!hasOtherSurvey) { + dropModeRepository.deleteById(dropId) + } + } + + surveyModeRepository.delete(survey) + logger.info("Удалена съёмка (survey_mode id={}), снято броней={}, осиротевших сбросов проверено={}", + surveyId, bookings.size, affectedDropIds.size) + } + + private fun deleteDropMode(drop: DropModeEntity) { + val dropId = drop.id ?: throw IllegalStateException("Drop mode id is null") + val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId) + if (links.isNotEmpty()) { + dropModeSurveyRepository.deleteAll(links) + } + dropModeRepository.delete(drop) + logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size) + } + /** * Строит контур полосы съёмки по параметрам видимости точки. * Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute]. diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt new file mode 100644 index 0000000..34b9916 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -0,0 +1,236 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.UUID + +class MissionEditModesTest { + + private val surveyStore = linkedMapOf() + private val dropStore = linkedMapOf() + private val dropSurveyStore = mutableListOf() + private val bookingStore = mutableListOf() + + private var autoId = 10L + + private val missionRepository = mock(MissionRepository::class.java) + + private val modeRepository = mock(ModeRepository::class.java).also { repo -> + `when`(repo.findById(anyLong())).thenAnswer { inv -> + val id = inv.arguments[0] as Long + java.util.Optional.ofNullable(surveyStore[id] ?: dropStore[id]) + } + } + + private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo -> + doAnswer { inv -> + val s = inv.arguments[0] as SurveyModeEntity + surveyStore.remove(s.id) + null + }.`when`(repo).delete(anyNonNull()) + } + + private val dropModeRepository = mock(DropModeRepository::class.java).also { repo -> + doAnswer { inv -> + val d = inv.arguments[0] as DropModeEntity + dropStore.remove(d.id) + null + }.`when`(repo).delete(anyNonNull()) + doAnswer { inv -> + dropStore.remove(inv.arguments[0] as Long) + null + }.`when`(repo).deleteById(anyLong()) + } + + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo -> + `when`(repo.findAllBySurveyMode_Id(anyLong())).thenAnswer { inv -> + val sid = inv.arguments[0] as Long + dropSurveyStore.filter { it.surveyMode?.id == sid } + } + `when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv -> + val did = inv.arguments[0] as Long + dropSurveyStore.filter { it.dropMode?.id == did } + } + doAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val links = inv.arguments[0] as Iterable + dropSurveyStore.removeAll(links.toSet()) + null + }.`when`(repo).deleteAll(anyNonNull()) + } + + private val modeBookingRepository = mock(ModeBookingRepository::class.java).also { repo -> + `when`(repo.findAllByMode_IdIn(anyNonNull())).thenAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val ids = inv.arguments[0] as Collection + bookingStore.filter { it.mode?.id in ids } + } + doAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val bs = inv.arguments[0] as Iterable + bookingStore.removeAll(bs.toSet()) + null + }.`when`(repo).deleteAll(anyNonNull()) + } + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = mock(PlanRepository::class.java), + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = modeBookingRepository, + bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java), + ballisticsService = mock(BallisticsService::class.java), + complexMissionService = mock(ComplexMissionService::class.java), + satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java), + satelliteMissionTypeFactory = SatelliteMissionTypeFactory( + listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()), + DefaultSatelliteMissionType() + ) + ) + + private val plan = PlanEntity(id = 1L, planNumber = 1) + + // --- DELETE mode --- + + @Test + fun `deleting a survey removes its bookings, links and the orphaned drop`() { + val survey = survey() + val drop = drop() + link(drop, survey) + booking(survey) + + service.deleteMode(survey.id!!) + + assertFalse(surveyStore.containsKey(survey.id)) + assertTrue(bookingStore.isEmpty()) + assertFalse(dropStore.containsKey(drop.id), "сброс без единственной съёмки должен быть удалён") + assertTrue(dropSurveyStore.isEmpty()) + } + + @Test + fun `deleting a survey keeps a drop still referenced by another survey`() { + val survey1 = survey() + val survey2 = survey() + val drop = drop() + link(drop, survey1) + link(drop, survey2) + + service.deleteMode(survey1.id!!) + + assertFalse(surveyStore.containsKey(survey1.id)) + assertTrue(surveyStore.containsKey(survey2.id)) + assertTrue(dropStore.containsKey(drop.id), "сброс с оставшейся съёмкой не удаляется") + assertEquals(1, dropSurveyStore.size) + assertEquals(survey2.id, dropSurveyStore.single().surveyMode?.id) + } + + @Test + fun `deleting a drop removes the drop and its links but keeps surveys`() { + val survey = survey() + val drop = drop() + link(drop, survey) + + service.deleteMode(drop.id!!) + + assertFalse(dropStore.containsKey(drop.id)) + assertTrue(dropSurveyStore.isEmpty()) + assertTrue(surveyStore.containsKey(survey.id), "съёмка независима от сброса и сохраняется") + } + + @Test + fun `deleting an unknown mode yields 404`() { + val ex = assertThrows { service.deleteMode(999L) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- save routes больше не создают живую миссию --- + + @Test + fun `saveSurveyRoute on a missing mission yields 404 instead of auto-creating`() { + // missionRepository не застаблен → findById возвращает пустой Optional. + val ex = assertThrows { + service.saveSurveyRoute(UUID.randomUUID(), SurveyRouteBuildRequestDTO(satelliteId = 25544)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `saveDropRoute on a missing mission yields 404 instead of auto-creating`() { + val ex = assertThrows { + service.saveDropRoute( + UUID.randomUUID(), + DropRouteSaveRequestDTO(satelliteId = 25544, surveyModeIds = listOf(1L)) + ) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- helpers --- + + private fun survey(): SurveyModeEntity { + val id = ++autoId + val s = SurveyModeEntity( + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0).atOffset(ZoneOffset.UTC), + revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0, + status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL + ) + surveyStore[id] = s + return s + } + + private fun drop(): DropModeEntity { + val id = ++autoId + val d = DropModeEntity( + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0).atOffset(ZoneOffset.UTC), + revolution = id, station = "102", duration = 60.0 + ) + dropStore[id] = d + return d + } + + private fun link(drop: DropModeEntity, survey: SurveyModeEntity) { + dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey)) + } + + private fun booking(survey: SurveyModeEntity) { + bookingStore.add(ModeBookingEntity(id = ++autoId, mode = survey, slotId = autoId)) + } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { org.mockito.ArgumentMatchers.any(); return null as T } + private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L } +} From 8133a5144ffcc08770738b9ce9893345f6a0f8f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:27:31 +0300 Subject: [PATCH 39/79] =?UTF-8?q?pcp-mission-planing:=20=D0=A44=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F-=D0=B7=D0=B0=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=20POST?= =?UTF-8?q?=20/missions/{id}/validate=20(=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2,=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B9=20di?= =?UTF-8?q?ff)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 7 ++- .../controller/MissionController.kt | 19 +++++++ .../controller/MissionValidationDTOs.kt | 55 +++++++++++++++++++ .../service/MissionPlaningService.kt | 16 ++++++ .../service/MissionEditModesTest.kt | 19 +++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index a33f765..0a1e1fa 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -262,8 +262,13 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы. - **Тесты:** PATCH меняет контур; DELETE с каскадами. -### Фаза 4 — Валидация-заглушка +### Фаза 4 — Валидация-заглушка — ✅ СДЕЛАНО **Модель: Sonnet.** + +> **Реализовано:** +> - `POST /api/missions/{id}/validate` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [validateMission](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): проверяет существование миссии (404, если нет) и возвращает корректный контракт с **пустым** списком конфликтов. Реальные правила — TODO раздела 6. +> - Контракт `MissionValidationResponse { missionId, conflicts: [ModeConflict] }`, где `ModeConflict { modeId, rule, severity, message, relatedModeId? }` ([MissionValidationDTOs.kt](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt)). Коды правил (`ConflictRule`: TIME_OVERLAP/TARGET_VISIBILITY/COMMS_SESSION/DROP_ORDER/ONBOARD_LIMIT) и уровни (`ConflictSeverity`: error/warning) заведены сразу как стабильная схема — появление реальных правил не сломает фронт. Дифф по режимам (а не весь план). +> - Тесты: 2 кейса в `MissionEditModesTest` (валидация существующей миссии → пустой список; неизвестной → 404). Весь модуль зелёный. - `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6. - **Приёмка:** `200` с пустым diff; контракт стабилен для фронта. diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index c550475..4f4adea 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -252,6 +252,25 @@ class MissionController( return ResponseEntity.noContent().build() } + @Operation( + summary = "Проверить миссию-черновик на конфликты", + description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " + + "режимам. MVP: валидатор-заглушка — контракт корректен, список конфликтов пуст; " + + "реальные правила (пересечения по времени, видимость цели, ЗРВ, порядок сброса, борт) — " + + "отдельной задачей." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Валидация выполнена, конфликты в теле ответа"), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @PostMapping("/{mission_id}/validate") + fun validateMission( + @Parameter(description = "Идентификатор миссии-черновика") + @PathVariable("mission_id") id: UUID + ): MissionValidationResponse = missionPlaningService.validateMission(id) + @Operation(summary = "Создать миссию") diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt new file mode 100644 index 0000000..9ed7b6a --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt @@ -0,0 +1,55 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import java.util.UUID + +/** + * Коды правил валидации. MVP: валидатор-заглушка их **не вычисляет** — список существует как + * стабильный контракт для фронта, чтобы появление реальных правил (раздел 6 дизайн-дока) не ломало схему. + */ +object ConflictRule { + /** Пересечение по времени между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс). */ + const val TIME_OVERLAP = "TIME_OVERLAP" + /** Окно съёмки вне окна видимости цели (баллистика). */ + const val TARGET_VISIBILITY = "TARGET_VISIBILITY" + /** Окно сброса вне зоны радиовидимости станции. */ + const val COMMS_SESSION = "COMMS_SESSION" + /** Сбрасываемая съёмка не завершается строго до начала зоны сброса. */ + const val DROP_ORDER = "DROP_ORDER" + /** Бортовые ограничения: длительность/скважность, энергетика, память. */ + const val ONBOARD_LIMIT = "ONBOARD_LIMIT" +} + +/** Уровень конфликта: [ERROR] блокирует «Применить», [WARNING] — информативный. */ +object ConflictSeverity { + const val ERROR = "error" + const val WARNING = "warning" +} + +/** + * Один конфликт на затронутом включении. Возвращается диффом по режимам (а не как весь план), + * чтобы фронт мог точечно подсвечивать проблемные включения. + * + * @param modeId id режима (survey или drop), к которому относится конфликт. + * @param rule код правила, см. [ConflictRule]. + * @param severity уровень, см. [ConflictSeverity]. + * @param message человекочитаемое описание для оператора. + * @param relatedModeId id второго участника парного правила (например, пересечения по времени), если есть. + */ +data class ModeConflict( + val modeId: Long, + val rule: String, + val severity: String, + val message: String, + val relatedModeId: Long? = null +) + +/** + * Результат валидации миссии-черновика. + * + * MVP: [conflicts] всегда пуст — реальные правила ещё не считаются (TODO раздела 6 дизайн-дока). + * Контракт стабилен: фронт уже завязывается на него и не сломается, когда правила появятся. + */ +data class MissionValidationResponse( + val missionId: UUID, + val conflicts: List +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index a8eac66..eb4a246 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -32,6 +32,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResp import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionValidationResponse import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity @@ -910,6 +911,21 @@ class MissionPlaningService( logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size) } + /** + * Валидация миссии-черновика на конфликты/наложения включений. + * + * MVP-заглушка: проверяет существование миссии (404, если нет) и возвращает корректный контракт + * с **пустым** списком конфликтов. Реальные правила (пересечения по времени, видимость цели, ЗРВ + * станции, порядок сброса, бортовые ограничения) — TODO раздела 6 дизайн-дока. Контракт + * ([MissionValidationResponse]/[ModeConflict]) стабилен, чтобы фронт не ломался при их появлении. + */ + fun validateMission(missionId: UUID): MissionValidationResponse { + if (!missionRepository.existsById(missionId)) { + throw notFound(missionId) + } + return MissionValidationResponse(missionId = missionId, conflicts = emptyList()) + } + /** * Строит контур полосы съёмки по параметрам видимости точки. * Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute]. diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt index 34b9916..f71d247 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -199,6 +199,25 @@ class MissionEditModesTest { assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } + // --- validate (заглушка) --- + + @Test + fun `validate of an existing mission returns an empty conflict list`() { + val missionId = UUID.randomUUID() + `when`(missionRepository.existsById(missionId)).thenReturn(true) + + val result = service.validateMission(missionId) + + assertEquals(missionId, result.missionId) + assertTrue(result.conflicts.isEmpty(), "MVP-заглушка не считает конфликтов") + } + + @Test + fun `validate of a missing mission yields 404`() { + val ex = assertThrows { service.validateMission(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + // --- helpers --- private fun survey(): SurveyModeEntity { From ae7effe1fa9fe21aead8d61972f1ced65823dad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:36:59 +0300 Subject: [PATCH 40/79] =?UTF-8?q?pcp-tgu-service:=20=D0=A45=20=E2=80=94=20?= =?UTF-8?q?promote=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D1=87=D0=BD=D1=8B=D0=BC=20=D0=BB=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B1=D0=B0=D0=B7=D1=8B=20(POST=20/plans/{id}/promote?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 10 +- .../controller/PlanController.kt | 11 ++ .../service/CreateDraftUseCase.kt | 18 ++- .../service/IssueDuePlanWorker.kt | 104 +++++++++++++ .../service/CreateDraftUseCaseTest.kt | 23 ++- .../service/IssueDuePlanWorkerOnDemandTest.kt | 140 ++++++++++++++++++ 6 files changed, 299 insertions(+), 7 deletions(-) diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 0a1e1fa..852d7b1 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -272,8 +272,16 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6. - **Приёмка:** `200` с пустым diff; контракт стабилен для фронта. -### Фаза 5 — «Применить» = promote +### Фаза 5 — «Применить» = promote — ✅ СДЕЛАНО **Модель: Opus** (сердце корректности живого плана). + +> **Реализовано:** +> - `POST /api/plans/{draftPlanId}/promote` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [IssueDuePlanWorker.promoteDraftNow](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуя `startPreparedAttempt`). +> - **Оптимистичный лок базы:** [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt) снимает `spacecraft_planning_state.chain_version` на момент создания черновика и хранит его в `planned_plan.chain_version` DRAFT-строки. На promote [assertDraftBaseUnchanged](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) требует совпадения с текущей версией цепочки; любой ACCEPT/grace-сдвиг базы её инкрементит → **409** «база уехала, посмотрите дифф». Прежние версии остаются в БД (откат = новый черновик из старой версии). +> - **Контроль слота/дрейфа ЗРВ** ([assertDraftSlotStillValid](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt)): окно черновика должно совпадать с реальной будущей ЗРВ — для fork-forward слот берётся из проекции, для replace учитывается окно заменяемого базового плана (проекция, заякоренная после плана в полёте, его не возвращает). Сместился снапшот → **422**. +> - **Гард точки невозврата** переиспользован (`assertReissuableInSlot`): заморожено / активная выдача другого плана → 422 / 409. Вход в цепочку штатный: на внешний ACCEPT существующий `acceptPlan` делает супершединг (`chainVersion++`); `basePlanId` черновика не трогаем (провенанс — что замещаем/продолжаем). +> - Не-DRAFT план → 422, неизвестный черновик → 404. +> - Тесты: 7 кейсов promote в `IssueDuePlanWorkerOnDemandTest` (успех; конкурентный сдвиг базы → 409; не-DRAFT → 422; неизвестный → 404; заморозка → 422; чужая активная выдача → 409; дрейф окна → 422) + 1 кейс снимка версии в `CreateDraftUseCaseTest`. Весь модуль зелёный. - `pcp-tgu-service`: promote `draftPlanId` → выдача с **оптимистичным локом базы** (проверить, что `P0`/`currentBasePlanId` не изменились с момента создания черновика; иначе `409` «план изменился, посмотрите дифф»). Вход в цепочку: при внешнем `ACCEPT` существующий [acceptPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt#L142) делает супершединг (`chainVersion++`); для `fork-forward` `base_plan_id` уже выставлен. - Контроль **полноты перекрытия слота** при `replace` (остаточный риск раздела 10). - **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен). diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 35d79bb..322d3e8 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -59,4 +59,15 @@ class PlanController( @PostMapping("/{planId}/issue") fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse = issueDuePlanWorker.issuePlanNow(planId) + + /** + * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и выдаёт его. Под lock'ом + * состояния КА: оптимистичный лок базы (версия цепочки не уехала), контроль валидности слота + * и гард точки невозврата. + * 404 — черновика нет; 422 — план не DRAFT / слот сместился / точка невозврата пройдена; + * 409 — база изменилась с момента создания черновика либо по КА уже идёт выдача. + */ + @PostMapping("/{draftPlanId}/promote") + fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse = + issueDuePlanWorker.promoteDraftNow(draftPlanId) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt index 506d027..531ba1a 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -13,6 +13,7 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime import java.time.ZoneOffset import java.util.UUID @@ -36,7 +37,8 @@ class CreateDraftUseCase( private val plannedPlanRepository: PlannedPlanRepository, private val planProjectionService: PlanProjectionService, private val missionPlaningClient: MissionPlaningClient, - private val planningProperties: PlanningProperties + private val planningProperties: PlanningProperties, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository ) { private val log = LoggerFactory.getLogger(javaClass) @@ -182,6 +184,13 @@ class CreateDraftUseCase( kppId: String, clonedMissionId: UUID ): CreateDraftResponse { + // Снимок версии цепочки КА на момент создания черновика — оптимистичный токен для promote (Ф5): + // любой ACCEPT/grace-сдвиг базы инкрементит chain_version, и «Применить» по устаревшему черновику + // упадёт 409 «база уехала». chain_version у DRAFT хранит именно этот снимок (== живой при promote). + val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(base.spacecraftId) + .map { it.chainVersion } + .orElse(0L) + return try { val draft = plannedPlanRepository.save( PlannedPlanEntity( @@ -191,12 +200,13 @@ class CreateDraftUseCase( endTime = endTime, kppId = kppId, status = PlannedPlanStatus.DRAFT, - basePlanId = base.planId + basePlanId = base.planId, + chainVersion = baseChainVersion ) ) log.info( - "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}", - draft.planId, base.planId, base.spacecraftId + "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}", + draft.planId, base.planId, base.spacecraftId, baseChainVersion ) CreateDraftResponse(draftPlanId = draft.planId) } catch (ex: Exception) { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index fa9ea8e..e4985c4 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -59,6 +59,29 @@ class IssueDuePlanWorker( return startPreparedAttempt(prepared) } + /** + * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и сразу выдаёт его. + * + * Под lock'ом состояния КА проверяет (в порядке): + * 1. план существует и действительно DRAFT (иначе 404 / 422); + * 2. **оптимистичный лок базы** — версия цепочки не изменилась с момента создания черновика + * ([assertDraftBaseUnchanged]); иначе 409 «база уехала, посмотрите дифф»; + * 3. **слот ещё валиден** — окно черновика по-прежнему соответствует реальной будущей ЗРВ + * (дрейф снапшота / перекрытие слота при replace, [assertDraftSlotStillValid]); иначе 422; + * 4. гард точки невозврата ([assertReissuableInSlot]) — не заморожено и нет активной выдачи + * другого плана; иначе 422 / 409. + * + * Затем DRAFT→ISSUING, новая attempt, и старт BPMN. Цепочка продолжается штатно: на внешний + * ACCEPT существующий acceptPlan сделает супершединг (chainVersion++). Прежние версии остаются + * в БД (откат = новый черновик из старой версии). basePlanId черновика не трогаем — это его + * провенанс (какой план он замещает/продолжает). + */ + fun promoteDraftNow(draftPlanId: UUID): ProcessStartResponse { + val prepared = transactionTemplate.execute { prepareDraftPromotion(draftPlanId) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Draft plan with planId=$draftPlanId was not found") + return startPreparedAttempt(prepared) + } + /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { val now = LocalDateTime.now(ZoneOffset.UTC) @@ -156,6 +179,87 @@ class IssueDuePlanWorker( return PreparedAttempt(plan.planId, attempt.attemptId) } + private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? { + val now = LocalDateTime.now(ZoneOffset.UTC) + val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null + if (plan.status != PlannedPlanStatus.DRAFT) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $draftPlanId не является черновиком (status=${plan.status}) — «Применить» доступно только для DRAFT" + ) + } + + val state = getOrCreateLockedState(plan.spacecraftId) + assertDraftBaseUnchanged(plan, state) + assertDraftSlotStillValid(plan) + assertReissuableInSlot(plan, state, now) + + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + val attempt = createStartingAttempt(plan.planId, now) + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + log.info( + "Черновик {} применён (promote): spacecraft={}, окно [{}–{}], chainVersion={}", + plan.planId, plan.spacecraftId, plan.startTime, plan.endTime, plan.chainVersion + ) + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + /** + * Оптимистичный лок базы для promote: версия цепочки, снятая при создании черновика + * ([PlannedPlanEntity.chainVersion] у DRAFT), должна совпадать с текущей версией цепочки КА. + * Любой ACCEPT/grace-сдвиг базы между созданием черновика и «Применить» инкрементит версию — + * тогда черновик строился от устаревшей базы и его нужно пересоздать (посмотрев дифф). + */ + private fun assertDraftBaseUnchanged(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity) { + if (plan.chainVersion != state.chainVersion) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "База КА ${plan.spacecraftId} изменилась с момента создания черновика ${plan.planId} " + + "(цепочка v${plan.chainVersion} → v${state.chainVersion}) — посмотрите дифф и пересоздайте черновик" + ) + } + } + + /** + * Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно + * по-прежнему соответствовать реальной будущей ЗРВ. Для fork-forward слот есть в проекции; + * для replace слот = окно заменяемого базового плана, которое проекция (заякоренная ПОСЛЕ + * плана в полёте) не возвращает, поэтому окно базы учитывается явно. Сверка по КПП и времени + * с точностью до секунды (как при создании черновика). Не совпало → снапшот сместился → 422. + */ + private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) { + val candidates = planProjectionService.project(plan.spacecraftId) + .map { Triple(it.kppId, it.startTime, it.endTime) } + .toMutableList() + plan.basePlanId + ?.let { plannedPlanRepository.findById(it).orElse(null) } + ?.let { candidates.add(Triple(it.kppId, it.startTime, it.endTime)) } + + val matches = candidates.any { (kppId, start, end) -> + kppId == plan.kppId && + diffSeconds(start, plan.startTime) < 1 && + diffSeconds(end, plan.endTime) < 1 + } + if (!matches) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " + + "больше не соответствует ни одной будущей ЗРВ — снапшот сместился, пересоздайте черновик" + ) + } + } + + private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = + Math.abs(java.time.Duration.between(a, b).toSeconds()) + /** * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException], * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия: diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt index 6b167ff..15e1b34 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt @@ -17,9 +17,11 @@ import space.nstart.pcp_tgu_service.dto.CreateDraftRequest import space.nstart.pcp_tgu_service.dto.DraftMode import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime import java.time.ZoneOffset import java.util.Optional @@ -50,6 +52,19 @@ class CreateDraftUseCaseTest { assertEquals(base.kppId, saved.kppId) } + @Test + fun `draft snapshots the current chain version as its optimistic token`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val fixture = fixture( + base, + planningState = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 7) + ) + + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(7L, fixture.lastSaved!!.chainVersion) + } + @Test fun `replace rejects a DRAFT base`() { val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT) @@ -209,7 +224,8 @@ class CreateDraftUseCaseTest { cloneResult: UUID = UUID.randomUUID(), projection: List = emptyList(), existingDrafts: List = emptyList(), - failSave: Boolean = false + failSave: Boolean = false, + planningState: SpacecraftPlanningStateEntity? = null ): Fixture { val repo = mock(PlannedPlanRepository::class.java) val basePlanId = base?.planId ?: UUID.randomUUID() @@ -231,7 +247,10 @@ class CreateDraftUseCaseTest { `when`(client.cloneMission(anyUuid(), anyNonNull())) .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) - val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10)) + val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepo.findById(spacecraftId)).thenReturn(Optional.ofNullable(planningState)) + + val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10), stateRepo) return Fixture(useCase, client, saved) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index ad05243..e941485 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -285,6 +285,146 @@ class IssueDuePlanWorkerOnDemandTest { assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } + // --- Применить черновик: promoteDraftNow (Ф5, оптимистичный лок базы) --- + + @Test + fun `promoteDraftNow promotes a fresh draft into the chain and issues it`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Заменяемый базовый план (даёт окно для проверки валидности слота при replace). + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + worker.promoteDraftNow(draft.planId) + + // DRAFT ушёл в выдачу: markStarted перевёл его в WAITING_DECISION, КА указывает на новую attempt. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status) + assertEquals(1, attemptStore.size) + assertEquals(draft.planId, state.activePlanId) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects with 409 when the chain moved since the draft was created`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + // Черновик снят на версии цепочки 0, а база уже уехала вперёд (accept/grace) → состояние v1. + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 1) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects a non-draft plan with 422`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val live = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[live.planId] = live + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(live.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow returns 404 for unknown draft`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(UUID.randomUUID()) } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `promoteDraftNow rejects a draft inside the freeze window with 422`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт через 5 мин (< порога 10 мин): слот валиден (база даёт окно), но точка невозврата пройдена. + val base = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow rejects with 409 when another plan is being issued`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + // По КА уже идёт активная выдача другого плана. + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + chainVersion = 0, + activePlanId = UUID.randomUUID(), + activeAttemptId = UUID.randomUUID() + ) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow rejects with 422 when the draft window no longer matches any future ZRV`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Нет базового плана и пустая проекция → окно слота не подтверждается (снапшот сместился). + val draft = draftPlan(now().plusMinutes(30), now().plusHours(3), chainVersion = 0, basePlanId = null) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + } + + private fun draftPlan( + start: LocalDateTime, + end: LocalDateTime, + chainVersion: Long, + basePlanId: UUID? + ): PlannedPlanEntity = + persistedPlan(PlannedPlanStatus.DRAFT, start, end).apply { + this.chainVersion = chainVersion + this.basePlanId = basePlanId + } + private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(), From ec67d9d83ddef8e5be4762a664e4c61d8d9328be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:53:24 +0300 Subject: [PATCH 41/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=A46=20=D1=8F=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=20=E2=80=94=20draft/validate/promote=20=D0=B2=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20(repla?= =?UTF-8?q?ce),=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D1=81=D0=B8=20/api/pcp-tgu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 16 +- .../src/api/missionPlanningApi.ts | 76 +++++++- services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 102 ++++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 174 ++++++++++++++---- .../src/features/tgu-editor/editorApi.ts | 20 -- .../src/features/tgu-editor/editorDraft.ts | 6 + .../tgu-editor/editorValidation.test.ts | 49 +++++ .../features/tgu-editor/editorValidation.ts | 29 +++ services/pcp-tgu-ops-ui/vite.config.ts | 5 + 9 files changed, 418 insertions(+), 59 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 852d7b1..47bb06c 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -287,9 +287,21 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен). - **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки. -### Фаза 6 — Фронт (редактор) +### Фаза 6 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote) **Модель: Sonnet.** -- `src/features/tgu-editor/editorApi.ts`: заменить заглушку [saveEditedPlan](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts#L13) на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`. + +> **Реализовано (ядро):** +> - API-слой: [missionPlanningApi.ts](services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts) — `updateSurveyRoute` (PATCH), `deleteMode` (DELETE), `validateMission` (POST validate, тип `MissionValidation`/`ModeConflict`); новый [tguPlanApi.ts](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts) — `createDraft`/`promoteDraft`. Прямые вызовы через Vite-прокси: `/api/pcp-mission`→:7010, новый `/api/pcp-tgu`→:7011 (без BFF, как и было решено). +> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): ленивое создание DRAFT (`ensureDraft`, mode=replace) на первую сохранённую правку; `saveSurveyRoute`/`saveDropRoute`/`deleteMode` адресуются в `draftPlanId`, а не в живой план; после каждой серверной правки — перечитывание включений DRAFT + `validate`; смена выбранного плана сбрасывает черновик; дедуп включения (локальная правка ⊕ перечитанная plan-origin копия по `planModeId`). +> - Маппинг авторитетных конфликтов бэка на включения редактора: [editorValidation.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts) (по `planModeId`) + 3 теста. +> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением. +> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные. +> +> **Вынесено в отдельные подзадачи (не входит в ядро):** +> - **fork-forward в UI**: выбор будущей ЗРВ (мини-панель/диалог) и `createDraft(mode=fork-forward, windowStart/Stop/kpp)` для `LAID_IN`-базы. Сейчас `ensureDraft` всегда шлёт `replace`; для заложенной базы бэк ответит 422 — сообщение показывается оператору. +> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote). +> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора. +- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`. - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): lazy-создание черновика на первую правку; адресация правок в `DRAFT`; на каждой правке — `validate`. - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = `validate` (или скрыть), «Применить» = `promote` (гасить по гарду точки невозврата). - Выбор будущей ЗРВ для `fork-forward` (мини-панель/диалог); откат: список версий цепочки → «восстановить». diff --git a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts index 81226bd..574a2f4 100644 --- a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts @@ -138,7 +138,79 @@ export async function saveDropRoute(planId: string, params: SaveDropRouteParams) return JSON.parse(text) as DropMode; } -async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): Promise { +/** + * Перестроение контура существующего режима съёмки по новым параметрам окна/крена + * (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения + * в плане-черновике: контур пересчитывается тем же алгоритмом, что и /survey-route/build. + */ +export async function updateSurveyRoute(modeId: number, params: BuildSurveyRouteParams): Promise { + return postSurveyRoute(`${BASE}/api/missions/survey-modes/${encodeURIComponent(modeId)}`, params, "PATCH"); +} + +/** + * Удаление включения (съёмки или сброса) из плана-черновика + * (pcp-mission-planing-service, DELETE /api/missions/modes/{modeId}). Для съёмки бэк снимает + * брони слотов и связи со сбросами, осиротевший сброс удаляется; для сброса — только его связи. + */ +export async function deleteMode(modeId: number): Promise { + const url = `${BASE}/api/missions/modes/${encodeURIComponent(modeId)}`; + let response: Response; + try { + response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Ошибка удаления включения.", "http", response.status); + } +} + +/** Уровень конфликта валидации: error блокирует «Применить», warning — информативный. */ +export type ConflictSeverity = "error" | "warning"; + +/** Один конфликт на затронутом включении (дифф по режимам). */ +export type ModeConflict = { + /** id режима (survey или drop), к которому относится конфликт. */ + modeId: number; + /** Код правила (TIME_OVERLAP / TARGET_VISIBILITY / COMMS_SESSION / DROP_ORDER / ONBOARD_LIMIT). */ + rule: string; + severity: ConflictSeverity; + message: string; + /** id второго участника парного правила (например, пересечения по времени), если есть. */ + relatedModeId?: number | null; +}; + +export type MissionValidation = { + missionId: string; + conflicts: ModeConflict[]; +}; + +/** + * Валидация миссии-черновика на конфликты/наложения включений + * (pcp-mission-planing-service, POST /api/missions/{id}/validate). MVP: бэк-заглушка возвращает + * корректный контракт с пустым списком конфликтов; контракт стабилен для UI. + */ +export async function validateMission(missionId: string): Promise { + const url = `${BASE}/api/missions/${encodeURIComponent(missionId)}/validate`; + let response: Response; + try { + response = await fetch(url, { method: "POST", 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 JSON.parse(text) as MissionValidation; +} + +async function postSurveyRoute( + url: string, + params: BuildSurveyRouteParams, + method: "POST" | "PATCH" = "POST" +): Promise { const body = { satelliteId: params.satelliteId, time: params.time, @@ -154,7 +226,7 @@ async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): Pro let response: Response; try { response = await fetch(url, { - method: "POST", + method, headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify(body) }); diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts new file mode 100644 index 0000000..f28ba27 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -0,0 +1,102 @@ +import { TguApiError } from "../model/tguTypes"; + +/** + * Вызовы версионирования/выдачи плана в pcp-tgu-service напрямую через Vite-прокси + * (`/api/pcp-tgu` → :7011), как и pcp-mission напрямую на :7010. BFF не используется. + */ +const BASE = "/api/pcp-tgu"; + +/** Режим наполнения черновика; та же строка уходит в clone миссии. */ +export type DraftMode = "replace" | "fork-forward"; + +export type CreateDraftRequest = { + mode: DraftMode; + /** Начало окна целевой ЗРВ (UTC naive, ISO без зоны). Обязательно для fork-forward. */ + windowStart?: string; + /** Конец окна целевой ЗРВ. Обязательно для fork-forward. */ + windowStop?: string; + /** КПП целевой ЗРВ. Обязательно для fork-forward. */ + windowKppId?: string; +}; + +export type CreateDraftResponse = { + /** Идентификатор созданного черновика (= mission_id клон-миссии). */ + draftPlanId: string; +}; + +export type PromoteDraftResponse = { + processInstanceKey: number; + processDefinitionKey: number; + bpmnProcessId: string; + version: number; + planId: string; + attemptId: string; +}; + +/** + * Создаёт черновик плана (DRAFT) на основе базового плана + * (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает + * draftPlanId, в который адресуются правки (Ф3) и который применяется promote (Ф5). + */ +export async function createDraft(basePlanId: string, request: CreateDraftRequest): Promise { + return postJson( + `${BASE}/api/plans/${encodeURIComponent(basePlanId)}/draft`, + request, + "Не удалось создать черновик плана." + ); +} + +/** + * «Применить» черновик: промоутит DRAFT в живую цепочку и выдаёт его + * (pcp-tgu-service, POST /api/plans/{draftPlanId}/promote). Под оптимистичным локом базы и + * гардом точки невозврата: 409 — база уехала / уже идёт выдача; 422 — не DRAFT / слот сместился / + * заморожено; 404 — черновика нет. + */ +export async function promoteDraft(draftPlanId: string): Promise { + return postJson( + `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}/promote`, + undefined, + "Не удалось применить черновик плана." + ); +} + +async function postJson(url: string, body: unknown, fallbackMessage: string): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: body === undefined + ? { Accept: "application/json" } + : { Accept: "application/json", "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || fallbackMessage, "http", response.status); + } + if (!text.trim()) { + return undefined as T; + } + return JSON.parse(text) as T; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 1530538..83b0b26 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -5,7 +5,15 @@ import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; import type { RequestMapItem } from "../../model/requestTypes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; -import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi"; +import { + buildSurveyRoute, + deleteMode, + saveDropRoute, + saveSurveyRoute, + validateMission, + type SurveyRoute +} from "../../api/missionPlanningApi"; +import { 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"; @@ -13,7 +21,8 @@ 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 { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi"; +import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi"; +import { validationToConflicts } from "./editorValidation"; import { addDownlink, addShoot, @@ -107,6 +116,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); const [requestItems, setRequestItems] = useState([]); const [planModes, setPlanModes] = useState([]); + // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace). + // Пока null — редактор показывает живой план read-only; после создания все правки и валидация + // адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку. + const [draftPlanId, setDraftPlanId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); @@ -141,7 +154,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const modes: EditorMode[] = DEFAULT_EDITOR_MODES; // Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне. const planWorks = useMemo(() => planModesToWorks(planModes), [planModes]); - const displayWorks = useMemo(() => [...planWorks, ...draft.works], [planWorks, draft.works]); + // id режимов, уже представленных локальной (редактируемой) работой черновика — чтобы не дублировать + // только что сохранённое включение его plan-origin копией из перечитанных planModes. + const addedModeIds = useMemo( + () => new Set(draft.works.map((work) => work.planModeId).filter((id): id is number => id != null)), + [draft.works] + ); + const displayWorks = useMemo( + () => [ + ...planWorks.filter((work) => work.planModeId == null || !addedModeIds.has(work.planModeId)), + ...draft.works + ], + [planWorks, draft.works, addedModeIds] + ); const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const selectedWork = displayWorks.find((work) => work.id === selectedWorkId); @@ -194,7 +219,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, .sort((a, b) => a.startMs - b.startMs); }, [planModes, selectedZone]); - const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew); + // «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку); + // локальные несохранённые твики без DRAFT применять нечего. + const dirty = Boolean(draftPlanId); const addedCount = draft.works.filter((work) => work.isNew).length; // Реальный состав плана берём из включений (plan modes), а не из черновика: // черновик сидируется заглушкой, когда у плана нет встроенных works. @@ -383,17 +410,24 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }; }, [rightMode, noradId, window.fromMs, window.toMs]); + // Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану. useEffect(() => { - if (!selectedPlan) return; - const planId = selectedPlan.planId; + setDraftPlanId(undefined); + setServiceConflicts({}); + }, [selectedPlan]); + + // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). + useEffect(() => { + const missionId = draftPlanId ?? selectedPlan?.planId; + if (!missionId) return; let cancelled = false; - void fetchPlanModes(planId).then((modes) => { + void fetchPlanModes(missionId).then((modes) => { if (!cancelled) setPlanModes(modes); }).catch(() => { // silently ignore — plan modes are supplementary display data }); return () => { cancelled = true; }; - }, [selectedPlan]); + }, [selectedPlan, draftPlanId]); const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => { setHistory((current) => mutate(current, transform)); @@ -406,6 +440,34 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, changeDraft((current) => updateWork(current, workId, patch)); }; + // Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT (replace) + // и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. + const ensureDraft = useCallback(async (): Promise => { + if (draftPlanId) return draftPlanId; + if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); + const created = await createDraft(selectedPlan.planId, { mode: "replace" }); + setDraftPlanId(created.draftPlanId); + return created.draftPlanId; + }, [draftPlanId, selectedPlan]); + + // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId. + // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. + const revalidate = useCallback(async (missionId: string, works: EditorWork[]) => { + try { + const validation = await validateMission(missionId); + setServiceConflicts(validationToConflicts(validation, works)); + } catch { + // валидация вспомогательна — молча игнорируем сбой + } + }, []); + + // После каждой серверной правки перечитываем включения DRAFT (источник истины) и ревалидируем. + const refreshDraftModes = useCallback(async (missionId: string) => { + const modes = await fetchPlanModes(missionId); + setPlanModes(modes); + void revalidate(missionId, planModesToWorks(modes)); + }, [revalidate]); + const addShootWork = async () => { if (!target || !selectedShootPass || !modeId) return; @@ -413,13 +475,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. const numericNorad = noradId ? Number(noradId) : NaN; + // Маршрут, сохранённый в БД черновика (несёт surveyModeId для последующего delete/patch). + let savedRoute: SurveyRoute | undefined = surveyRoute; if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) { - const planId = selectedPlan.planId; const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000); setRouteLoading(true); setRouteError(undefined); try { - const saved = await saveSurveyRoute(planId, { + // Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план. + const draftId = await ensureDraft(); + savedRoute = await saveSurveyRoute(draftId, { satelliteId: numericNorad, time: new Date(routeWindow.startMs).toISOString().slice(0, 19), durationSeconds: routeDurationSeconds, @@ -431,7 +496,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) }); - setSurveyRoute(saved); + setSurveyRoute(savedRoute); + void refreshDraftModes(draftId); } catch (error) { setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); return; @@ -454,11 +520,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, label: mode?.label ?? "Съёмка", modeId, target, - revolution: surveyRoute?.revolution ?? selectedShootPass.revolution, - rollDeg: surveyRoute?.roll ?? routeRoll, - captureAngleDeg: surveyRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, - centerLat: surveyRoute?.lat, - centerLon: surveyRoute?.longitude + // id сохранённого режима съёмки — чтобы удаление/правка адресовались в БД черновика. + planModeId: savedRoute?.surveyModeId, + revolution: savedRoute?.revolution ?? selectedShootPass.revolution, + rollDeg: savedRoute?.roll ?? routeRoll, + captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, + centerLat: savedRoute?.lat, + centerLon: savedRoute?.longitude }) ); setSelectedWorkId(id); @@ -492,14 +560,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, return; } - const planId = selectedPlan.planId; const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); setDropSaving(true); setDropError(undefined); + let savedDropId: number | undefined; try { - await saveDropRoute(planId, { + // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. + const draftId = await ensureDraft(); + const savedDrop = await saveDropRoute(draftId, { satelliteId: numericNorad, station: String(selectedZone.stationId), revolution: selectedZone.revolution, @@ -511,6 +581,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19), planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19) }); + savedDropId = savedDrop.id; + // Перечитываем включения DRAFT (источник истины) и ревалидируем. + void refreshDraftModes(draftId); } catch (error) { setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); return; @@ -518,8 +591,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setDropSaving(false); } - // Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне. - void fetchPlanModes(planId).then(setPlanModes).catch(() => {}); const stationNm = stationNameById(selectedZone.stationId); const id = nextWorkId(idCounterRef); changeDraft((current) => @@ -528,7 +599,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, startMs: zoneStartMsVal, endMs: zoneStopMsVal, label: `Сброс · ${stationNm}`, - station: { id: String(selectedZone.stationId), name: stationNm } + station: { id: String(selectedZone.stationId), name: stationNm }, + planModeId: savedDropId }) ); setSelectedWorkId(id); @@ -537,17 +609,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedDropSurveyIds(new Set()); }; - const runCheck = () => { + // «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное + // превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было). + const runCheck = async () => { setChecking(true); - setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations })); - setChecking(false); - setNotice("Проверка видимости выполнена для текущего черновика."); + setApplyError(undefined); + try { + if (draftPlanId) { + const validation = await validateMission(draftPlanId); + setServiceConflicts(validationToConflicts(validation, planWorks)); + setNotice("Проверка выполнена на сервере (валидация черновика)."); + } else { + setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations })); + setNotice("Проверка видимости выполнена для текущего черновика."); + } + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось выполнить проверку."); + } finally { + setChecking(false); + } }; + // «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под + // оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка). const applyDraft = async () => { - const planId = selectedPlan?.planId ?? draft.planId; - if (!planId) { - setApplyError("Нельзя применить черновик: план не выбран."); + if (!draftPlanId) { + setApplyError("Нет правок для применения — черновик ещё не создан."); setNotice(undefined); return; } @@ -556,8 +643,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setApplyError(undefined); setNotice(undefined); try { - const result = await saveEditedPlan({ planId, draft }); - setNotice(result.message); + const result = await promoteDraft(draftPlanId); + setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`); + setDraftPlanId(undefined); + setServiceConflicts({}); setHistory((current) => ({ ...current, past: [], future: [] })); } catch (error) { setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана."); @@ -566,6 +655,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; + // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, + // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые + // (planModeId нет) — только локально. + const handleDeleteWork = async (workId: string) => { + const work = draft.works.find((item) => item.id === workId); + if (work?.planModeId != null && draftPlanId) { + try { + await deleteMode(work.planModeId); + await refreshDraftModes(draftPlanId); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось удалить включение."); + return; + } + } + changeDraft((current) => deleteWork(current, workId)); + setSelectedWorkId(undefined); + setRightMode("none"); + }; + const zoom = (factor: number) => { setWindow((current) => { const center = (current.fromMs + current.toMs) / 2; @@ -756,11 +864,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, conflicts={conflicts[selectedWork.id] ?? []} readOnly={selectedWork.origin === "plan"} onUpdate={onChangeWork} - onDelete={(workId) => { - changeDraft((current) => deleteWork(current, workId)); - setSelectedWorkId(undefined); - setRightMode("none"); - }} + onDelete={(workId) => { void handleDeleteWork(workId); }} onDuplicate={(workId) => { const id = nextWorkId(idCounterRef); changeDraft((current) => duplicateWork(current, workId, id)); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts index bf184f5..6a1cb94 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts @@ -1,23 +1,3 @@ -import type { Draft } from "./model/editorTypes"; - -export type SaveEditedPlanRequest = { - planId: string; - draft: Draft; -}; - -export type SaveEditedPlanResult = { - saved: false; - message: string; -}; - -export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise { - // TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service. - return { - saved: false, - message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend." - }; -} - export type PlanSurveyMode = { id: number; planId: number; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 54179fe..525b65f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -22,6 +22,8 @@ export type AddShootInput = { label: string; modeId?: string; target: EditorTarget; + /** id сохранённого режима съёмки в БД черновика (если съёмка персистнута). */ + planModeId?: number; /** Параметры построенного маршрута (если съёмка строилась с превью). */ revolution?: number; rollDeg?: number; @@ -36,6 +38,8 @@ export type AddDownlinkInput = { endMs: number; label: string; station: EditorGroundStation; + /** id сохранённого режима сброса в БД черновика (если сброс персистнут). */ + planModeId?: number; }; export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { @@ -151,6 +155,7 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft { targetName: input.target.name, targetLat: input.target.lat, targetLon: input.target.lon, + planModeId: input.planModeId, revolution: input.revolution, rollDeg: input.rollDeg, captureAngleDeg: input.captureAngleDeg, @@ -171,6 +176,7 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft { startMs: input.startMs, endMs: input.endMs, stationId: input.station.id, + planModeId: input.planModeId, origin: "added", isNew: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts new file mode 100644 index 0000000..be34e76 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { MissionValidation } from "../../api/missionPlanningApi"; +import { validationToConflicts } from "./editorValidation"; +import type { EditorWork } from "./model/editorTypes"; + +function shoot(id: string, planModeId?: number): EditorWork { + return { + id, + kind: "shoot", + label: "Съёмка", + startMs: 0, + endMs: 1000, + origin: "plan", + planModeId + }; +} + +describe("validationToConflicts", () => { + it("maps backend conflicts onto editor work ids by planModeId", () => { + const works = [shoot("survey-7", 7), shoot("survey-9", 9)]; + const validation: MissionValidation = { + missionId: "m1", + conflicts: [ + { modeId: 7, rule: "TIME_OVERLAP", severity: "error", message: "Пересечение по времени", relatedModeId: 9 }, + { modeId: 7, rule: "TARGET_VISIBILITY", severity: "warning", message: "Вне зоны видимости" } + ] + }; + + const map = validationToConflicts(validation, works); + + expect(map["survey-7"]).toEqual(["Пересечение по времени", "Вне зоны видимости"]); + expect(map["survey-9"]).toBeUndefined(); + }); + + it("drops conflicts whose modeId has no matching work", () => { + const works = [shoot("survey-7", 7)]; + const validation: MissionValidation = { + missionId: "m1", + conflicts: [{ modeId: 42, rule: "TIME_OVERLAP", severity: "error", message: "x" }] + }; + + expect(validationToConflicts(validation, works)).toEqual({}); + }); + + it("returns an empty map for the stub (no conflicts)", () => { + const works = [shoot("survey-7", 7)]; + expect(validationToConflicts({ missionId: "m1", conflicts: [] }, works)).toEqual({}); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts new file mode 100644 index 0000000..a5e7e5e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts @@ -0,0 +1,29 @@ +import type { MissionValidation } from "../../api/missionPlanningApi"; +import type { EditorConflictMap } from "./editorConflicts"; +import type { EditorWork } from "./model/editorTypes"; + +/** + * Проецирует авторитетные конфликты бэкенда (валидация миссии-черновика, keyed по numeric modeId) + * на карту конфликтов редактора (keyed по workId). Включения редактора несут `planModeId` — + * идентификатор сохранённого режима в БД; по нему и сопоставляем. Конфликты на режимах, которых + * нет среди работ редактора (например, ещё не подгружены), молча отбрасываются. + * + * MVP: бэк-валидатор — заглушка и возвращает пустой список, поэтому сейчас результат пуст. Маппинг + * написан вперёд: когда правила появятся, UI начнёт подсвечивать конкретные включения без доработок. + */ +export function validationToConflicts(validation: MissionValidation, works: EditorWork[]): EditorConflictMap { + const workIdByModeId = new Map(); + for (const work of works) { + if (work.planModeId != null) { + workIdByModeId.set(work.planModeId, work.id); + } + } + + const result: EditorConflictMap = {}; + for (const conflict of validation.conflicts) { + const workId = workIdByModeId.get(conflict.modeId); + if (!workId) continue; + result[workId] = [...(result[workId] ?? []), conflict.message]; + } + return result; +} diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts index 7d9d868..c70f01b 100644 --- a/services/pcp-tgu-ops-ui/vite.config.ts +++ b/services/pcp-tgu-ops-ui/vite.config.ts @@ -22,6 +22,11 @@ export default defineConfig({ changeOrigin: true, rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "") }, + "/api/pcp-tgu": { + target: "http://localhost:7011", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, "") + }, "/api/stations": { target: "http://localhost:7009", changeOrigin: true From 2a841fc7b31ea2d81d3f8b0576349e335e1eadbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 13:59:52 +0300 Subject: [PATCH 42/79] =?UTF-8?q?tgu:=20DELETE=20/plans/{draftPlanId}=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BE=D1=82=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B0=20(?= =?UTF-8?q?=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=20+=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B0=20=C2=AB=D0=9E=D1=82=D0=BC=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=C2=BB=20=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BE=D1=82=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...TASK_tgu-plan-versioning-manual-reissue.md | 2 +- services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 18 ++++ .../src/features/tgu-editor/EditorToolbar.tsx | 17 +++- .../src/features/tgu-editor/TguEditorTab.tsx | 39 +++++++- .../controller/PlanController.kt | 16 +++- .../service/DeleteDraftUseCase.kt | 57 ++++++++++++ .../service/DeleteDraftUseCaseTest.kt | 91 +++++++++++++++++++ 7 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 47bb06c..19c822f 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -137,7 +137,7 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку. - `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ. 2. `POST /api/plans/{planId}/issue` — **боевой** эндпоинт ручной выдачи (поднять из `TestTguController` в `PlanController`), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную». -3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии). +3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии). ✅ СДЕЛАНО — [DeleteDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt) (404 нет / 422 не DRAFT / 204 успех; удаление клон-миссии best-effort, строка удаляется первой) + [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) `DELETE /api/plans/{draftPlanId}`; 4 unit-теста в `DeleteDraftUseCaseTest`. Фронт: `abandonDraft` + кнопка «Отмена» в редакторе + авто-отмена незавершённого черновика при смене плана/уходе (best-effort, не оставляет осиротевший DRAFT). 4. Цепочка/откат: переиспользуем `base_plan_id`/`chain_version`; восстановление версии = `draft(from=Pk)` → `issue`. --- diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index f28ba27..7c512b2 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -60,6 +60,24 @@ export async function promoteDraft(draftPlanId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}`; + let response: Response; + try { + response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Не удалось отменить черновик плана.", "http", response.status); + } +} + async function postJson(url: string, body: unknown, fallbackMessage: string): Promise { let response: Response; try { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 9feb5b1..3debbf1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -12,6 +12,7 @@ type EditorToolbarProps = { addedCount: number; dirty: boolean; applying: boolean; + canceling: boolean; windowLabel: string; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; @@ -19,6 +20,7 @@ type EditorToolbarProps = { onRedo: () => void; onCheck: () => void; onApply: () => void; + onCancel: () => void; onZoom: (factor: number) => void; onPan: (factor: number) => void; }; @@ -34,6 +36,7 @@ export function EditorToolbar({ addedCount, dirty, applying, + canceling, windowLabel, rightMode, onSetRightMode, @@ -41,6 +44,7 @@ export function EditorToolbar({ onRedo, onCheck, onApply, + onCancel, onZoom, onPan }: EditorToolbarProps) { @@ -107,7 +111,18 @@ export function EditorToolbar({ {checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"} - + )} + +
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 83b0b26..a6bfe9d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -13,7 +13,7 @@ import { validateMission, type SurveyRoute } from "../../api/missionPlanningApi"; -import { createDraft, promoteDraft } from "../../api/tguPlanApi"; +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"; @@ -111,6 +111,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); + const [canceling, setCanceling] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -416,6 +417,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setServiceConflicts({}); }, [selectedPlan]); + // Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик, + // не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409). + const draftPlanIdRef = useRef(undefined); + useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]); + useEffect(() => () => { + const stale = draftPlanIdRef.current; + if (stale) { + // best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён). + void abandonDraft(stale).catch(() => {}); + } + }, [selectedPlan]); + // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). useEffect(() => { const missionId = draftPlanId ?? selectedPlan?.planId; @@ -655,6 +668,28 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; + // «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает + // редактор к живому плану (read-only) и сбрасывает локальные правки. + const cancelDraft = async () => { + if (!draftPlanId) return; + setCanceling(true); + setApplyError(undefined); + setNotice(undefined); + try { + await abandonDraft(draftPlanId); + setDraftPlanId(undefined); + setServiceConflicts({}); + setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); + setSelectedWorkId(undefined); + setRightMode("none"); + setNotice("Черновик отменён — правки отброшены."); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик."); + } finally { + setCanceling(false); + } + }; + // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые // (planModeId нет) — только локально. @@ -745,6 +780,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, addedCount={addedCount} dirty={dirty} applying={applying} + canceling={canceling} windowLabel={formatDuration(window.toMs - window.fromMs)} rightMode={rightMode} onSetRightMode={(mode) => { @@ -757,6 +793,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onRedo={() => setHistory((current) => redo(current))} onCheck={runCheck} onApply={applyDraft} + onCancel={cancelDraft} onZoom={zoom} onPan={pan} /> diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 322d3e8..48e4ac6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -1,6 +1,8 @@ package space.nstart.pcp_tgu_service.controller /** Содержит методы API для чтения текущих планов, создания черновика и ручной выдачи плана. */ +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -13,6 +15,7 @@ import space.nstart.pcp_tgu_service.dto.CreateDraftResponse import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.service.CreateDraftUseCase +import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker import space.nstart.pcp_tgu_service.service.PlanQueryService import java.util.UUID @@ -22,7 +25,8 @@ import java.util.UUID class PlanController( private val planQueryService: PlanQueryService, private val issueDuePlanWorker: IssueDuePlanWorker, - private val createDraftUseCase: CreateDraftUseCase + private val createDraftUseCase: CreateDraftUseCase, + private val deleteDraftUseCase: DeleteDraftUseCase ) { /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @@ -70,4 +74,14 @@ class PlanController( @PostMapping("/{draftPlanId}/promote") fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse = issueDuePlanWorker.promoteDraftNow(draftPlanId) + + /** + * Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию, снимая блокировку «один + * открытый черновик на базу». 404 — плана нет; 422 — план не DRAFT (живые планы не трогаем). + */ + @DeleteMapping("/{draftPlanId}") + fun deleteDraft(@PathVariable draftPlanId: UUID): ResponseEntity { + deleteDraftUseCase.deleteDraft(draftPlanId) + return ResponseEntity.noContent().build() + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt new file mode 100644 index 0000000..81dba61 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt @@ -0,0 +1,57 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.util.UUID + +/** + * Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию. + * + * Снимает блокировку «один открытый черновик на базу» ([CreateDraftUseCase]) и подчищает + * осиротевший клон в mission-planing, если оператор начал правку, но не применил её. + * + * Порядок: сначала удаляем DRAFT-строку (локально, транзакционно), затем best-effort удаляем + * клон-миссию. При сбое удаления миссии остаётся лишь осиротевший клон (безвреден, GC позже), но + * блокирующей DRAFT-строки уже нет — оператор может сразу создать новый черновик. Обратный порядок + * рисковал бы DRAFT-строкой, ссылающейся на удалённую миссию (вечный 409 + 404 на правках). + * + * Удалять разрешено только статус DRAFT — живые/выданные планы этот путь не трогает (422). + */ +@Service +class DeleteDraftUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val missionPlaningClient: MissionPlaningClient +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional + fun deleteDraft(draftPlanId: UUID) { + val plan = plannedPlanRepository.findById(draftPlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $draftPlanId not found") } + if (plan.status != PlannedPlanStatus.DRAFT) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $draftPlanId не является черновиком (status=${plan.status}) — удалить можно только DRAFT" + ) + } + plannedPlanRepository.delete(plan) + log.info("Черновик {} удалён (отказ от правок)", draftPlanId) + + // Клон миссии: planId == missionId. Best-effort — сбой удаления клона не откатывает удаление + // DRAFT-строки (блокирующей строки уже нет; осиротевший клон безвреден, GC позже). + try { + missionPlaningClient.deleteMission(draftPlanId) + } catch (ex: Exception) { + log.error( + "Черновик {} удалён, но клон миссии не удалось удалить — остался осиротевший клон", + draftPlanId, ex + ) + } + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt new file mode 100644 index 0000000..166893b --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt @@ -0,0 +1,91 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class DeleteDraftUseCaseTest { + + private val store = linkedMapOf() + private val repo = mock(PlannedPlanRepository::class.java).also { repo -> + `when`(repo.findById(anyUuid())).thenAnswer { inv -> Optional.ofNullable(store[inv.arguments[0]]) } + doAnswer { inv -> store.remove((inv.arguments[0] as PlannedPlanEntity).planId); null } + .`when`(repo).delete(anyNonNull()) + } + private val client = mock(MissionPlaningClient::class.java) + private val useCase = DeleteDraftUseCase(repo, client) + + @Test + fun `deletes the DRAFT row and its cloned mission`() { + val draft = plan(PlannedPlanStatus.DRAFT) + store[draft.planId] = draft + + useCase.deleteDraft(draft.planId) + + assertFalse(store.containsKey(draft.planId)) + verify(client).deleteMission(draft.planId) + } + + @Test + fun `unknown plan yields 404`() { + val ex = assertThrows { useCase.deleteDraft(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + verify(client, never()).deleteMission(anyUuid()) + } + + @Test + fun `refuses to delete a non-DRAFT plan with 422 and touches nothing`() { + val live = plan(PlannedPlanStatus.AWAITING_LAYIN) + store[live.planId] = live + + val ex = assertThrows { useCase.deleteDraft(live.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(store.containsKey(live.planId)) + verify(client, never()).deleteMission(anyUuid()) + } + + @Test + fun `a failed mission delete still removes the DRAFT row (best-effort)`() { + val draft = plan(PlannedPlanStatus.DRAFT) + store[draft.planId] = draft + doThrow(RuntimeException("mission-planing down")).`when`(client).deleteMission(anyUuid()) + + useCase.deleteDraft(draft.planId) + + assertFalse(store.containsKey(draft.planId)) + } + + private fun plan(status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = "25544", + startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(3), + endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(4), + kppId = "KPP-1", + status = status + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} From 057452f19d587f5d78ef08aa148397b76a64b16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 14:12:21 +0300 Subject: [PATCH 43/79] =?UTF-8?q?pcp-tgu-ops-ui:=20fork-forward=20=D0=B2?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B1=D1=83=D0=B4?= =?UTF-8?q?=D1=83=D1=89=D0=B5=D0=B9=20=D0=97=D0=A0=D0=92=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D1=8B=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B5=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Селектор «Целевая ЗРВ» в EditorToolbar: текущий слот (replace, по умолчанию) либо будущая ЗРВ из проекции pcp-tgu-service (fetchSpacecraftPlans → GET /api/plans/{spacecraftId}, фильтр futureZrvWindows: PLANNED строго в будущем, кроме базы — та же проекция, что валидирует бэк). Выбор окна переводит обзор редактора и заставляет ensureDraft слать mode=fork-forward (naive-UTC окно + kpp); селектор блокируется после создания DRAFT. --- ...TASK_tgu-plan-versioning-manual-reissue.md | 6 +- services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 30 ++++++++ .../src/features/tgu-editor/EditorToolbar.tsx | 29 ++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 61 +++++++++++++++-- .../tgu-editor/editorForkForward.test.ts | 68 +++++++++++++++++++ .../features/tgu-editor/editorForkForward.ts | 59 ++++++++++++++++ .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 30 ++++++++ 7 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 19c822f..600365d 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -297,8 +297,12 @@ P2 = DRAFT (base_plan_id=P0, окно=W) > - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением. > - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные. > +> **fork-forward в UI — ✅ СДЕЛАНО:** +> - Селектор «Целевая ЗРВ» в [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Текущий слот (правка)» (replace, по умолчанию) ⊕ будущие ЗРВ КА. Список — из проекции pcp-tgu-service ([fetchSpacecraftPlans](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts), `GET /api/plans/{spacecraftId}`), отфильтрованной до PLANNED строго в будущем, кроме базы ([editorForkForward.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts) `futureZrvWindows` + 5 тестов). Это та же проекция, с которой бэк сверяет окно (`assertWindowMatchesProjection`). +> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): выбор окна переводит обзор редактора на него; `ensureDraft` шлёт `toCreateDraftRequest(targetWindow)` — fork-forward (naive-окно + kpp) при выбранной ЗРВ, иначе replace. Селектор блокируется после создания DRAFT (режим зафиксирован). Окна — naive-UTC (без зоны), как ждёт `LocalDateTime` бэка. +> - Приёмка: `tsc -b`, `vitest` (46) и `vite build` — зелёные. +> > **Вынесено в отдельные подзадачи (не входит в ядро):** -> - **fork-forward в UI**: выбор будущей ЗРВ (мини-панель/диалог) и `createDraft(mode=fork-forward, windowStart/Stop/kpp)` для `LAID_IN`-базы. Сейчас `ensureDraft` всегда шлёт `replace`; для заложенной базы бэк ответит 422 — сообщение показывается оператору. > - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote). > - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора. - `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`. diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index 7c512b2..9d6a0fe 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -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 { + 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). Триггерит клон миссии; возвращает diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 3debbf1..cc67e62 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -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({
+ {(futureWindows.length > 0 || targetWindowId) && ( + + )} +
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index a6bfe9d..72898ed 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -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(); + // Будущие ЗРВ КА (проекция PLANNED) и выбранная цель fork-forward. Пусто/undefined — правка + // текущего слота (replace); выбор окна заставляет ensureDraft создать черновик на следующей ЗРВ. + const [futureWindows, setFutureWindows] = useState([]); + const [targetWindowId, setTargetWindowId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); @@ -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 => { 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) => { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts new file mode 100644 index 0000000..8525623 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts @@ -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 { + 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" + }); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts new file mode 100644 index 0000000..d3fae74 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts @@ -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); +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 0b8a24a..680ec9a 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -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; From 9c2d8516bc159593faf909f03d98b41342c13e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 16:25:46 +0300 Subject: [PATCH 44/79] =?UTF-8?q?pcp:=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=B7=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B8=20=C2=AB=D0=9F=D0=BB=D0=B0=D0=BD=D1=8B=C2=BB=20(=D0=B1?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0/=D0=B1=D0=B5=D0=B7=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B8,=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B4=D0=B2=D1=83=D1=85=20=D0=97=D0=A0=D0=92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оператор берёт за основу прошлый план (предыстория) или начинает цепочку с нуля, набирает окно из двух реальных ЗРВ и создаёт план-кандидат; сразу показывается прогноз будущей цепочки от него. Рабочим (якорем) план становится после ручной отправки (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 + тесты. --- .../controller/MissionCloneDTOs.kt | 10 + .../controller/MissionController.kt | 9 + .../service/MissionPlaningService.kt | 36 +++ .../service/MissionEditModesTest.kt | 39 ++++ services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 59 +++++ .../src/features/tgu-editor/EditorToolbar.tsx | 29 --- .../src/features/tgu-editor/TguEditorTab.tsx | 62 +---- .../tgu-editor/editorForkForward.test.ts | 68 ------ .../features/tgu-editor/editorForkForward.ts | 59 ----- .../tgu-planning/CreateChainPlanDialog.tsx | 211 +++++++++++++++++ .../features/tgu-planning/TguPlanningPage.tsx | 52 +++++ .../src/features/tgu-planning/TguTimeline.tsx | 29 ++- .../tgu-planning/createChainWindows.test.ts | 64 +++++ .../tgu-planning/createChainWindows.ts | 65 ++++++ .../src/styles/tgu-planning.css | 84 +++++++ .../controller/PlanController.kt | 27 ++- .../pcp_tgu_service/dto/NewChainDTOs.kt | 56 +++++ .../integration/api/MissionPlaningClient.kt | 26 +++ .../service/CreateChainPlanUseCase.kt | 168 ++++++++++++++ .../service/IssueDuePlanWorker.kt | 28 +-- .../service/PlanCalculationService.kt | 11 + .../service/PlanProjectionService.kt | 27 +++ .../service/ZrvWindowMatcher.kt | 44 ++++ .../service/CreateChainPlanUseCaseTest.kt | 218 ++++++++++++++++++ .../service/IssueDuePlanWorkerOnDemandTest.kt | 21 +- .../service/ZrvWindowMatcherTest.kt | 54 +++++ 26 files changed, 1316 insertions(+), 240 deletions(-) delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts delete mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt index 9c942aa..c86005f 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt @@ -19,3 +19,13 @@ data class MissionCloneRequest( ) data class MissionCloneResponse(val newMissionId: UUID) + +/** Запрос создания пустой миссии (план новой цепочки «без истории»). */ +data class MissionBlankRequest( + /** КА (= noradId). */ + val satelliteId: Long, + /** Начало окна миссии (UTC naive). */ + val missionStart: LocalDateTime, + /** Конец окна миссии (UTC naive). */ + val missionStop: LocalDateTime +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index 4f4adea..0edc981 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -300,4 +300,13 @@ class MissionController( @PathVariable("mission_id") id: UUID, @RequestBody body: MissionCloneRequest ): MissionCloneResponse = missionPlaningService.cloneMission(id, body) + + @Operation( + summary = "Создать пустую миссию", + description = "Создаёт миссию без режимов на заданное окно — для плана новой цепочки «без истории». " + + "Возвращает newMissionId, который tgu-service использует как planId черновика." + ) + @PostMapping("/blank") + fun createBlankMission(@RequestBody body: MissionBlankRequest): MissionCloneResponse = + missionPlaningService.createBlankMission(body) } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index eb4a246..18b29aa 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -30,6 +30,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse import space.nstart.pcp.pcp_mission_planing_service.controller.MissionValidationResponse @@ -245,6 +246,41 @@ class MissionPlaningService( return MissionCloneResponse(newMissionId = newMission.missionId) } + /** + * Создаёт пустую миссию (без режимов) на заданное окно — для плана новой цепочки «без истории». + * Возвращает новый missionId, который tgu использует как planId черновика; включения оператор + * добавляет сам в редакторе. Создаётся миссия (status=NEW) и её основной план (planNumber=1). + */ + fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse { + if (!req.missionStop.isAfter(req.missionStart)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "missionStop must be after missionStart") + } + + val mission = missionRepository.save( + MissionEntity( + satelliteId = req.satelliteId, + station = null, + missionStart = req.missionStart, + missionStop = req.missionStop, + status = "NEW" + ) + ) + planRepository.save( + PlanEntity( + mission = mission, + planStart = req.missionStart, + planStop = req.missionStop, + planNumber = 1 + ) + ) + + logger.info( + "Создана пустая миссия {}: satelliteId={}, окно [{}–{}]", + mission.missionId, req.satelliteId, req.missionStart, req.missionStop + ) + return MissionCloneResponse(newMissionId = mission.missionId) + } + fun modesByMission(id: UUID): List { if (!missionRepository.existsById(id)) { throw notFound(id) diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt index f71d247..05c9ab0 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -218,6 +218,45 @@ class MissionEditModesTest { assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) } + // --- createBlankMission (план новой цепочки «без истории») --- + + @Test + fun `createBlankMission persists a mission and its plan with the given window`() { + val saved = arrayOfNulls(1) + `when`(missionRepository.save(anyNonNull())).thenAnswer { inv -> + val m = inv.arguments[0] as space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity + saved[0] = m + m + } + val start = LocalDateTime.of(2026, 6, 10, 12, 0) + val stop = LocalDateTime.of(2026, 6, 10, 12, 10) + + val response = service.createBlankMission( + space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest( + satelliteId = 25544, missionStart = start, missionStop = stop + ) + ) + + assertEquals(saved[0]!!.missionId, response.newMissionId) + assertEquals(25544L, saved[0]!!.satelliteId) + assertEquals(start, saved[0]!!.missionStart) + assertEquals(stop, saved[0]!!.missionStop) + assertEquals("NEW", saved[0]!!.status) + } + + @Test + fun `createBlankMission with stop not after start yields 400`() { + val t = LocalDateTime.of(2026, 6, 10, 12, 0) + val ex = assertThrows { + service.createBlankMission( + space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest( + satelliteId = 25544, missionStart = t, missionStop = t + ) + ) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + // --- helpers --- private fun survey(): SurveyModeEntity { diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index 9d6a0fe..456e91c 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -43,6 +43,33 @@ export type TguPlanResponse = { status: string; }; +/** «Сырое» окно ЗРВ КА (граница реальной зоны) из pcp-tgu-service. Времена — ISO с `Z`. */ +export type ZrvWindowResponse = { + startTime: string; + endTime: string; + kppId: string; +}; + +/** + * Запрос создания плана новой цепочки (вкладка «Планы»). Окно набирается из двух ЗРВ: + * `windowStart`/`windowKppId` — старт выбранной стартовой зоны, `windowStop` — конец конечной. + * Времена — UTC naive (ISO без зоны), как ждёт `LocalDateTime` бэка. `basePlanId` — предыстория + * (история тянется) либо `null` (цепочка без истории, пустой план). + */ +export type CreateChainPlanRequest = { + spacecraftId: string; + basePlanId: string | null; + windowStart: string; + windowStop: string; + windowKppId: string; +}; + +export type CreateChainPlanResponse = { + draftPlanId: string; + /** Прогноз будущей цепочки от созданного плана (what-if, состояние КА не тронуто). */ + projectedChain: TguPlanResponse[]; +}; + /** * Цепочка планов одного КА из pcp-tgu-service (GET /api/plans/{spacecraftId}): persisted планы плюс * будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет @@ -63,6 +90,38 @@ export async function fetchSpacecraftPlans(spacecraftId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}/zrv`; + 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 ZrvWindowResponse[]) : []; +} + +/** + * Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат + * (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки + * (promote) и приёмки. 404 — базы нет; 422 — окно не из ЗРВ / база чужого КА / нечисловой КА. + */ +export async function createChainPlan(request: CreateChainPlanRequest): Promise { + return postJson( + `${BASE}/api/plans/new-chain`, + request, + "Не удалось создать план новой цепочки." + ); +} + /** * Создаёт черновик плана (DRAFT) на основе базового плана * (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index cc67e62..3debbf1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -1,7 +1,5 @@ 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; @@ -15,10 +13,6 @@ 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; @@ -43,10 +37,6 @@ export function EditorToolbar({ dirty, applying, canceling, - futureWindows, - targetWindowId, - targetLocked, - onSelectTargetWindow, windowLabel, rightMode, onSetRightMode, @@ -93,25 +83,6 @@ export function EditorToolbar({
- {(futureWindows.length > 0 || targetWindowId) && ( - - )} -
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 72898ed..cacc962 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -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(); - // Будущие ЗРВ КА (проекция PLANNED) и выбранная цель fork-forward. Пусто/undefined — правка - // текущего слота (replace); выбор окна заставляет ensureDraft создать черновик на следующей ЗРВ. - const [futureWindows, setFutureWindows] = useState([]); - const [targetWindowId, setTargetWindowId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); @@ -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 => { 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) => { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts deleted file mode 100644 index 8525623..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 { - 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" - }); - }); -}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts deleted file mode 100644 index d3fae74..0000000 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts +++ /dev/null @@ -1,59 +0,0 @@ -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); -} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx new file mode 100644 index 0000000..81c7520 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from "react"; +import { + createChainPlan, + fetchZrvWindows, + promoteDraft, + type CreateChainPlanResponse +} from "../../api/tguPlanApi"; +import type { TguPlanUi } from "../../model/timelineTypes"; +import { TguApiError } from "../../model/tguTypes"; +import { formatUtcDateTime, parseUtc } from "../../utils/utcTime"; +import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; + +type CreateChainPlanDialogProps = { + spacecraftId: string; + /** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */ + plans: TguPlanUi[]; + onClose: () => void; + /** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */ + onSent: () => void; +}; + +/** + * Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает + * цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт план-кандидат. Сразу + * показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки + * («Отправить вручную» = promote) и последующей приёмки. + */ +export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) { + const [zrvOptions, setZrvOptions] = useState([]); + const [zrvLoading, setZrvLoading] = useState(true); + const [zrvError, setZrvError] = useState(); + + const [basePlanId, setBasePlanId] = useState(""); // "" — без истории + const [startId, setStartId] = useState(""); + const [endId, setEndId] = useState(""); + + const [creating, setCreating] = useState(false); + const [error, setError] = useState(); + const [result, setResult] = useState(); + const [sending, setSending] = useState(false); + + // Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков). + const baseOptions = useMemo( + () => + plans + .filter((plan) => plan.spacecraftId === spacecraftId && plan.status !== "PLANNED" && plan.status !== "DRAFT") + .sort((a, b) => b.startMs - a.startMs), + [plans, spacecraftId] + ); + + useEffect(() => { + let cancelled = false; + setZrvLoading(true); + setZrvError(undefined); + void fetchZrvWindows(spacecraftId) + .then((windows) => { + if (!cancelled) setZrvOptions(futureZrvOptions(windows, Date.now())); + }) + .catch((err) => { + if (!cancelled) setZrvError(err instanceof TguApiError ? err.message : "Не удалось получить ЗРВ КА."); + }) + .finally(() => { + if (!cancelled) setZrvLoading(false); + }); + return () => { + cancelled = true; + }; + }, [spacecraftId]); + + const start = zrvOptions.find((option) => option.id === startId); + const end = zrvOptions.find((option) => option.id === endId); + const canCreate = Boolean(start && end) && !creating; + + const create = async () => { + if (!start || !end) return; + setCreating(true); + setError(undefined); + try { + const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end); + setResult(await createChainPlan(request)); + } catch (err) { + setError( + err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план." + ); + } finally { + setCreating(false); + } + }; + + const send = async () => { + if (!result) return; + setSending(true); + setError(undefined); + try { + await promoteDraft(result.draftPlanId); + onSent(); + onClose(); + } catch (err) { + setError(err instanceof TguApiError ? err.message : "Не удалось отправить план."); + } finally { + setSending(false); + } + }; + + return ( +
+
+
+

Создать план

+ +
+ + {!result ? ( + <> + + + {zrvError &&
{zrvError}
} + {zrvLoading ? ( +
Загрузка ЗРВ…
+ ) : zrvOptions.length === 0 ? ( +
Нет будущих ЗРВ для этого КА.
+ ) : ( + <> + + + + + )} + + {error &&
{error}
} + +
+ + +
+ + ) : ( + <> +
+ План-кандидат создан (черновик {result.draftPlanId}). Прогноз будущей цепочки от него: +
+
    + {result.projectedChain.length === 0 ? ( +
  • Цепочка после этого плана пуста (нет дальнейших ЗРВ).
  • + ) : ( + result.projectedChain.map((plan) => ( +
  • + {formatUtcDateTime(parseUtc(plan.startTime))} – {formatUtcDateTime(parseUtc(plan.endTime))} ·{" "} + {plan.kppId} +
  • + )) + )} +
+ + {error &&
{error}
} + +
+ + +
+
+ После отправки план уходит в выдачу; рабочим (якорем цепочки) он станет после приёмки. +
+ + )} +
+
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 7a95b6f..b017bdf 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -5,6 +5,8 @@ import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelin import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; +import { fetchZrvWindows, type ZrvWindowResponse } from "../../api/tguPlanApi"; +import { CreateChainPlanDialog } from "./CreateChainPlanDialog"; import { TguPlanDetails } from "./TguPlanDetails"; import { TguPlanningLayout } from "./TguPlanningLayout"; import { TguSidebar } from "./TguSidebar"; @@ -36,6 +38,9 @@ export function TguPlanningPage() { const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); const [activeTab, setActiveTab] = useState("timeline"); + const [createChainOpen, setCreateChainOpen] = useState(false); + // ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться). + const [zrvWindows, setZrvWindows] = useState([]); // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии // и дальше держим смонтированной (скрытой через display:none), чтобы её // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось @@ -102,6 +107,33 @@ export function TguPlanningPage() { setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab))); }, [activeTab]); + // ЗРВ выбранного КА для подсветки на таймлайне. Сбой непрозрачен — просто нет подсветки. + useEffect(() => { + if (!selectedSpacecraftId) { + setZrvWindows([]); + return; + } + let cancelled = false; + void fetchZrvWindows(selectedSpacecraftId) + .then((windows) => { + if (!cancelled) setZrvWindows(windows); + }) + .catch(() => { + if (!cancelled) setZrvWindows([]); + }); + return () => { + cancelled = true; + }; + }, [selectedSpacecraftId]); + + const zrvBands = useMemo(() => { + if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined; + return { + spacecraftId: selectedSpacecraftId, + windows: zrvWindows.map((zrv) => ({ startMs: parseUtc(zrv.startTime), endMs: parseUtc(zrv.endTime) })) + }; + }, [selectedSpacecraftId, zrvWindows]); + // display:contents для активной вкладки сохраняет grid-раскладку shell // (внутренний
остаётся прямым grid-элементом), display:none скрывает остальные. const tabStyle = (tab: TguActiveTab) => @@ -194,10 +226,22 @@ export function TguPlanningPage() { }} />
+
+ +
{ setSelectedPlanId(planId); @@ -217,6 +261,14 @@ export function TguPlanningPage() { onClose={() => setSelectedPlanId(undefined)} />
+ {createChainOpen && selectedSpacecraftId && ( + setCreateChainOpen(false)} + onSent={() => void loadData(appliedRange)} + /> + )}
{visitedTabs.has("map") && ( diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx index 495e614..93113fa 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -15,15 +15,22 @@ import { platformLabel } from "./tguTimelineMapper"; import { statusStyle } from "./tguStatus"; import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime"; +type ZrvBands = { + spacecraftId: string; + windows: { startMs: number; endMs: number }[]; +}; + type TguTimelineProps = { rows: TimelineRow[]; range: TimelineRange; selectedPlanId?: string; invalidRange?: boolean; + /** ЗРВ выбранного КА — подсветка доступных зон на его строке. */ + zrvBands?: ZrvBands; onSelectPlan: (planId: string) => void; }; -export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) { +export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, onSelectPlan }: TguTimelineProps) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(1200); const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28); @@ -124,6 +131,26 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec NORAD {row.platform?.noradId || row.spacecraftId} + {zrvBands?.spacecraftId === row.spacecraftId && + zrvBands.windows.map((zrv, zrvIndex) => { + const from = Math.max(zrv.startMs, range.fromMs); + const to = Math.min(zrv.endMs, range.toMs); + if (to <= from) return null; + const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth); + const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth)); + return ( + + ЗРВ {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)} + + ); + })} ); })} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts new file mode 100644 index 0000000..e3aae9c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; +import type { ZrvWindowResponse } from "../../api/tguPlanApi"; + +const NOW = Date.UTC(2026, 5, 3, 12, 0, 0); + +function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse { + return { startTime: startIso, endTime: endIso, kppId }; +} + +describe("futureZrvOptions", () => { + it("keeps only future zones and sorts by start", () => { + const windows = [ + zrv("2026-06-03T15:00:00Z", "2026-06-03T15:10:00Z"), + zrv("2026-06-03T11:00:00Z", "2026-06-03T11:10:00Z"), // в прошлом → отбросить + zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z") + ]; + const options = futureZrvOptions(windows, NOW); + expect(options.map((o) => o.startMs)).toEqual([ + Date.UTC(2026, 5, 3, 13, 0, 0), + Date.UTC(2026, 5, 3, 15, 0, 0) + ]); + }); + + it("produces naive UTC (no zone) start/stop strings", () => { + const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")], NOW); + expect(option.start).toBe("2026-06-03T13:00:00"); + expect(option.stop).toBe("2026-06-03T13:10:00"); + expect(option.kppId).toBe("KPP-1"); + }); +}); + +describe("buildChainPlanRequest", () => { + const start: ZrvOption = { + id: "a", startMs: Date.UTC(2026, 5, 3, 13, 0, 0), endMs: Date.UTC(2026, 5, 3, 13, 10, 0), + start: "2026-06-03T13:00:00", stop: "2026-06-03T13:10:00", kppId: "KPP-1" + }; + const end: ZrvOption = { + id: "b", startMs: Date.UTC(2026, 5, 3, 15, 0, 0), endMs: Date.UTC(2026, 5, 3, 15, 10, 0), + start: "2026-06-03T15:00:00", stop: "2026-06-03T15:10:00", kppId: "KPP-2" + }; + + it("spans from start zone begin to end zone finish and keeps the start kpp", () => { + const req = buildChainPlanRequest("25544", "base-1", start, end); + expect(req).toEqual({ + spacecraftId: "25544", + basePlanId: "base-1", + windowStart: "2026-06-03T13:00:00", + windowStop: "2026-06-03T15:10:00", + windowKppId: "KPP-1" + }); + }); + + it("allows a single-zone window (start == end zone)", () => { + const req = buildChainPlanRequest("25544", null, start, start); + expect(req.windowStart).toBe("2026-06-03T13:00:00"); + expect(req.windowStop).toBe("2026-06-03T13:10:00"); + expect(req.basePlanId).toBeNull(); + }); + + it("throws when the end zone finishes before the start begins", () => { + expect(() => buildChainPlanRequest("25544", null, end, start)).toThrow(); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts new file mode 100644 index 0000000..e10dfe2 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts @@ -0,0 +1,65 @@ +import { parseUtc } from "../../utils/utcTime"; +import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi"; + +/** + * Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из + * конечной — конец окна. `start`/`stop` — naive UTC (ISO без зоны), как ждёт `LocalDateTime` бэка; + * `startMs`/`endMs` — для сортировки/сравнения и подсветки на таймлайне. + */ +export type ZrvOption = { + /** Детерминированный id зоны (для value в select и key). */ + id: string; + startMs: number; + endMs: number; + /** Naive UTC начала зоны. */ + start: string; + /** Naive UTC конца зоны. */ + stop: string; + kppId: string; +}; + +/** Будущие ЗРВ-зоны (строго позже nowMs), по возрастанию начала. */ +export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] { + return windows + .map((zrv) => { + const startMs = parseUtc(zrv.startTime); + const endMs = parseUtc(zrv.endTime); + return { + id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`, + startMs, + endMs, + start: toNaiveUtc(startMs), + stop: toNaiveUtc(endMs), + kppId: zrv.kppId + }; + }) + .filter((option) => option.startMs > nowMs) + .sort((a, b) => a.startMs - b.startMs); +} + +/** + * Собирает тело запроса создания плана новой цепочки из выбора оператора. Окно — от начала стартовой + * зоны до конца конечной; `basePlanId` — предыстория (или null для цепочки без истории). Бросает, + * если конец окна не позже начала (конечная зона раньше стартовой). + */ +export function buildChainPlanRequest( + spacecraftId: string, + basePlanId: string | null, + start: ZrvOption, + end: ZrvOption +): CreateChainPlanRequest { + if (end.endMs <= start.startMs) { + throw new Error("Конец окна должен быть позже начала: выберите конечную ЗРВ не раньше стартовой."); + } + return { + spacecraftId, + basePlanId, + windowStart: start.start, + windowStop: end.stop, + windowKppId: start.kppId + }; +} + +function toNaiveUtc(ms: number): string { + return new Date(ms).toISOString().slice(0, 19); +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 6cb5cd7..2f967b3 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -1358,3 +1358,87 @@ grid-template-columns: 1fr; } } + +/* «Создать план»: кнопка над таймлайном + модальный диалог новой цепочки. */ +.tgu-center__actions { + display: flex; + justify-content: flex-end; + padding: 0.5rem 0.75rem 0; +} + +.tgu-chain-dialog__backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); +} + +.tgu-chain-dialog { + width: min(34rem, calc(100vw - 2rem)); + max-height: calc(100vh - 3rem); + overflow: auto; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1rem 1.1rem 1.1rem; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg-panel); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} + +.tgu-chain-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.tgu-chain-dialog__header h2 { + margin: 0; + font-size: 1.05rem; +} + +.tgu-chain-dialog__field { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.82rem; +} + +.tgu-chain-dialog__field > span { + color: var(--text-muted, #9fb0b3); +} + +.tgu-chain-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.3rem; +} + +.tgu-chain-dialog__chain { + margin: 0; + padding-left: 1.1rem; + font-size: 0.8rem; + line-height: 1.5; + max-height: 12rem; + overflow: auto; +} + +.tgu-chain-dialog__hint { + font-size: 0.78rem; + color: var(--text-muted, #9fb0b3); +} + +/* ЗРВ выбранного КА на таймлайне — лёгкая подсветка доступных зон. */ +.tgu-timeline__zrv { + fill: rgba(90, 170, 200, 0.16); + stroke: rgba(90, 170, 200, 0.5); + stroke-width: 1; + rx: 2; + pointer-events: all; +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 48e4ac6..0c5ea68 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -10,13 +10,18 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest +import space.nstart.pcp_tgu_service.dto.CreateChainPlanResponse import space.nstart.pcp_tgu_service.dto.CreateDraftRequest import space.nstart.pcp_tgu_service.dto.CreateDraftResponse import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.dto.ZrvWindowResponse +import space.nstart.pcp_tgu_service.service.CreateChainPlanUseCase import space.nstart.pcp_tgu_service.service.CreateDraftUseCase import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker +import space.nstart.pcp_tgu_service.service.PlanProjectionService import space.nstart.pcp_tgu_service.service.PlanQueryService import java.util.UUID @@ -26,7 +31,9 @@ class PlanController( private val planQueryService: PlanQueryService, private val issueDuePlanWorker: IssueDuePlanWorker, private val createDraftUseCase: CreateDraftUseCase, - private val deleteDraftUseCase: DeleteDraftUseCase + private val deleteDraftUseCase: DeleteDraftUseCase, + private val createChainPlanUseCase: CreateChainPlanUseCase, + private val planProjectionService: PlanProjectionService ) { /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @@ -42,6 +49,24 @@ class PlanController( ): List = planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) + /** + * «Сырые» ЗРВ КА из последнего снапшота (границы реальных зон) — из них оператор набирает окно + * нового плана во вкладке «Планы». Пусто, если снапшота нет. + */ + @GetMapping("/{spacecraftId}/zrv") + fun getZrvWindows(@PathVariable spacecraftId: String): List = + planProjectionService.rawZrvWindows(spacecraftId).map(ZrvWindowResponse::from) + + /** + * Создаёт план новой цепочки (действие «Создать план» во вкладке «Планы»): окно из двух ЗРВ, + * опциональная база-предыстория (или цепочка без истории). Возвращает план-кандидат (DRAFT) и + * прогноз будущей цепочки от него. План становится рабочим после ручной отправки (promote) и + * приёмки. 404 — базы нет; 422 — окно не из ЗРВ / база чужого КА / нечисловой spacecraftId. + */ + @PostMapping("/new-chain") + fun createChainPlan(@RequestBody body: CreateChainPlanRequest): CreateChainPlanResponse = + createChainPlanUseCase.createChainPlan(body) + /** * Создаёт черновик плана (DRAFT) на основе basePlanId. * Режим replace — черновик в том же слоте (база ещё правима). diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt new file mode 100644 index 0000000..f313778 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt @@ -0,0 +1,56 @@ +package space.nstart.pcp_tgu_service.dto + +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.UUID + +/** + * «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — UTC ISO с `Z` + * (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC). + */ +data class ZrvWindowResponse( + val startTime: OffsetDateTime, + val endTime: OffsetDateTime, + val kppId: String +) { + companion object { + fun from(window: CalculatedPlan): ZrvWindowResponse = + ZrvWindowResponse( + startTime = window.startTime.atOffset(ZoneOffset.UTC), + endTime = window.endTime.atOffset(ZoneOffset.UTC), + kppId = window.kppId + ) + } +} + +/** + * Запрос создания плана новой цепочки (вкладка «Планы», действие оператора «Создать план»). + * + * Окно набирается из двух реальных ЗРВ: [windowStart]/[windowKppId] — начало (старт и КПП берутся + * у одной зоны), [windowStop] — конец (конец другой, не раньше начала зоны). [basePlanId] — + * «предыстория»: прошлый план КА, чьё содержимое клонируется в окно и от которого тянется история + * (`base_plan_id`). Если `null` — цепочка без истории: создаётся пустой план, наполняемый с нуля. + */ +data class CreateChainPlanRequest( + /** КА (= noradId строкой); обязателен для сценария «без базы». */ + val spacecraftId: String, + /** Прошлый план-предыстория или null для цепочки без истории. */ + val basePlanId: UUID? = null, + /** Начало окна (UTC naive) — старт выбранной стартовой ЗРВ. */ + val windowStart: LocalDateTime, + /** Конец окна (UTC naive) — конец выбранной конечной ЗРВ. */ + val windowStop: LocalDateTime, + /** КПП стартовой ЗРВ. */ + val windowKppId: String +) + +/** + * Ответ создания плана новой цепочки: id плана-кандидата (DRAFT, применяется через `promote`) и + * прогноз будущей цепочки от его окна (what-if, состояние КА не тронуто). + */ +data class CreateChainPlanResponse( + val draftPlanId: UUID, + val projectedChain: List +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt index dd2f115..b5c4bed 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt @@ -17,6 +17,12 @@ data class MissionCloneRequest( data class MissionCloneResponse(val newMissionId: UUID) +data class MissionBlankRequest( + val satelliteId: Long, + val missionStart: LocalDateTime, + val missionStop: LocalDateTime +) + @Component class MissionPlaningClient(properties: MissionPlaningProperties) { @@ -47,6 +53,26 @@ class MissionPlaningClient(properties: MissionPlaningProperties) { "Пустой ответ mission-planing на клон миссии $missionId" ) + /** Создаёт пустую миссию на окно (план новой цепочки «без истории»). */ + fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse = + webClient.post() + .uri("/api/missions/blank") + .bodyValue(req) + .retrieve() + .onStatus({ it.isError }) { resp -> + resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body -> + val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY + else HttpStatus.valueOf(resp.statusCode().value()) + ResponseStatusException(status, "Создание пустой миссии не удалось (${resp.statusCode()}): $body") + } + } + .bodyToMono(MissionCloneResponse::class.java) + .block(timeout) + ?: throw ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "Пустой ответ mission-planing на создание пустой миссии" + ) + /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */ fun deleteMission(missionId: UUID) { webClient.delete() diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt new file mode 100644 index 0000000..b754e59 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt @@ -0,0 +1,168 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest +import space.nstart.pcp_tgu_service.dto.CreateChainPlanResponse +import space.nstart.pcp_tgu_service.dto.DraftMode +import space.nstart.pcp_tgu_service.dto.PlanResponse +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionBlankRequest +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.UUID + +/** + * Создаёт план новой цепочки (вкладка «Планы», действие оператора «Создать план») и сразу считает + * прогноз будущей цепочки от него. + * + * Сценарий: КА что-то случилось / нужно перепланировать — оператор берёт за основу прошлый план + * («предыстория») или начинает с нуля, выбирает окно из двух реальных ЗРВ и получает план-кандидат + * (DRAFT). Кандидат не меняет состояние КА: он становится рабочим только после ручной отправки + * ([IssueDuePlanWorker.promoteDraftNow]) и приёмки (якорение цепочки в acceptPlan). + * + * Два режима наполнения: + * - **с базой** (`basePlanId != null`): клон включений прошлого плана, пересёкшихся с окном + * (тот же fork-forward-клон), `base_plan_id = basePlanId` (история тянется); + * - **без базы**: пустая миссия, `base_plan_id = null` (новая история с нуля). + * + * Атомарность как в [CreateDraftUseCase]: DRAFT сохраняется только после успешного создания миссии; + * при сбое сохранения — компенсация (удаление миссии). + */ +@Service +class CreateChainPlanUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val planProjectionService: PlanProjectionService, + private val missionPlaningClient: MissionPlaningClient, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse { + val now = LocalDateTime.now(ZoneOffset.UTC) + + if (!req.windowStop.isAfter(req.windowStart)) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "windowStop должен быть позже windowStart") + } + if (!req.windowStart.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStart (${req.windowStart}) должен быть строго в будущем" + ) + } + + // База (если задана) должна принадлежать тому же КА — иначе клон чужого содержимого. + val base: PlannedPlanEntity? = req.basePlanId?.let { basePlanId -> + val plan = plannedPlanRepository.findById(basePlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Базовый план $basePlanId не найден") } + if (plan.spacecraftId != req.spacecraftId) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Базовый план $basePlanId принадлежит другому КА (${plan.spacecraftId}, ожидался ${req.spacecraftId})" + ) + } + if (plan.status == PlannedPlanStatus.DRAFT) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Нельзя взять за основу черновик") + } + plan + } + + // Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой). + val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId) + if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно [${req.windowStart}–${req.windowStop}, kpp=${req.windowKppId}] не набирается из реальных ЗРВ КА ${req.spacecraftId}" + ) + } + + // Создаём миссию-носитель содержимого: клон базы в окно либо пустая миссия. + val newMissionId = if (base != null) { + missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest( + mode = DraftMode.FORK_FORWARD, + windowStart = req.windowStart, + windowStop = req.windowStop + ) + ).newMissionId + } else { + missionPlaningClient.createBlankMission( + MissionBlankRequest( + satelliteId = parseSatelliteId(req.spacecraftId), + missionStart = req.windowStart, + missionStop = req.windowStop + ) + ).newMissionId + } + + val draft = saveDraftWithCompensation(req, base, newMissionId) + + // Прогноз будущей цепочки от окна нового плана (what-if, состояние КА не тронуто). + val projected = planProjectionService + .projectAfterWindow(req.spacecraftId, draft.toCalculatedPlan()) + .map(PlanResponse::projected) + + return CreateChainPlanResponse(draftPlanId = draft.planId, projectedChain = projected) + } + + private fun saveDraftWithCompensation( + req: CreateChainPlanRequest, + base: PlannedPlanEntity?, + newMissionId: UUID + ): PlannedPlanEntity { + // Снимок версии цепочки — оптимистичный токен для promote (как в CreateDraftUseCase). + val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(req.spacecraftId) + .map { it.chainVersion } + .orElse(0L) + + return try { + val draft = plannedPlanRepository.save( + PlannedPlanEntity( + planId = newMissionId, + spacecraftId = req.spacecraftId, + startTime = req.windowStart, + endTime = req.windowStop, + kppId = req.windowKppId, + status = PlannedPlanStatus.DRAFT, + basePlanId = base?.planId, + chainVersion = baseChainVersion + ) + ) + log.info( + "Создан план новой цепочки: draftPlanId={}, basePlanId={}, spacecraftId={}, окно [{}–{}], chainVersion={}", + draft.planId, base?.planId, req.spacecraftId, req.windowStart, req.windowStop, baseChainVersion + ) + draft + } catch (ex: Exception) { + log.error( + "Сбой сохранения плана новой цепочки {} после создания миссии; компенсация (удалить миссию)", + newMissionId, ex + ) + try { + missionPlaningClient.deleteMission(newMissionId) + } catch (compEx: Exception) { + log.error("Компенсация не удалась: миссия {} осталась осиротевшей", newMissionId, compEx) + } + throw ex + } + } + + private fun parseSatelliteId(spacecraftId: String): Long = + spacecraftId.toLongOrNull() + ?: throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "spacecraftId '$spacecraftId' не числовой — нельзя создать пустую миссию без базы" + ) + + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = startTime, endTime = endTime, kppId = kppId) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index e4985c4..9bf7727 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -230,36 +230,22 @@ class IssueDuePlanWorker( /** * Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно - * по-прежнему соответствовать реальной будущей ЗРВ. Для fork-forward слот есть в проекции; - * для replace слот = окно заменяемого базового плана, которое проекция (заякоренная ПОСЛЕ - * плана в полёте) не возвращает, поэтому окно базы учитывается явно. Сверка по КПП и времени - * с точностью до секунды (как при создании черновика). Не совпало → снапшот сместился → 422. + * по-прежнему набираться из реальных ЗРВ снапшота. Сверка против «сырых» окон ЗРВ единым + * правилом [ZrvWindowMatcher] (начало+КПП одной зоны, конец — другой) — оно покрывает и окно + * из одной ЗРВ (replace/fork-forward), и окно из двух ЗРВ (план новой цепочки). Не набирается → + * снапшот сместился → 422. */ private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) { - val candidates = planProjectionService.project(plan.spacecraftId) - .map { Triple(it.kppId, it.startTime, it.endTime) } - .toMutableList() - plan.basePlanId - ?.let { plannedPlanRepository.findById(it).orElse(null) } - ?.let { candidates.add(Triple(it.kppId, it.startTime, it.endTime)) } - - val matches = candidates.any { (kppId, start, end) -> - kppId == plan.kppId && - diffSeconds(start, plan.startTime) < 1 && - diffSeconds(end, plan.endTime) < 1 - } - if (!matches) { + val rawWindows = planProjectionService.rawZrvWindows(plan.spacecraftId) + if (!ZrvWindowMatcher.matches(rawWindows, plan.startTime, plan.endTime, plan.kppId)) { throw ResponseStatusException( HttpStatus.UNPROCESSABLE_ENTITY, "Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " + - "больше не соответствует ни одной будущей ЗРВ — снапшот сместился, пересоздайте черновик" + "больше не набирается из реальных ЗРВ — снапшот сместился, пересоздайте черновик" ) } } - private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = - Math.abs(java.time.Duration.between(a, b).toSeconds()) - /** * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException], * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия: diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index 0f82246..65cea70 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -24,6 +24,17 @@ class PlanCalculationService( private val planningProperties: PlanningProperties ) { + /** + * Возвращает «сырые» окна ЗРВ КА (наблюдательные окна точек закладки как есть), без построения + * цепочки и без подбора целевой длительности: одно окно = один [CalculatedPlan] `[startTime, endTime]` + * с `kppId = pointId`. Из этих окон оператор вручную набирает план новой цепочки (старт и конец — + * границы реальных ЗРВ), и против них же валидируется выбранное окно. + */ + fun rawWindows(spacecraftPoints: SpacecraftPoints): List = + buildCandidates(spacecraftPoints).map { candidate -> + candidate.toCalculatedPlan(spacecraftPoints.spacecraftId, candidate.endTime) + } + /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt index 1e9b09a..de7316f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt @@ -42,6 +42,33 @@ class PlanProjectionService( } } + /** + * «Сырые» окна ЗРВ КА из последнего снапшота — границы реальных зон, из которых оператор + * вручную набирает окно нового плана. Пусто, если снапшота нет. В отличие от [project] это + * не цепочка, а исходные окна (без подбора длительности и нахлёста). + */ + @Transactional(readOnly = true) + fun rawZrvWindows(spacecraftId: String): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + return planCalculationService.rawWindows(points) + } + + /** + * Прогноз будущей цепочки, если новый план занял окно [afterPlan]: считается тем же + * [PlanCalculationService.calculatePlansAfter] поверх текущего снапшота ЗРВ, но якорь — не + * состояние КА, а переданное окно. Используется при создании плана новой цепочки, чтобы сразу + * показать оператору, как ляжет цепочка «от него». Состояние КА не трогается (what-if). + */ + @Transactional(readOnly = true) + fun projectAfterWindow(spacecraftId: String, afterPlan: CalculatedPlan): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + return planCalculationService.calculatePlansAfter(points, afterPlan) + } + /** * Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка. * diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt new file mode 100644 index 0000000..0157838 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt @@ -0,0 +1,44 @@ +package space.nstart.pcp_tgu_service.service + +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import java.time.Duration +import java.time.LocalDateTime + +/** + * Сверка набранного оператором окна плана с «сырыми» ЗРВ КА. + * + * Окно собирается из двух реальных зон: старт (и КПП) берётся у одной ЗРВ, конец — у другой (не + * раньше старта). Поэтому окно валидно, если: + * - начало совпадает (с точностью до секунды) с началом какой-то ЗРВ **этого же КПП**, и + * - конец совпадает с концом какой-то ЗРВ, и + * - конец строго позже начала. + * + * Случай «одна ЗРВ» (текущие replace/fork-forward, где старт и конец — границы одной зоны) — частный + * случай этого правила, поэтому проверка единая для создания плана и для контроля слота на promote. + */ +object ZrvWindowMatcher { + + /** Допуск сравнения времён — компенсирует погрешность сериализации ISO-строк на фронте. */ + private const val TOLERANCE_SECONDS = 1L + + fun matches( + windows: List, + windowStart: LocalDateTime, + windowStop: LocalDateTime, + windowKppId: String + ): Boolean { + if (!windowStop.isAfter(windowStart)) { + return false + } + val startMatches = windows.any { zrv -> + zrv.kppId == windowKppId && diffSeconds(zrv.startTime, windowStart) < TOLERANCE_SECONDS + } + val stopMatches = windows.any { zrv -> + diffSeconds(zrv.endTime, windowStop) < TOLERANCE_SECONDS + } + return startMatches && stopMatches + } + + private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = + Math.abs(Duration.between(a, b).toSeconds()) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt new file mode 100644 index 0000000..bec8b3b --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt @@ -0,0 +1,218 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.integration.api.MissionBlankRequest +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class CreateChainPlanUseCaseTest { + + private val spacecraftId = "25544" + private val kppId = "KPP-1" + + @Test + fun `with base clones mission into the window and creates a DRAFT with basePlanId and chain preview`() { + val base = plan(time(1), time(2), PlannedPlanStatus.LAID_IN) + val newMissionId = UUID.randomUUID() + val windowStart = time(5) + val windowStop = time(6) + val chainTail = listOf(CalculatedPlan(spacecraftId, time(7), time(8), kppId)) + val fixture = fixture(base = base, cloneResult = newMissionId, projectedTail = chainTail, window = windowStart to windowStop) + + val response = fixture.useCase.createChainPlan( + CreateChainPlanRequest(spacecraftId, base.planId, windowStart, windowStop, kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + // Клон по окну (fork-forward-стиль), пустая миссия не создаётся. + verify(fixture.client).cloneMission(anyUuid(), anyNonNull()) + verify(fixture.client, never()).createBlankMission(anyNonNull()) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertEquals(base.planId, saved.basePlanId) + assertEquals(windowStart, saved.startTime) + assertEquals(windowStop, saved.endTime) + assertEquals(kppId, saved.kppId) + // Прогноз цепочки от окна вернулся. + assertEquals(1, response.projectedChain.size) + assertEquals(PlannedPlanStatus.PLANNED, response.projectedChain.first().status) + } + + @Test + fun `without base creates a blank mission and a DRAFT with null basePlanId`() { + val newMissionId = UUID.randomUUID() + val fixture = fixture(base = null, blankResult = newMissionId) + + val response = fixture.useCase.createChainPlan( + CreateChainPlanRequest(spacecraftId, basePlanId = null, time(5), time(6), kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + verify(fixture.client).createBlankMission(anyNonNull()) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertNull(saved.basePlanId) + } + + @Test + fun `window not matching any raw ZRV yields 422 and no mission is created`() { + val fixture = fixture(base = null, rawWindows = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId))) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).createBlankMission(anyNonNull()) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + @Test + fun `base belonging to another spacecraft yields 422`() { + val base = plan(time(1), time(2), PlannedPlanStatus.LAID_IN).apply { spacecraftId = "99999" } + val fixture = fixture(base = base) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, base.planId, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `windowStop not after windowStart yields 422`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(6), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `windowStart in the past yields 422`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(-1), time(1), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `unknown base plan yields 404`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, UUID.randomUUID(), time(5), time(6), kppId)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `non-numeric spacecraftId without base yields 422`() { + val fixture = fixture(base = null, spacecraftIdOverride = "SAT-A") + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest("SAT-A", null, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() { + val newMissionId = UUID.randomUUID() + val fixture = fixture(base = null, blankResult = newMissionId, failSave = true) + assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId)) + } + verify(fixture.client, times(1)).deleteMission(newMissionId) + } + + // --- helpers --- + + private class Fixture( + val useCase: CreateChainPlanUseCase, + val client: MissionPlaningClient, + private val savedHolder: Array + ) { + val lastSaved: PlannedPlanEntity? get() = savedHolder[0] + } + + private fun fixture( + base: PlannedPlanEntity?, + cloneResult: UUID = UUID.randomUUID(), + blankResult: UUID = UUID.randomUUID(), + rawWindows: List? = null, + projectedTail: List = emptyList(), + failSave: Boolean = false, + spacecraftIdOverride: String = spacecraftId, + window: Pair = time(5) to time(6) + ): Fixture { + val repo = mock(PlannedPlanRepository::class.java) + `when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base)) + val saved = arrayOfNulls(1) + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + if (failSave) throw RuntimeException("DB down") + val e = inv.arguments[0] as PlannedPlanEntity + saved[0] = e + e + } + + val projectionService = mock(PlanProjectionService::class.java) + // По умолчанию сырые ЗРВ совпадают с запрашиваемым окном → валидно. + val defaultRaw = listOf(CalculatedPlan(spacecraftIdOverride, window.first, window.second, kppId)) + `when`(projectionService.rawZrvWindows(spacecraftIdOverride)).thenReturn(rawWindows ?: defaultRaw) + `when`(projectionService.projectAfterWindow(anyNonNull(), anyNonNull())).thenReturn(projectedTail) + + val client = mock(MissionPlaningClient::class.java) + `when`(client.cloneMission(anyUuid(), anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) + `when`(client.createBlankMission(anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = blankResult)) + + val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepo.findById(spacecraftIdOverride)) + .thenReturn(Optional.of(SpacecraftPlanningStateEntity(spacecraftId = spacecraftIdOverride, chainVersion = 0))) + + val useCase = CreateChainPlanUseCase(repo, projectionService, client, stateRepo) + return Fixture(useCase, client, saved) + } + + private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status + ) + + // Фиксированная база времени на инстанс теста: time() детерминирован, окно в запросе и в + // моке сырых ЗРВ совпадает байт-в-байт (без дрейфа now() между вызовами). + private val t0: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + private fun time(hour: Int): LocalDateTime = t0.plusHours(hour.toLong()) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index e941485..a0b64ec 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -297,7 +297,7 @@ class IssueDuePlanWorkerOnDemandTest { val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) planStore[draft.planId] = draft val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) - val worker = worker(planStore, attemptStore, state) + val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) worker.promoteDraftNow(draft.planId) @@ -365,7 +365,8 @@ class IssueDuePlanWorkerOnDemandTest { val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) planStore[draft.planId] = draft val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) - val worker = worker(planStore, attemptStore, state) + // Слот валиден (окно совпадает с ЗРВ) — отказ именно из-за порога заморозки, а не слота. + val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } @@ -389,7 +390,8 @@ class IssueDuePlanWorkerOnDemandTest { activePlanId = UUID.randomUUID(), activeAttemptId = UUID.randomUUID() ) - val worker = worker(planStore, attemptStore, state) + // Слот валиден — отказ именно 409 (чужая выдача), а не 422 (слот). + val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } @@ -440,7 +442,8 @@ class IssueDuePlanWorkerOnDemandTest { attemptStore: MutableMap, state: SpacecraftPlanningStateEntity, projection: List = emptyList(), - projectionService: PlanProjectionService = projectionReturning(projection) + rawWindows: List = emptyList(), + projectionService: PlanProjectionService = projectionReturning(projection, rawWindows) ): IssueDuePlanWorker { val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( @@ -464,12 +467,20 @@ class IssueDuePlanWorkerOnDemandTest { ) } - private fun projectionReturning(projection: List): PlanProjectionService { + private fun projectionReturning( + projection: List, + rawWindows: List = emptyList() + ): PlanProjectionService { val service = mock(PlanProjectionService::class.java) `when`(service.project(spacecraftId)).thenReturn(projection) + `when`(service.rawZrvWindows(spacecraftId)).thenReturn(rawWindows) return service } + /** Сырое окно ЗРВ, совпадающее со слотом черновика (старт+КПП и конец) — слот проходит валидацию. */ + private fun rawWindowFor(plan: PlannedPlanEntity): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId) + private fun plannedRepository(store: MutableMap): PlannedPlanRepository { val repository = mock(PlannedPlanRepository::class.java) `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt new file mode 100644 index 0000000..fdb5c78 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt @@ -0,0 +1,54 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import java.time.LocalDateTime +import java.time.ZoneOffset + +class ZrvWindowMatcherTest { + + private val sc = "25544" + private val t0 = LocalDateTime.now(ZoneOffset.UTC).withNano(0) + private fun at(min: Long) = t0.plusMinutes(min) + + private val zrvA = CalculatedPlan(sc, at(0), at(10), "KPP-1") + private val zrvB = CalculatedPlan(sc, at(100), at(110), "KPP-2") + private val windows = listOf(zrvA, zrvB) + + @Test + fun `window spanning two different ZRV (start of A, end of B) matches`() { + assertTrue(ZrvWindowMatcher.matches(windows, at(0), at(110), "KPP-1")) + } + + @Test + fun `single-ZRV window (start and end of the same zone) matches`() { + assertTrue(ZrvWindowMatcher.matches(windows, at(0), at(10), "KPP-1")) + } + + @Test + fun `start not aligned to any ZRV start does not match`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(3), at(110), "KPP-1")) + } + + @Test + fun `start kpp mismatch does not match even if time aligns`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(110), "KPP-2")) + } + + @Test + fun `end not aligned to any ZRV end does not match`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(105), "KPP-1")) + } + + @Test + fun `stop not after start does not match`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(0), "KPP-1")) + } + + @Test + fun `empty windows never match`() { + assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1")) + } +} From 0e534c5bd53b58023e3ef4738795c49429885a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 16:26:03 +0300 Subject: [PATCH 45/79] =?UTF-8?q?docs:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20TASK-=D0=B4=D0=BE=D0=BA=D0=B8=20(dat?= =?UTF-8?q?etime-utc,=20tgu-editor-integration,=20plan-on-demand-redesign)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tasks/TASK_datetime-utc-consistency.md | 198 ------------ docs/tasks/TASK_tgu-editor-integration.md | 219 ------------- .../tasks/TASK_tgu-plan-on-demand-redesign.md | 297 ------------------ 3 files changed, 714 deletions(-) delete mode 100644 docs/tasks/TASK_datetime-utc-consistency.md delete mode 100644 docs/tasks/TASK_tgu-editor-integration.md delete mode 100644 docs/tasks/TASK_tgu-plan-on-demand-redesign.md diff --git a/docs/tasks/TASK_datetime-utc-consistency.md b/docs/tasks/TASK_datetime-utc-consistency.md deleted file mode 100644 index f52103f..0000000 --- a/docs/tasks/TASK_datetime-utc-consistency.md +++ /dev/null @@ -1,198 +0,0 @@ -# Задача: единообразие времени (UTC) по всей группе сервисов `pcp` - -> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента). -> Разделы 1–9 — инструкция, которую агент читает в репозитории и которой следует. -> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. - ---- - -## 0. Промпт для запуска агента (copy-paste) - -``` -Ты работаешь в монорепозитории observatio-terrae (pcp). - -Задача: провести аудит и привести к единообразию работу со временем (датами) по всей -группе сервисов pcp. Целевое соглашение: ВСЁ ВРЕМЯ — UTC, представление на API не зависит -от часового пояса машины-клиента или сервера. - -Перед началом обязательно прочитай: -1. /AGENTS.md — правила работы, стиль, формат финального ответа. -2. /docs/tasks/TASK_datetime-utc-consistency.md — это ТЗ (данный файл). -3. Эталонную реализацию на фронте: services/pcp-tgu-ops-ui/src/utils/utcTime.ts - и историю правок в pcp-tgu-ops-ui (вкладки «Планы», «Редактор», «Заявки», «Карта 2D»). - -Это в первую очередь АУДИТ + точечные правки, а не массовый рефактор. Сначала инвентаризация -(раздел 4), затем фиксы по чек-листу (раздел 5) с приоритетом на места, где сдвиг времени -виден пользователю или уходит на бэкенд при записи. Backend (Kotlin) трогать только там, где -сериализация реально неоднозначна (раздел 6) — сериализационные правки сопровождать тестом. - -Не ломай чужие контракты: если меняешь формат на проводе — обнови потребителей и тесты. -После каждого модуля прогоняй его тесты/сборку. В конце дай отчёт по формату из AGENTS.md -плюс заполненную таблицу инвентаризации (раздел 4) и список расхождений. -``` - ---- - -## 1. Контекст и зачем это - -Время в системе хранится и передаётся как **UTC**, но представление на границах API -неоднородно: часть сервисов отдаёт naive `LocalDateTime` (ISO без зоны, напр. -`"2026-05-29T01:00:00"`, по соглашению — UTC), часть — `OffsetDateTime`/`Instant` -(ISO с зоной, напр. `"2026-05-29T01:00:00Z"`). Эта разница уже привела к багу во фронте: - -- `pcp-tgu-service` отдаёт планы как naive `LocalDateTime`. -- Фронт парсил такие строки через `new Date(s)` / `Date.parse(s)`, а JS трактует строку - **без зоны как локальное время браузера** → сдвиг на офсет (для МСК — +3 ч). -- Одни вкладки показывали через `toLocaleString` (пояс браузера), другие — в UTC → - одно и то же время выглядело по-разному на разных экранах и у разных операторов. -- В пути **записи** заявки оператор, вводя `01:00`, отправлял на бэк `22:00`. - -Корень — не «UTC против местного», а **несогласованность парсинга/форматирования** и -**неоднородность формата на проводе между сервисами**. Эта задача делает всё единообразным. - -## 2. Целевое соглашение (canonical) - -1. **Всё время — UTC.** На любом API времена представляют момент в UTC. -2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис - исторически отдаёт naive `LocalDateTime`, это допустимо **только** при железном правиле - «naive = UTC», но такой эндпоинт — кандидат на миграцию к `Z` (раздел 6). -3. **Фронт:** - - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких - «голых» `new Date(string)` / `Date.parse(string)`; - - отображение — только UTC (`formatUtcDateTime/Date/Time`, либо `toLocale*` с - `timeZone: "UTC"`); никакого `toLocaleString` без `timeZone`; - - `` — значение через `toUtcInputValue`, чтение обратно - через `parseUtc`; отправка на offset-бэк — через `toUtcIso`. -4. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка. - -## 3. Эталон (уже сделано — переиспользовать, не переделывать) - -В `pcp-tgu-ops-ui` это соглашение уже внедрено и может служить образцом: - -- `src/utils/utcTime.ts` — `parseUtc`, `formatUtcDateTime/Date/Time`, `toUtcInputValue`, - `toUtcIso`. **Единая точка** парсинга/форматирования. -- Переведены вкладки «Планы», «Редактор», «Заявки», «Карта 2D» (источник `startMs/endMs` - в `tguTimelineMapper`, пикеры диапазона, детали заявок, путь создания заявки). -- Нюанс: `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) — `parseUtc` их тоже - корректно обрабатывает (зона есть → не добавляет `Z`), а запись идёт через `toUtcIso`. - -Любой новый код во фронте обязан следовать этому слою. - -## 4. Инвентаризация (заполнить в отчёте) - -Стартовый срез (тип `LocalDateTime` vs `OffsetDateTime/ZonedDateTime/Instant` в файлах -`dto|controller|api|response|model`); агент должен уточнить и довести до пер-эндпоинтной -картины «что именно отдаётся наружу»: - -| Сервис | Преобладает на границе API | Действие | -|---|---|---| -| pcp-tgu-service | naive `LocalDateTime` | задокументировать «=UTC», кандидат на `Z` | -| pcp-request-service | `OffsetDateTime` (`Z`) | эталон, проверить полноту | -| pcp-dynamic-plan-service | naive `LocalDateTime` (+1 offset-файл) | проверить смешение | -| pcp-complex-mission-service | naive `LocalDateTime` (+2 offset) | проверить смешение | -| pcp-ui-service | naive `LocalDateTime` (+1 offset) | проверить (агрегатор/BFF?) | -| slots-service | naive `LocalDateTime` | задокументировать «=UTC» | -| pcp-coverage-scheme-service | naive `LocalDateTime` | задокументировать «=UTC» | -| pcp-ballistics-service | naive `LocalDateTime` | задокументировать «=UTC» | -| pcp-mission-planing-service | naive `LocalDateTime` | задокументировать «=UTC» | -| pcp-route-processing-service | нет времени на границе (проверить) | — | -| pcp-satellite-catalog-service | нет (проверить) | — | -| pcp-stations-service | нет (проверить) | — | -| tle-monitoring-service | нет (проверить) | — | - -Колонки итоговой таблицы: **сервис · эндпоинт/поле · тип · формат на проводе (пример) · -naive? · потребители · вердикт (ok / задокументировать / мигрировать на `Z`)**. - -Полезно: во всех проверенных сервисах Jackson настроен с `JavaTimeModule` и -`disable(WRITE_DATES_AS_TIMESTAMPS)` → времена сериализуются как ISO-строки (не числа), -поэтому `LocalDateTime` уходит без зоны, `OffsetDateTime`/`Instant` — с `Z`/офсетом. - -## 5. Фронт: инвариант и фиксы (`pcp-tgu-ops-ui`) - -Эталон внедрён, но нужно **закрепить как инвариант** и проверить, что ничего не утекло: - -1. Найти нарушителей (раздел 7, паттерн A). Любой «голый» разбор/формат строки времени - бэка чинить через `utils/utcTime`. -2. Добавить **защиту от регрессий** — на выбор: - - юнит-тест-«страж», который грепает `src/` и падает на запрещённых паттернах - (`new Date()`, `Date.parse(`, `toLocaleString(` без `timeZone`, - `formatDateTimeLocal`); **или** - - ESLint-правило (`no-restricted-syntax`) с тем же смыслом. -3. Прогнать `npm run test` и `npm run build` в модуле. - -## 6. Backend: когда и что править - -По умолчанию — **не менять формат на проводе** (это контракт). Действия: - -1. **Документировать инвариант.** Для сервисов с naive `LocalDateTime`: явно зафиксировать - «все времена на API — UTC» (KDoc у DTO + запись в `docs/standards`, раздел 8). Это дёшево - и безопасно, и снимает неоднозначность для будущих клиентов. -2. **Чинить реальные баги.** Если найдётся место, где сервис: - - кладёт в naive-поле **локальное** время сервера (`LocalDateTime.now()` вместо - `LocalDateTime.now(ZoneOffset.UTC)` / `Instant.now()`), или - - конвертирует под зону JVM при чтении/записи — - это **баг**: исправить на UTC, добавить тест. -3. **Миграция к явному `Z` (опционально, по согласованию).** Перевод naive `LocalDateTime` - на `OffsetDateTime`/`Instant` (UTC) на границе — самый чистый итог, но это смена контракта: - делать только с обновлением потребителей (на фронте `parseUtc` уже совместим с обоими) и - сериализационным тестом. Оформить как отдельные under-задачи per-service, не «большим взрывом». -4. **Тест формата.** Для каждого сервиса, отдающего время, иметь тест, фиксирующий формат на - проводе (есть `Z` / нет зоны) — чтобы контракт не «уехал» молча. - -## 7. Как искать нарушителей (детект-паттерны) - -**A. Фронт (`services/pcp-tgu-ops-ui/src`), запрещено вне `utils/utcTime.ts`:** - -```bash -# разбор строки как локального времени -grep -rn "new Date([a-zA-Z].*)\.getTime\|Date\.parse(" src --include=*.ts --include=*.tsx | grep -v utcTime -# показ в поясе браузера -grep -rn "toLocaleString\|toLocaleDateString\|toLocaleTimeString" src --include=*.ts --include=*.tsx | grep -v 'timeZone: "UTC"' -# локальные хелперы пикеров -grep -rn "formatDateTimeLocal" src -# локальные компоненты даты -grep -rn "getFullYear()\|getMonth()\|getDate()\|getHours()\|getMinutes()" src --include=*.ts --include=*.tsx | grep -v getUTC -``` - -**B. Backend (Kotlin) — потенциально локальное «сейчас» в UTC-полях:** - -```bash -# now() без UTC-зоны -grep -rn "LocalDateTime\.now()\|LocalDate\.now()\|LocalTime\.now()" services/*/src/main -# конвертации под зону системы -grep -rn "atZone(\|ZoneId\.systemDefault\|toLocalDateTime()\|systemDefault()" services/*/src/main -# типы времени на границе (для инвентаризации) -grep -rn "LocalDateTime\|OffsetDateTime\|ZonedDateTime\|Instant" services/*/src/main | grep -iE "dto|controller|response" -``` - -Каждое попадание классифицировать: **ок / задокументировать / починить / мигрировать**. - -## 8. Зафиксировать стандарт - -Создать `docs/standards/DATETIME_UTC.md` (на него ссылаться из KDoc/комментариев): - -- соглашение из раздела 2 (всё UTC; формат на проводе; правила фронта); -- таблица состояния сервисов (итог раздела 4); -- правило для нового кода: фронт — только `utils/utcTime`; бэк — UTC-`now`, - предпочтительно `Z` на новых API. - -При наличии общего чек-листа в `docs/standards/CHECKLIST.md` — добавить пункт -«Время на API — UTC; фронт парсит/форматирует через utcTime». - -## 9. Definition of Done - -- [ ] Заполнена пер-эндпоинтная таблица инвентаризации (раздел 4) по всем 13 сервисам. -- [ ] Фронт: нарушителей по паттернам раздела 7A не осталось; добавлена защита от регрессий - (тест-страж или ESLint-правило); `npm run test` и `npm run build` зелёные. -- [ ] Backend: задокументирован инвариант «API = UTC» у сервисов с naive `LocalDateTime`; - найденные реальные баги локального `now`/конвертаций исправлены с тестами. -- [ ] Создан `docs/standards/DATETIME_UTC.md`; обновлён `CHECKLIST.md` (если есть). -- [ ] Список расхождений и предложенные under-задачи на миграцию к `Z` (если такие нужны) - приведены в финальном отчёте (формат — `AGENTS.md`). - -### Не делать - -- Массовую смену формата на проводе без обновления потребителей и тестов. -- Изменение семантики хранения в БД (там уже UTC) — только представление/парсинг. -- Локализацию под пояс машины «чтобы было привычнее» — это и есть источник проблемы. -``` diff --git a/docs/tasks/TASK_tgu-editor-integration.md b/docs/tasks/TASK_tgu-editor-integration.md deleted file mode 100644 index fe32add..0000000 --- a/docs/tasks/TASK_tgu-editor-integration.md +++ /dev/null @@ -1,219 +0,0 @@ -# Задача: встроить интерфейс «Редактор включений КА» в `pcp-tgu-ops-ui` - -> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента). -> Разделы 1–11 — инструкция, которую агент читает в репозитории и которой следует. -> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. - ---- - -## 0. Промпт для запуска агента (copy-paste) - -``` -Ты работаешь в монорепозитории observatio-terrae (pcp). - -Задача: встроить прототип интерфейса «Редактор включений КА» в существующее -фронтенд-приложение services/pcp-tgu-ops-ui (Vite + React 19 + TypeScript). - -Перед началом обязательно прочитай: -1. /AGENTS.md — правила работы, стиль, формат финального ответа. -2. /docs/tasks/TASK_tgu-editor-integration.md — полное ТЗ этой задачи (этот файл). -3. Исходный прототип: /docs/ui-prototypes/pcp-tgu-editor/ (editor.jsx и соседние). -4. Целевую структуру: services/pcp-tgu-ops-ui/src (App.tsx, features/, api/, styles/). - -Это ПОРТ, а не копирование: прототип на Babel-standalone с глобалями и мок-данными; -цель — ESM/TS-модули, реальный API-клиент, существующая тема и паттерны проекта. - -Выполняй строго по этапам из раздела 5 ТЗ. Делай минимальные ревьюопригодные изменения, -не трогай чужие сервисы и backend (Kotlin). Карту, орбитальную математику и мок-данные -из прототипа НЕ переноси — переиспользуй features/tgu-map-2d и api/tguApi (раздел 3). -После каждого этапа прогоняй `npm run test` и `npm run build` в модуле. - -В конце дай отчёт в формате из раздела «Обязательный формат финального ответа» AGENTS.md. -``` - ---- - -## 1. Контекст и цель - -`pcp-tgu-ops-ui` — операторский UI ТГУ (Mission Ops): уже работающее SPA на **Vite 7 + React 19 + TypeScript**, feature-sliced структура (`features/tgu-planning`, `features/tgu-map-2d`), централизованный API-клиент `src/api/tguApi.ts` (все вызовы через `/api/...`, dev-proxy на backend `:7008`), токены темы в `src/styles/theme.css`, юнит-тесты на Vitest. - -Прототип «Редактор» — браузерный demo (React/Babel из CDN, JSX компилируется в браузере, общие глобали `D`/`SENSOR_MODES`/`window.WorldGeo`, мок-данные из `data.jsx`). Это **другой стек**, поэтому его нельзя «подключить» файлами — нужно портировать как новую фичу приложения. - -**Цель:** редактор включений КА доступен как третья вкладка приложения (`timeline | map | editor`), работает на реальных данных через `tguApi`, переиспользует существующую карту и тему, покрыт тестами и собирается без ошибок. - -Функциональность редактора (из прототипа `editor.jsx`): выбор активного КА/плана, черновик включений (`works`: `shoot` — съёмка, `downlink` — сброс), drag/resize по таймлайну, добавление съёмки по точке на карте и сброса по сеансу со станцией, контекст соседних КА, undo/redo, проверка конфликтов (пересечения по времени — живые; видимость цели/станции — по кнопке), применение правок. - ---- - -## 2. Границы задачи (scope) - -**В scope:** -- Новая фича `src/features/tgu-editor/` в `pcp-tgu-ops-ui`. -- Третья вкладка `"editor"` в существующем переключателе вкладок. -- Перенос UI-компонентов редактора на TSX + типизация. -- Чистые модули логики (undo/redo, конфликты) + их тесты. -- Подключение к реальным данным через `tguApi` (планы/платформы). -- Перемаппинг токенов темы. - -**Вне scope (НЕ делать без отдельного запроса):** -- Изменения в любых Kotlin-сервисах, включая `pcp-tgu-service` (backend-эндпоинт сохранения правок — отдельная задача, см. раздел 7). -- Перенос карты/орбитальной математики/мок-данных из прототипа (раздел 3). -- Добавление роутера, state-менеджеров, UI-библиотек, новых рантайм-зависимостей без необходимости. -- Рефакторинг существующих фич `tgu-planning` / `tgu-map-2d` сверх минимально необходимого для переиспользования. -- Изменения `helm/`, `settings.gradle.kts`, CI (модуль фронта в них и так не участвует). - ---- - -## 3. Целевая архитектура и правила переиспользования - -Следуй существующему feature-sliced паттерну (см. `features/tgu-planning`, `features/tgu-map-2d`): UI-компоненты в `*.tsx`, типы в `model/*.ts`, нетривиальная логика — в отдельных чистых `*.ts` модулях с тестами. - -**Обязательно переиспользовать (НЕ дублировать):** -- **Карта** — `features/tgu-map-2d` (`Tgu2DMapView`, `canvas/drawTracks|drawSwath|drawStations|drawPlanWorks|drawBaseMap`, `geometry/mapProjection|spatialIndex|bbox`). Редактору нужна карта в режиме «выбор точки съёмки» и подсветки трасс — расширяй существующий компонент пропсами (`pickMode`, `onPickPoint`, `targets`), а не переноси `mapview.jsx`/`orbit.jsx`/`world.js`/`basemap.js`. -- **API** — `src/api/tguApi.ts` (`fetchPlans`, `fetchPlatforms`, паттерн `fetchJson`/`TguApiError`). Все сетевые вызовы редактора добавляй сюда же или в `features/tgu-editor/editorApi.ts` в том же стиле. -- **Тема** — `src/styles/theme.css` (см. раздел 6). -- **Модели** — `src/model/tguTypes.ts`, `src/model/timelineTypes.ts`. Переиспользуй существующие типы планов/платформ, не вводи параллельные. - -**Не переносить из прототипа:** `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx` (дублируют то, что уже есть в TS), `data.jsx` (мок), `tabs.jsx` (заменяется вкладкой), `Editor.html`/`Map.html` (точка входа уже `index.html` + `main.tsx`). - -Орбитальные функции, которых нет в `tgu-map-2d` (`targetPasses`, `stationPasses`, `subPoint`, `nearestStation`), вынеси в чистый общий модуль `features/tgu-map-2d/geometry/passes.ts` (с тестами) и переиспользуй из карты и редактора. Не размещай бизнес/гео-логику внутри React-компонентов. - ---- - -## 4. Инвентаризация и маппинг файлов - -Источник: `docs/ui-prototypes/pcp-tgu-editor/` (положить туда содержимое архива до начала работ — по образцу уже существующих `docs/ui-prototypes/pcp-tgu-mission-ops/`). - -| Прототип | Действие | Целевой файл | -|---|---|---| -| `editor.jsx` (`EditorApp`) | Портировать, убрать `ReactDOM.createRoot`, сделать экспортируемым компонентом-вкладкой | `features/tgu-editor/TguEditorTab.tsx` | -| `editor.jsx` (`EditorToolbar`) | Портировать | `features/tgu-editor/EditorToolbar.tsx` | -| `editorrail.jsx` | Портировать | `features/tgu-editor/EditorRail.tsx` | -| `editortimeline.jsx` | Портировать | `features/tgu-editor/EditorTimeline.tsx` | -| `editorpanels.jsx` (`WorkInspector`/`ShootPanel`/`DownlinkPanel`) | Портировать | `features/tgu-editor/EditorPanels.tsx` | -| `visibility.jsx` | Портировать визуализацию; гео-расчёты вынести в `passes.ts` | `features/tgu-editor/VisibilityTracks.tsx` | -| `editor.jsx` (undo/redo, mutate, seedDraft) | Вынести в чистый модуль | `features/tgu-editor/editorDraft.ts` (+`editorDraft.test.ts`) | -| `editor.jsx` (overlaps + runCheck) | Вынести в чистый модуль | `features/tgu-editor/editorConflicts.ts` (+`editorConflicts.test.ts`) | -| типы `work`/`draft`/`target` | Создать | `features/tgu-editor/model/editorTypes.ts` | -| `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx`, `data.jsx`, `tabs.jsx`, `*.html` | НЕ переносить | — | - ---- - -## 5. Пошаговый план (атомарные этапы) - -Каждый этап завершай прогоном `npm run test` и `npm run build` в `services/pcp-tgu-ops-ui`. - -**Этап 0 — подготовка.** Скопировать прототип в `docs/ui-prototypes/pcp-tgu-editor/`. Прочитать `App.tsx`, `TguPlanningPage.tsx`, `TguToolbar.tsx`, `tguApi.ts`, `theme.css`, `Tgu2DMapView.tsx`. Зафиксировать в отчёте точки расширения. - -**Этап 1 — типы и каркас фичи.** Создать `features/tgu-editor/model/editorTypes.ts` (`Work` с `kind: "shoot" | "downlink"`, `origin`, `modeId`, `stationId`, `targetLat/Lon`, `isNew`; `Draft`, `EditorTarget`). Создать пустой `TguEditorTab.tsx` с заголовком-заглушкой. Скомпилироваться. - -**Этап 2 — чистая логика + тесты.** Перенести в `editorDraft.ts`: `seedDraft`, `mutate`, `undo`, `redo`, `addShoot`, `addDownlink`, `updateWork`, `deleteWork`, `duplicateWork` (как чистые функции над `Draft`/`Work`, без React и без `Date.now()` внутри — id/время передавать параметром для детерминизма тестов). Перенести в `editorConflicts.ts`: `overlaps` и проверку видимости. Написать `editorDraft.test.ts` и `editorConflicts.test.ts` (undo/redo инварианты, обнаружение пересечений, отсутствие/наличие видимости). Это первоочередное — логика должна быть покрыта до UI. - -**Этап 3 — токены темы.** Реализовать перемаппинг (раздел 6). Убедиться, что портируемые стили резолвятся в палитру приложения. - -**Этап 4 — UI без карты.** Портировать `EditorToolbar`, `EditorRail`, `EditorTimeline`, `EditorPanels`, `VisibilityTracks` на TSX. Завязать их на состояние в `TguEditorTab` (`useState`/`useMemo`/`useCallback` напрямую из `react`). Drag/resize таймлайна сохранить. Вместо `alert()` использовать уведомление в стиле `decisionNotice`/`decisionError`. - -**Этап 5 — карта.** Расширить `Tgu2DMapView` пропсами `pickMode`/`onPickPoint`/`targets` (минимально, без поломки текущего использования в `Tgu2DMapTab`). Подключить в редакторе для выбора точки съёмки и подсветки трасс цели/станции через `passes.ts`. - -**Этап 6 — данные.** Заменить мок `D` на реальные данные: планы/платформы грузить через `tguApi` (как в `TguPlanningPage`). `seedDraft` строить из выбранного плана. Активный КА/план брать из пропсов (раздел 7), не из `location.search`. - -**Этап 7 — вкладка.** В `model/timelineTypes.ts`/`TguPlanningPage.tsx` расширить `activeTab` до `"timeline" | "map" | "editor"`, добавить кнопку вкладки в `TguToolbar` (`onTabChange`), отрендерить `TguEditorTab` при `activeTab === "editor"`, передав `selectedSpacecraftId`/`selectedPlan`/`appliedRange`. - -**Этап 8 — apply (заглушка).** Реализовать `applyDraft` через типизированную функцию `saveEditedPlan` в `editorApi.ts`, помеченную `// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service` (раздел 7). Поведение: показать понятное уведомление о том, что сохранение во внешний сервис ещё не реализовано; не падать. - -**Этап 9 — финализация.** Прогнать `npm run test` и `npm run build`. Отчёт по формату AGENTS.md. - ---- - -## 6. Перемаппинг токенов темы - -Прототип использует oklch-токены (`--bg-0..3`, `--ink..--ink-3`, `--t-optical/radar/combo`, `--now`, `--warn`, `--line`, `--shadow`), которых нет в `theme.css` приложения (там hex: `--bg-base`, `--bg-panel`, `--text`, `--accent`, `--danger`, `--warning`, ...). - -Минимально-инвазивный подход: создать `src/styles/editor-tokens.css` (импортировать в `TguEditorTab` или в `main.tsx`) с алиасами, чтобы портированный код, ссылающийся на старые имена, резолвился в палитру приложения — без переписывания каждого inline-стиля: - -```css -:root { - --bg-0: var(--bg-base); - --bg-1: var(--bg-panel); - --bg-2: var(--bg-panel-2); - --bg-3: var(--bg-elevated); - --ink: var(--text); - --ink-1: var(--text); - --ink-2: var(--text-muted); - --ink-3: var(--text-dim); - --now: var(--warning); - --warn: var(--warning); - /* --line, --line-soft, --accent, --shadow совпадают по имени — проверить значения */ - /* --t-optical/radar/combo: задать на основе --accent/--warning/--danger или вынести в палитру типов КА */ -} -``` - -Уточнить фактические значения `--line/--line-soft/--accent/--shadow` (имена совпадают, значения могут отличаться) и зафиксировать решение в отчёте. Альтернатива (если команда предпочитает) — переписать стили редактора сразу на родные токены приложения; выбрать один путь и не смешивать. - ---- - -## 7. Данные и API - -- **Чтение:** планы и платформы — через существующие `fetchPlans`/`fetchPlatforms` (`tguApi.ts`). Активный КА/план редактор получает пропсами от `TguPlanningPage` (`selectedSpacecraftId`, `selectedPlan`), а не из URL. -- **Гео/орбиты:** через `tgu-map-2d` + `passes.ts`; мок `world.js`/`data.jsx` не использовать. -- **Сохранение правок (apply):** в текущем API `pcp-tgu-service` **нет** эндпоинта сохранения отредактированного набора включений (есть только выдача плана и `decision`/`issue`). Поэтому в этой задаче `applyDraft` реализуется как заглушка с явным `TODO(backend)` и не делает реальной записи. - -**Предложение контракта для backend-команды** (вне scope, зафиксировать в отчёте, не реализовывать): -- `POST /api/tgu-planning/plans/{planId}/edits` с телом `{ works: EditedWork[], baseRevision?: string }`, идемпотентно по `operationId`, возвращает новый план/attempt. Семантику (новый attempt vs патч существующего) согласовать отдельно. - ---- - -## 8. Конвенции - -Следуй `AGENTS.md` (раздел про стиль и scope) применительно к TS/React: -- Минимальные, локальные, ревьюопригодные изменения; не переименовывать и не форматировать несвязанные файлы. -- Предпочитать `const`, маленькие функции, явные имена; не вводить «умные» абстракции на будущее. -- Бизнес/гео-логику не держать в React-компонентах — выносить в чистые `*.ts` модули. -- Комментарии — там, где помогают сопровождению (undo/redo инварианты, правила конфликтов, edge cases), а не на каждую строку. Язык комментариев — как в проекте. -- Не глотать ошибки сети молча — использовать паттерн `TguApiError`/уведомлений, как в существующем коде. -- Не добавлять новые рантайм-зависимости без явной необходимости; React/ReactDOM/Babel из CDN прототипа не тащить — они уже есть в проекте как npm-пакеты. -- TypeScript: без `any` в публичных сигнатурах; типы — в `model/`. - ---- - -## 9. Критерии приёмки и команды валидации - -Рабочая директория: `services/pcp-tgu-ops-ui`. - -``` -npm install # при необходимости -npm run test # vitest run — все тесты зелёные, включая новые editorDraft/editorConflicts/passes -npm run build # tsc -b && vite build — без ошибок типов и сборки -npm run dev # ручная проверка: вкладка «Редактор» открывается, см. ниже -``` - -Функциональные критерии: -1. В тулбаре доступна третья вкладка; переключение `timeline | map | editor` работает, существующие вкладки не сломаны. -2. Редактор открывается на реальных данных (планы/платформы из `tguApi`), без мок-`D`. -3. Работают: drag/resize включений, добавление съёмки по точке на карте, добавление сброса по станции, undo/redo, проверка конфликтов (живые пересечения + видимость по кнопке). -4. Карта в редакторе — это `tgu-map-2d` (нет второго движка карты в бандле). -5. Тема консистентна с приложением (нет «бесцветных» из-за отсутствующих токенов). -6. `applyDraft` не падает и явно сообщает про отсутствующий backend-эндпоинт. - -Если какая-то команда не запускалась — указать какая и почему, и описать остаточный риск (по AGENTS.md). - ---- - -## 10. Риски, допущения, чего не делать - -- **Допущение:** структура планов/включений из `tguApi` достаточна, чтобы построить `seedDraft`. Если в реальном ответе нет полей под `works` редактора — зафиксировать gap, сделать адаптер-маппер и описать недостающие поля в отчёте, **не** менять backend. -- **Риск:** React 19 + StrictMode даёт двойной вызов эффектов — проверить `runCheck`/`setTimeout` на отсутствие двойных срабатываний. -- **Риск:** расширение `Tgu2DMapView` может задеть `Tgu2DMapTab` — новые пропсы делать опциональными с дефолтами, существующее поведение не менять. -- **Не делать:** правки в Kotlin-сервисах, в чужих фичах сверх необходимого, новый роутинг, новые тяжёлые зависимости, перенос мок-данных и второго движка карты. - ---- - -## 11. Обязательный формат финального ответа - -По `AGENTS.md` агент завершает задачу блоками: - -1. **Changed files** — список изменённых/созданных файлов. -2. **What changed** — краткое summary реализации по этапам. -3. **Architecture compliance** — почему соблюдены границы слоёв (логика вне компонентов, переиспользование `tgu-map-2d`/`tguApi`/темы, отсутствие дублей карты и мок-данных). -4. **Validation** — какие команды (`npm run test`, `npm run build`, ручная проверка) запускались и результаты. -5. **Risks / assumptions** — что не проверено, gap по данным/backend, edge cases. diff --git a/docs/tasks/TASK_tgu-plan-on-demand-redesign.md b/docs/tasks/TASK_tgu-plan-on-demand-redesign.md deleted file mode 100644 index 88e1713..0000000 --- a/docs/tasks/TASK_tgu-plan-on-demand-redesign.md +++ /dev/null @@ -1,297 +0,0 @@ -# Задача: pcp-tgu-service — «храним только выданное» + закладочный флоу - -> ТЗ для кодинг-агента и для приёмки. Раздел 0 — промпт запуска. -> Разделы 1–9 — что и как делаем. Раздел 10 — **критерии приёмки** (по ним проверяем результат). -> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. - ---- - -## 0. Промпт для запуска агента (copy-paste) - -``` -Ты работаешь в монорепозитории observatio-terrae (pcp), модуль services/pcp-tgu-service (Kotlin/Spring Boot). - -Задача: перестроить хранение и выдачу планов ТГУ — перестать материализовать -будущую цепочку планов в БД (хранить только выданные планы и их историю — вечно), -считать будущее на лету как проекцию, и добавить закладочный флоу с реакцией -на два Kafka-события «не заложен» и «не принят». - -Перед началом обязательно прочитай: -1. /AGENTS.md — правила работы, стиль, формат финального ответа. -2. /docs/tasks/TASK_tgu-plan-on-demand-redesign.md — это ТЗ. -3. Текущий код: services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/ - (service/, entity/, repository/, controller/, integration/kafka/, config/). - -Делай строго по фазам раздела 5. Всё — в одной ветке/одном PR. -Ядро расчёта цепочки (PlanCalculationService) НЕ меняем — оно уже монотонно-корректно. -После работы прогоняй ./gradlew :services:pcp-tgu-service:test из корня монорепо. -В конце дай отчёт в формате из AGENTS.md и пройдись по разделу 10 (критерии приёмки). -``` - ---- - -## 1. Контекст и цель - -`pcp-tgu-service` (порт `:7011`) — роллинг-планировщик сеансов управления по каждому операционному КА. Поток: список КА из НСИ → окна видимости (ЗРВ) из баллистики → расчёт цепочки планов (~`target` 1440±240 мин, привязка 1:1 к ЗРВ, строгая монотонность) → выдача дозревшего плана через короткий BPMN → решение из Kafka. UI (через агрегатор `:7008`) рисует таймлайн планов. - -**Проблема сейчас.** Таблица `planned_plan` материализует **всю будущую цепочку** как `PLANNED`-строки и переписывает её на каждое изменение хэша ЗРВ. Это копит «лишнее»: пачки `SUPERSEDED`/`EXPIRED` на каждый пересчёт, рост без границ, отдельная машинерия `plan_recalculation_job`, и целый класс багов рассинхрона цепочки со снапшотом («схлопывание в SUPERSEDED», «зомби-PLANNED»). При этом будущая цепочка — **детерминированная проекция** от (снапшот ЗРВ + базовый план + конфиг). - -**Цель.** -1. Хранить в `planned_plan` **только планы, перешедшие в выдачу**, и их историю — **вечно** (ретеншн не делаем). -2. Будущее (не выданное) — **не хранить вовсе**, считать на лету как проекцию (в т.ч. для UI). -3. Добавить **закладочный флоу**: после `ACCEPTED` план оптимистично считается заложенным; реагируем только на явный негатив — два раздельных Kafka-события «не заложен» и «не принят», оба пока ведут себя одинаково (срочно выдать план на ближайшую ЗРВ), но разнесены для будущего расхождения. -4. Реагировать на **изменение параметров станций** (Kafka-событие): станция могла выйти из строя — нужно заново запросить ЗРВ у баллистики по затронутым КА и пересобрать цепочку. В on-demand-модели это сводится к форсу `visibility_refresh_job` → новый снапшот → проекция пересчитается сама. -5. Снести мёртвый код (`tracked_plan`) и ставшую ненужной машинерию пересчёта (`plan_recalculation_job`). - -Масштаб: **100–150 КА**, горизонт планирования **до 10 суток**, деплой **2–3 реплики** (горизонтальное масштабирование уже обеспечено координацией через БД: `FOR UPDATE SKIP LOCKED` + pessimistic-lock на КА). - ---- - -## 2. Границы задачи (scope) - -**В scope:** -- Перестроение хранения/выдачи планов в `pcp-tgu-service` (backend, Kotlin). -- Новый сервис проекции; переписанный воркер выдачи; read-эндпоинт, отдающий persisted ∪ проекцию. -- Новые статусы плана и закладочный флоу с двумя новыми Kafka-консьюмерами. -- Удаление мёртвого кода и снятие `plan_recalculation_job`. -- Миграции БД, изменения конфига (`application.yml` + `config-repo/pcp-tgu-service.yaml`). -- Юнит-тесты на новую логику. - -**Вне scope (без отдельного запроса):** -- Фронтенд `pcp-tgu-ops-ui` и агрегатор `pcp-ui-service` — контракт `/api/plans` сохраняем неизменным, фронт не трогаем. -- Изменение ядра расчёта `PlanCalculationService` (кроме переиспользования; алгоритм не меняем). -- BPMN-схемы (`issueSatellitePlan`, `updateAcceptedPlanSlots`) — не меняем. -- Источник/продюсер новых Kafka-событий «не заложен»/«не принят» — мы только консьюмеры; контракт ниже фиксируем. -- Ретеншн/архивация — НЕ делаем (выданное храним вечно). - ---- - -## 3. Текущая архитектура (точки изменения) - -- [PlannedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt) / `planned_plan` — хранит всю цепочку, upsert по бизнес-ключу `(spacecraft, start, end, kpp)`. -- [RebuildPlanScheduleUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt) — пересобирает цепочку: `calculatePlans/After` → upsert → `supersedeObsoleteFuturePlans` → `expireMissedPlans`. -- [PlanCalculationService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt) — **ядро, не трогаем**. -- [SpacecraftVisibilitySnapshotService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt) — на изменение хэша ставит `plan_recalculation_job`. -- [PlanRecalculationWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt) / `PlanRecalculationJobService` / `plan_recalculation_job` — **снимаем целиком**. -- [IssueDuePlanWorker](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) — сейчас сканирует `PLANNED` (`findDueForUpdate`). Переписываем на on-demand. -- [HandlePlanDecisionUseCase](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt) — обработка `ACCEPTED/REJECTED`; `promoteNextPlanOrEnqueueRebuild` завязан на хранимые `PLANNED` → переписываем. -- [PlanQueryService](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) / [PlanController](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) — read-путь, дополняем проекцией. -- Мёртвый код: [TrackedPlanEntity](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt), `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository` — на репозиторий нет ссылок. - ---- - -## 4. Целевая архитектура - -- `planned_plan` пополняется строкой **только в момент выдачи** (статус `ISSUING`). Дальше строка живёт по жизненному циклу выдачи и **хранится вечно**. -- **Будущее не хранится.** `PlanProjectionService.project(spacecraftId)` считает цепочку на лету из последнего снапшота ЗРВ + текущей базы + конфига, переиспользуя `PlanCalculationService`. -- **Выдача** ([IssueDuePlanWorker]) идёт по активным КА, считает голову проекции; если голова «дозрела» (`issue_not_before ≤ now`) или взведён `urgent_reissue_from` — материализует **ровно эту голову** в `ISSUING` и выдаёт. Никаких `PLANNED`-строк в БД. -- **Read** ([PlanQueryService]) отдаёт `persisted (вся история) ∪ проекция будущего` (виртуальный `PLANNED`, в БД не пишется), дедуп по бизнес-ключу. Контракт `/api/plans` неизменен. -- **Закладочный флоу**: `ACCEPTED` → `AWAITING_LAYIN` (оптимистично = база, цепочка идёт дальше). Два новых события гасят план терминально (`LAYIN_FAILED` / `NOT_ACCEPTED`) и взводят срочную перевыдачу на ближайшую ЗРВ с лидом `now + reissue-prep-minutes`. - ---- - -## 5. Фазы работ - -### Фаза 0. Чистка мёртвого кода -- Удалить `TrackedPlanEntity`, `TrackedPlanStatus`, `TrackedPlanSource`, `TrackedPlanJpaRepository`. -- Миграция `V7__drop_tracked_plan.sql`: `DROP TABLE IF EXISTS tracked_plan;` -- Убедиться, что компиляция/тесты не ссылались на эти классы. - -### Фаза 1. On-demand проекция, конец материализации цепочки -- Новый `PlanProjectionService` с `project(spacecraftId): List` и `projectHead(spacecraftId): CalculatedPlan?`: - читает `spacecraft_visibility_snapshot`, текущую базу (`spacecraft_planning_state.currentBasePlanId`), считает цепочку через `PlanCalculationService` (`calculatePlans`/`calculatePlansAfter`). Опционально — in-memory кэш по `payload_hash` (если делаешь — инвалидируй при смене снапшота/базы; при 150 КА можно и без кэша). -- Переписать `IssueDuePlanWorker` на on-demand: идём по активным КА (`spacecraft_snapshot.active = true` / `spacecraft_planning_state`), для каждого через `PlanProjectionService.projectHead` берём ближайший план; если у КА нет активной попытки (`activeAttemptId == null`) и (`issue_not_before(head) ≤ now` **или** взведён `urgent_reissue_from`) — материализуем голову в `planned_plan` со статусом `ISSUING` и стартуем выдачу. Сохраняем claim-семантику с lock на КА. -- `RebuildPlanScheduleUseCase`: убрать материализацию (`upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans`). Логику расчёта забирает `PlanProjectionService`; сам use case либо удаляется, либо превращается в тонкую обёртку над проекцией (если на него ссылается `TestTguController` — переориентировать/упростить). -- **Снять машинерию пересчёта**: удалить `PlanRecalculationWorker`, `PlanRecalculationJobService`, `PlanRecalculationJobEntity`, `PlanRecalculationJobRepository`; убрать `enqueue(...)` из `SpacecraftVisibilitySnapshotService` (hash-проверку оставить — она просто перестаёт что-либо ставить). Миграция `V8__drop_plan_recalculation_job.sql`: `DROP TABLE IF EXISTS plan_recalculation_job;` -- `PlannedPlanRepository`: удалить ставшие мёртвыми `upsertPlannedByBusinessKey`, `findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime`, `findAllBySpacecraftIdAndStatusAndStartTimeLessThanEqualOrderByStartTime`, `findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime`. Оставить/добавить: `lockByPlanId`, выборка истории по КА и по всем для read, защита от дублей выдачи. - -### Фаза 2. Read-проекция для UI -- `PlanQueryService.getAllPlans/getPlansBySpacecraftId`: вернуть `persisted ∪ projection`. Persisted — все строки `planned_plan` (история вечна; `historyDays` оставить как фильтр **отображения** левого края, БД не режем). Projection — `PlanProjectionService.project(...)` по активным КА, виртуальный статус `PLANNED`, дедуп: проекционный план не добавляем, если его бизнес-ключ уже есть среди persisted. -- `PlanResponse`: научить представлять виртуальные планы (детерминированный `planId` из бизнес-ключа, либо nullable — на усмотрение, но контракт JSON для фронта не ломать; `status = PLANNED`). Добавить фабрику `from(CalculatedPlan, status)`. - -### Фаза 3. Статусы и закладочный флоу -- `PlannedPlanStatus`: добавить `AWAITING_LAYIN`, `LAID_IN`, `LAYIN_FAILED`, `NOT_ACCEPTED`. `PLANNED` оставить только как виртуальный для проекции (в БД не писать). -- `HandlePlanDecisionUseCase` (топик `satellite-plan-decision`): - - `ACCEPTED` → план `ACCEPTED` затем `AWAITING_LAYIN`, `currentBasePlanId = plan`, проекция продолжается; `acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)` (как есть). - - `REJECTED` → план терминально `REJECTED` → общий `reissueOnNearestWindow(...)`. - - Удалить `promoteNextPlanOrEnqueueRebuild` (он завязан на хранимые `PLANNED`). -- Новые события (раздельные консьюмеры, общее поведение): - - `TopicsProperties`: добавить `layinFailed` и `planNotAccepted`. - - DTO `PlanExecutionFeedbackMessage(eventId, spacecraftId, planId, occurredAt, reason)` — пока общий на оба события. - - Консьюмеры `LayinFailedConsumer`, `PlanNotAcceptedConsumer` (groupId `${spring.kafka.consumer.group-id}`). - - В use case — два метода `handleLayinFailed(msg)` / `handleNotAccepted(msg)`, **оба идемпотентны** по `eventId` через `processed_plan_decision_event`, оба зовут общий `reissueOnNearestWindow(...)`; статус помечаемого плана соответственно `LAYIN_FAILED` / `NOT_ACCEPTED`. -- `reissueOnNearestWindow(spacecraftId, failedPlanId, now)` (общий для REJECTED-после-cutoff / LAYIN_FAILED / NOT_ACCEPTED): - - под lock на КА: помечает план терминальным статусом (история — навечно), снимает его с роли базы (`currentBasePlanId` ← предыдущая валидная база или `null`), чистит `activePlanId/activeAttemptId`; - - ставит `spacecraft_planning_state.urgent_reissue_from = now + reissue-prep-minutes`. -- `IssueDuePlanWorker` urgent-path: если у КА `urgent_reissue_from != null` — голову проекции берём как ближайшую ЗРВ со `startTime ≥ urgent_reissue_from`, материализуем и выдаём немедленно (не ждём `issue_not_before`), после успешного старта `urgent_reissue_from = null`. -- Миграция `V9__planning_state_urgent_reissue.sql`: `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` - -### Фаза 3b. Событие «изменились параметры станций» -- `TopicsProperties` + конфиги: `topics.station-params-changed: pcp.tgu.station-params-changed.v1`. -- DTO `StationParamsChangedMessage(eventId, stationId?, occurredAt, reason?)`. -- Консьюмер `StationParamsChangedConsumer` (groupId `${spring.kafka.consumer.group-id}`). -- Use case `handleStationParamsChanged(msg)`: **идемпотентен** по `eventId` (общий ledger `processed_plan_decision_event`); ставит `visibility_refresh_job` (`VisibilityRefreshJobService.enqueue`, reason `STATION_PARAMS_CHANGED`) по **всем активным КА** (`spacecraft_snapshot.active = true`). Дальше [VisibilityRefreshWorker] перечитывает ЗРВ у баллистики → новый снапшот → on-demand проекция пересобирает цепочку автоматически. -- `stationId` в событии пока **не используется для сужения** (карты «станция → КА» в tgu нет; баллистика считает ЗРВ по-КА). Поле оставляем на будущее для точечного refresh; сейчас поведение — blanket refresh всех активных КА (при 150 КА дёшево). -- Уже выданные/принятые планы (`ISSUING`/`AWAITING_LAYIN`) ретроспективно не меняются — пересобирается только будущая часть цепочки (после базы). Если станция текущего плана отказала по сути — это придёт отдельным негативным событием (`layin-failed`/`not-accepted`). - -### Фаза 4. Конфиг -- `PlanningProperties` + `application.yml` + `config-repo/pcp-tgu-service.yaml`: - - `external-points-lookahead-days: 3 → 10`; - - новый `reissue-prep-minutes: 30`; - - `min-reissue-lead-minutes` — оставить (REJECTED-до-cutoff). -- `TopicsProperties` + конфиги: `topics.layin-failed: pcp.tgu.layin-failed.v1`, `topics.plan-not-accepted: pcp.tgu.plan-not-accepted.v1`, `topics.station-params-changed: pcp.tgu.station-params-changed.v1` (с возможностью переопределить). - -### Фаза 5. Тесты -- Сохранить `PlanCalculationServiceTest` (ядро не тронуто). -- Новые: `PlanProjectionServiceTest`, `IssueDuePlanWorker` (on-demand голова + urgent-path), `HandlePlanDecisionUseCase` (`ACCEPTED→AWAITING_LAYIN`; `REJECTED→reissue`), консьюмеры `layin-failed`/`not-accepted` (идемпотентность, раздельность, общий reissue), консьюмер `station-params-changed` (идемпотентность + постановка refresh по всем активным КА), read-проекция (`persisted ∪ projection` + дедуп). -- Удалить тесты снятой машинерии (recalc worker, supersede/expire); поправить `RebuildPlanScheduleUseCaseTest`. - ---- - -## 6. Модель данных и миграции - -| Миграция | Действие | -|---|---| -| `V7__drop_tracked_plan.sql` | `DROP TABLE IF EXISTS tracked_plan;` | -| `V8__drop_plan_recalculation_job.sql` | `DROP TABLE IF EXISTS plan_recalculation_job;` | -| `V9__planning_state_urgent_reissue.sql` | `ALTER TABLE spacecraft_planning_state ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL;` | - -`planned_plan` — схему не меняем (новые значения статуса — это `VARCHAR`, миграция не нужна). Индексы оставляем. `ddl-auto: validate` — значит сущности должны точно соответствовать таблицам после миграций. - ---- - -## 7. Конфиг (итоговые значения) - -```yaml -planning: - notification-before-start-minutes: 263 - target-plan-duration-minutes: 1440 - target-plan-duration-delta-minutes: 240 - external-update-enabled: true - external-points-lookahead-days: 10 # было 3 - in-memory-history-days: 14 - external-update-fixed-delay-ms: 600000 - notification-check-fixed-delay-ms: 30000 - min-reissue-lead-minutes: 5 - reissue-prep-minutes: 30 # новый - worker-lock-ttl-seconds: 300 - -topics: - visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 - satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 - layin-failed: pcp.tgu.layin-failed.v1 # новый - plan-not-accepted: pcp.tgu.plan-not-accepted.v1 # новый - station-params-changed: pcp.tgu.station-params-changed.v1 # новый -``` - ---- - -## 8. Контракты Kafka (входящие, мы — консьюмер) - -Существующий `satellite-plan-decision` (`PlanDecisionMessage`) — без изменений. - -Новые топики, общий DTO `PlanExecutionFeedbackMessage`: - -```json -{ - "eventId": "uuid", // dedup-ключ (идемпотентность) - "spacecraftId": "string", - "planId": "uuid", // план, по которому пришёл негатив (текущая база) - "occurredAt": "2026-06-02T10:00:00", - "reason": "string|null" -} -``` - -- `pcp.tgu.layin-failed.v1` — «не заложен». -- `pcp.tgu.plan-not-accepted.v1` — «не принят». -- Поведение пока идентично; обработчики раздельные. - -Новый топик `pcp.tgu.station-params-changed.v1`, DTO `StationParamsChangedMessage`: - -```json -{ - "eventId": "uuid", // dedup-ключ (идемпотентность) - "stationId": "string|null", // какая станция изменилась; пока не используется для сужения - "occurredAt": "2026-06-02T10:00:00", - "reason": "string|null" -} -``` - -- При получении — поставить `visibility_refresh_job` по всем активным КА (перезапрос ЗРВ у баллистики), цепочка пересоберётся через on-demand проекцию. - ---- - -## 9. Допущения и риски - -- **Источник «ближайшей ЗРВ»** для срочной перевыдачи — тот же последний снапшот ЗРВ; если в нём нет окна со `startTime ≥ urgent_reissue_from`, голова пуста — КА ждёт следующего refresh окон (это корректное поведение, не ошибка). -- **Идемпотентность** новых событий — обязательна (at-least-once Kafka), через общий `processed_plan_decision_event`. -- **`ddl-auto: validate`**: после правок прогнать миграции на чистой БД и убедиться, что Hibernate-валидация проходит. -- **Совместимость UI**: JSON-форма `/api/plans` не меняется; виртуальные планы отдаются с тем же набором полей и `status: "PLANNED"`. -- **Сборка ресурсов**: при правке `application.yml` помнить про `./gradlew :services:pcp-tgu-service:processResources` (IntelliJ-рестарт не всегда пересобирает ресурсы). - ---- - -## 10. Критерии приёмки (по ним проверяем) - -### 10.1. Хранение «только выданное» -- [ ] При штатной работе (КА с окнами ЗРВ, ни одного дозревшего плана) в `planned_plan` **нет ни одной строки** для этого КА. Будущее видно только через `/api/plans` как `PLANNED`. -- [ ] Строка в `planned_plan` появляется **ровно в момент выдачи** и сразу в статусе `ISSUING` (не `PLANNED`). -- [ ] Статус `PLANNED` **никогда не пишется** в БД (проверяемо: `SELECT count(*) FROM planned_plan WHERE status='PLANNED'` = 0 в любой момент). -- [ ] Выданные планы и их история **не удаляются** (ретеншн-джоба нет; нет `DELETE`/`SUPERSEDED`-массовой чистки будущего). -- [ ] В коде нет материализации цепочки: `upsertPlannedByBusinessKey`, `supersedeObsoleteFuturePlans`, `expireMissedPlans` удалены. - -### 10.2. Проекция будущего -- [ ] `GET /api/plans` и `GET /api/plans/{spacecraftId}` возвращают persisted-планы **плюс** вычисленную проекцию будущего; JSON-контракт прежний, фронт работает без изменений. -- [ ] Проекция совпадает по содержанию с тем, что раньше материализовалось (та же монотонная цепочка из `PlanCalculationService`): для одних и тех же окон ЗРВ набор `(start,end,kpp)` будущих планов идентичен прежнему поведению. -- [ ] Нет дублей: если бизнес-ключ есть в persisted, проекция его не добавляет повторно. -- [ ] Левый край истории по-прежнему регулируется `historyDays`. - -### 10.3. Снятие машинерии и мёртвого кода -- [ ] Таблиц `tracked_plan` и `plan_recalculation_job` нет (миграции `DROP` применены); связанные сущности/репозитории/воркеры/сервисы удалены. -- [ ] `SpacecraftVisibilitySnapshotService` больше не ставит recalc-job; сохранение снапшота и hash-проверка работают. -- [ ] Проект компилируется без ссылок на удалённые классы; неиспользуемые методы репозитория убраны. - -### 10.4. Закладочный флоу (оптимизм + реакция на негатив) -- [ ] `ACCEPTED` → план переходит в `AWAITING_LAYIN`, становится базой; цепочка продолжается; `updateAcceptedPlanSlots`-job ставится. Никакого ожидания подтверждения закладки в виде доп. строк. -- [ ] Отсутствие любых событий после `ACCEPTED` = «заложен» (план остаётся базой, ничего не делаем). -- [ ] Событие `pcp.tgu.layin-failed.v1` по базовому плану → план в `LAYIN_FAILED`, взводится `urgent_reissue_from = now + reissue-prep-minutes`, и ближайшая ЗРВ ≥ этого времени **выдаётся немедленно** (не дожидаясь `issue_not_before`). -- [ ] Событие `pcp.tgu.plan-not-accepted.v1` → то же поведение, но статус `NOT_ACCEPTED`; обработчик **отдельный** (разные методы/консьюмеры), общее ядро `reissueOnNearestWindow`. -- [ ] Оба события **идемпотентны**: повтор того же `eventId` — no-op (повторной перевыдачи нет). -- [ ] `reissue-prep-minutes` берётся из конфига (по умолчанию 30); изменение значения меняет лид срочной выдачи. -- [ ] Если в снапшоте нет ЗРВ ≥ `urgent_reissue_from` — выдачи нет, флаг сохраняется, повторная попытка произойдёт после обновления окон (без ошибок/исключений в логе). - -### 10.4b. Реакция на изменение параметров станций -- [ ] Событие `pcp.tgu.station-params-changed.v1` ставит `visibility_refresh_job` по всем активным КА (reason `STATION_PARAMS_CHANGED`). -- [ ] После перезапроса ЗРВ снапшоты обновляются, и `/api/plans` отдаёт цепочку, пересобранную по новым окнам (без перезапуска сервиса и без ручного rebuild). -- [ ] Событие идемпотентно по `eventId` (повтор — без повторной постановки refresh). -- [ ] Уже выданные/принятые планы ретроспективно не меняются; пересобирается только будущее. - -### 10.5. Масштабирование / корректность под несколькими подами -- [ ] Claim во всех воркерах — `FOR UPDATE SKIP LOCKED`; выдача плана и обработка решения — под lock на КА (один план не выдаётся дважды при 2–3 подах). -- [ ] У одного КА одновременно не более одной активной попытки выдачи (`activeAttemptId`). -- [ ] Сервис стартует и работает с `external-points-lookahead-days: 10`. - -### 10.6. Конфиг и миграции -- [ ] `application.yml` и `config-repo/pcp-tgu-service.yaml` содержат итоговые значения раздела 7 (lookahead 10, `reissue-prep-minutes: 30`, два новых топика). -- [ ] `ddl-auto: validate` проходит на чистой БД после применения всех миграций (V7–V9). - -### 10.7. Тесты и сборка -- [ ] `./gradlew :services:pcp-tgu-service:test` — зелёный. -- [ ] Есть тесты на: проекцию, on-demand выдачу, urgent-path, `ACCEPTED→AWAITING_LAYIN`, оба новых консьюмера (идемпотентность + раздельность + общий reissue), read-объединение с дедупом. -- [ ] Старые тесты снятой машинерии удалены, оставшиеся — зелёные. - ---- - -## 11. Команды проверки - -```bash -# из корня монорепо -./gradlew :services:pcp-tgu-service:test -./gradlew :services:pcp-tgu-service:processResources # после правок application.yml - -# ручная проверка флоу (нужен tgu.test-controller.enabled=true локально): -# - запустить сервис на :7011, дождаться окон ЗРВ -# - GET http://localhost:7011/api/plans/{spacecraftId} -> в ответе PLANNED-проекция, в БД строк нет -# - сэмулировать ACCEPTED -> в БД ACCEPTED/AWAITING_LAYIN, проекция продолжается -# - отправить layin-failed/not-accepted -> терминальный статус + срочная выдача ближайшей ЗРВ -``` From 3638afdb8564ea9850fbb5ffa82bdc375cd5e9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 00:03:52 +0300 Subject: [PATCH 46/79] =?UTF-8?q?pcp:=20=D0=BE=D1=82=D0=BA=D0=B0=D1=82=20U?= =?UTF-8?q?TC-=D1=81=D0=BB=D0=BE=D1=8F=20=E2=80=94=20=D1=81=D0=BA=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=BD=D0=BE=D0=B9=20naive=20LocalDateTime=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B7=D0=BE=D0=BD=D1=8B=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возврат к модели «настенных часов»: одна ISO-строка без зоны ("2026-05-29T01:00:00") от БД до экрана, никто не привязывает её к часовому поясу, сдвигаться нечему. Бэкенд — снять зону с границы API: - DTO OffsetDateTime/Instant -> LocalDateTime и убрана конвертация atOffset(ZoneOffset.UTC)/toUtcOffset() в фабриках и мапперах: tgu PlanResponse/NewChainDTOs/TguPlanningDTO, request RequestApiDtos, complex ComplexPlanRun*DTO, dynamic ObservationParametersDebugDTO. - Удалён толерантный Jackson-слой: PcpTimeModule(+autoconfig, ServiceLoader, imports), PcpTimeModuleV3, LocalDateTimeTimeZoneSerializer; снята их регистрация в Camunda/Rest/dynamic JacksonConfig. Штатный JSR-310 (JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS=false) оставлен. - Kafka-конверт KafkaMessage.time сериализуется ISO без офсета. - mission-planing Mode/SurveyMode/DropMode entity и репозитории/сервис переведены на LocalDateTime; колонки БД остаются timestamptz (Hibernate мапит на naive без миграции — прецедент MissionEntity/PlanEntity). - RequestServiceClient (dynamic) синхронизирован по типу с request-service. Анти-регрессия: LocalDateTime.now(ZoneOffset.UTC) для серверных штампов оставлен (выбор настенных часов UTC, не зона на проводе). Фронт — снять офсет с egress: toUtcIso/фильтры отдают naive без Z (после удаления толерантного десериализатора штатный парсер падал бы на Z). Дисплейные хелперы (показ «как пришло», без сдвига) сохранены. Тесты: контрактные ждут naive ISO; удалены UTC-артефакты (нормализация офсетов) и тесты прослоек. Удалён docs/standards/DATETIME_UTC.md. Операционно перед выкатом: сервисы катить согласованно; Kafka-топики с ранее опубликованным офсетом +00:00 дренировать/проиграть. --- docs/standards/CHECKLIST.md | 4 +- docs/standards/DATETIME_UTC.md | 182 ------------------ .../PcpTimeJacksonAutoConfiguration.kt | 23 --- .../dto/satellite/mission/ModeResponseDTO.kt | 8 +- .../pcp/pcp_types_lib/message/KafkaMessage.kt | 10 +- .../json/LocalDateTimeTimeZoneSerializer.kt | 26 --- .../pcp_types_lib/utils/json/PcpTimeModule.kt | 124 ------------ .../com.fasterxml.jackson.databind.Module | 1 - ...ot.autoconfigure.AutoConfiguration.imports | 1 - .../LocalDateTimeTimeZoneSerializerTest.kt | 48 ----- .../utils/json/PcpTimeModuleTest.kt | 81 -------- .../dto/ComplexPlanRunResponseDTO.kt | 7 +- .../dto/ComplexPlanRunSummaryDTO.kt | 7 +- .../service/ComplexPlanRunMapper.kt | 19 +- .../controller/ComplexPlanControllerTest.kt | 6 +- .../controller/ComplexPlanRunApiTest.kt | 4 +- .../ComplexPlanRunOrchestrationServiceTest.kt | 13 +- .../space/nstart/pcp/complan/JacksonConfig.kt | 19 -- .../pcp/complan/apl/BasicPlanCalculator.kt | 3 +- .../complan/service/RequestServiceClient.kt | 20 +- .../types/ObservationParametersDebugDTO.kt | 3 +- ...bservationParametersDebugFileWriterTest.kt | 3 +- .../service/RequestServiceClientTest.kt | 26 +-- .../entity/DropModeEntity.kt | 4 +- .../entity/ModeEntity.kt | 4 +- .../entity/SurveyModeEntity.kt | 4 +- .../repository/ModeRepository.kt | 4 +- .../repository/SurveyModeRepository.kt | 6 +- .../service/MissionPlaningService.kt | 37 ++-- .../LegacySatelliteMissionTypeSupport.kt | 3 +- .../service/MissionCloneTest.kt | 15 +- .../service/MissionEditModesTest.kt | 5 +- .../service/MissionPlaningServiceTest.kt | 53 +---- .../controller/RequestController.kt | 18 +- .../requestservice/dto/GridManagementDtos.kt | 4 +- .../requestservice/dto/RequestApiDtos.kt | 46 ++--- .../service/GridRebuildService.kt | 4 +- .../requestservice/service/RequestService.kt | 49 +++-- .../GridManagementControllerTest.kt | 6 +- .../dto/RequestApiDtoContractTest.kt | 53 +---- .../dto/RequestResponseDtoContractTest.kt | 28 ++- .../RequestServiceListRequestsJpaTest.kt | 9 +- .../service/RequestServiceTest.kt | 5 +- .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 4 +- .../features/tgu-requests/TguRequestsTab.tsx | 2 +- services/pcp-tgu-ops-ui/src/utils/utcTime.ts | 7 +- .../config/CamundaJacksonConfig.kt | 5 - .../pcp_tgu_service/config/PcpTimeModuleV3.kt | 94 --------- .../config/RestJacksonConfig.kt | 21 +- .../pcp_tgu_service/dto/NewChainDTOs.kt | 15 +- .../pcp_tgu_service/dto/PlanResponse.kt | 27 ++- .../config/PcpTimeModuleV3Test.kt | 55 ------ .../dto/PlanResponseContractTest.kt | 24 +-- .../service/PlanQueryServiceTest.kt | 5 +- .../slots_service/dto/tgu/TguPlanningDTO.kt | 10 +- .../controller/CatalogControllerTest.kt | 6 +- .../dto/tgu/TguPlanningDtoContractTest.kt | 23 +-- 57 files changed, 230 insertions(+), 1063 deletions(-) delete mode 100644 docs/standards/DATETIME_UTC.md delete mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt delete mode 100755 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt delete mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt delete mode 100644 libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module delete mode 100644 libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports delete mode 100644 libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt delete mode 100644 libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt delete mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt delete mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 88e0b19..4dff708 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -21,8 +21,8 @@ - существующие публичные контракты не сломаны без явного запроса; - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; -- время на API — UTC; backend использует UTC-`now` (`Instant.now()` / `LocalDateTime.now(ZoneOffset.UTC)`), фронт парсит/форматирует через `utils/utcTime` (см. `docs/standards/DATETIME_UTC.md`); -- чтение времени на бэке толерантно к обоим форматам (naive=UTC и `Z`) — модуль наследуется из `pcp-types-lib` (`PcpTimeModule`) авто-конфигурацией; ручной `ObjectMapper`/не-pcp-types-lib сервис регистрирует модуль явно; +- время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now(ZoneOffset.UTC)`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`; +- egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z); - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/standards/DATETIME_UTC.md b/docs/standards/DATETIME_UTC.md deleted file mode 100644 index de1aaac..0000000 --- a/docs/standards/DATETIME_UTC.md +++ /dev/null @@ -1,182 +0,0 @@ -# DATETIME_UTC.md — единообразие времени (UTC) в группе сервисов `pcp` - -## Назначение - -Этот документ фиксирует соглашение по работе со временем во всей группе сервисов `pcp` -и состояние сервисов на момент аудита. На него ссылаются KDoc у DTO и комментарии в коде. -Цель — убрать неоднозначность парсинга/форматирования и неоднородность формата на проводе, -которая уже приводила к сдвигу времени на офсет пояса (для МСК — +3 ч). - -## 1. Соглашение (canonical) - -1. **Всё время — UTC.** На любом API времена представляют момент в UTC. В БД — тоже UTC. -2. **Формат на проводе — ISO-8601 с явной зоной `Z`** (предпочтительно). Где сервис исторически - отдаёт naive `LocalDateTime` (ISO без зоны, напр. `"2026-05-29T01:00:00"`), это допустимо - **только** при железном правиле «naive = UTC»; такой эндпоинт — кандидат на миграцию к `Z`. -3. **Backend:** - - «сейчас» — только UTC: `Instant.now()` или `LocalDateTime.now(ZoneOffset.UTC)`; **не** `LocalDateTime.now()`; - - конвертация `Instant`/`OffsetDateTime` → `LocalDateTime` для naive-полей — только через UTC - (`LocalDateTime.ofInstant(instant, ZoneOffset.UTC)`, `withOffsetSameInstant(ZoneOffset.UTC)`), - **никогда** через `ZoneId.systemDefault()` / `atZone(systemDefault())`; - - Jackson во всех сервисах настроен с `JavaTimeModule` + `disable(WRITE_DATES_AS_TIMESTAMPS)`, - поэтому `LocalDateTime` уходит как ISO без зоны, а `OffsetDateTime`/`Instant` — с `Z`/офсетом. -4. **Frontend (`pcp-tgu-ops-ui`):** - - парсинг строк бэка — только через `src/utils/utcTime.ts` (`parseUtc`); никаких «голых» - `new Date(string)` / `Date.parse(string)`; - - отображение — только UTC (`formatUtcDateTime/Date/Time` либо `toLocale*` с `timeZone: "UTC"`); - - `` — значение через `toUtcInputValue`, чтение через `parseUtc`, - отправка на offset-бэк — через `toUtcIso`. - - Инвариант закреплён тестом-стражем `src/utils/utcTime.guard.test.ts` (падает на запрещённых паттернах). -5. **Никакой неявной локализации** под пояс машины ни на фронте, ни в логах/ответах бэка. - -## 2. Эталоны переиспользования - -- Frontend: `services/pcp-tgu-ops-ui/src/utils/utcTime.ts` — единая точка парсинга/форматирования. -- Backend (чтение): `libs/pcp-types-lib` `utils/json/PcpTimeModule` — слой толерантности - (бэковый аналог `parseUtc`): любой потребитель читает и naive(=UTC), и `Z`/офсет (см. раздел ниже). -- Backend (запись): `pcp-request-service` отдаёт `OffsetDateTime` (строки с `Z`) и конвертирует вход - через `OffsetDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()` — корректный образец. - -## 2bis. Слой толерантности времени (включатель безопасного флипа) - -`PcpTimeModule` (`libs/pcp-types-lib/.../utils/json/PcpTimeModule.kt`) — Jackson-модуль с -толерантными **десериализаторами** для `LocalDateTime`, `OffsetDateTime`, `Instant`. Любой -потребитель с ним читает время в **обоих** форматах на проводе и нормализует к UTC: - -| Тип поля | naive `…T01:00:00` | с зоной `…T01:00:00Z` / `+03:00` | -|---|---|---| -| `LocalDateTime` | как есть (= UTC) | приводится к UTC, зона отбрасывается | -| `OffsetDateTime` | `atOffset(UTC)` | `withOffsetSameInstant(UTC)` | -| `Instant` | трактуется как момент UTC | штатно | - -Модуль трогает **только чтение** — формат записи определяется типом поля и не меняется, поэтому -подключение слоя само по себе не сдвигает ни один контракт. - -**Зачем.** Микросервисы деплоятся независимо. Раньше перевод продюсера `LocalDateTime` → `OffsetDateTime` -(на проводе появляется `Z`) ломал ещё не обновлённого потребителя — штатный `LocalDateTimeDeserializer` -кидает исключение на офсете. С толерантностью во всём флоте продюсера можно флипать **в любом порядке, -без синхронного деплоя**. Это снимает требование «продюсер + все потребители одним коммитом». - -**Подключение (три механизма, чтобы покрыть все мапперы):** -- Spring Boot auto-configuration `PcpTimeJacksonAutoConfiguration` (`@Bean Module`) + - `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` — Boot-managed - `ObjectMapper` каждого из 12 сервисов, зависящих от pcp-types-lib (правок в сервисах не требуется). -- Jackson SPI `META-INF/services/com.fasterxml.jackson.databind.Module` — ручные мапперы с - `findAndRegisterModules()` (напр. `pcp-request-service`). -- Точечная регистрация в мапперах, что обходят оба: `pcp-dynamic-plan-service/JacksonConfig`. - -**Особый случай — pcp-tgu-service.** Сервис мигрирован с WebFlux на **servlet/MVC** (см. ниже -«Миграция tgu на MVC») и теперь зависит от pcp-types-lib, как остальной флот. Но из-за Boot 4 на -HTTP-границе всё равно работает **Jackson 3**, поэтому время покрывается двумя путями: -- Jackson-2 модуль **из либы** (`pcp_types_lib.utils.json.PcpTimeModule`) — явно зарегистрирован в - ручном маппере `CamundaJacksonConfig` (Camunda/внутренний парсинг); своего зеркала больше нет. -- `config/PcpTimeModuleV3.kt` (**Jackson 3**, `tools.jackson.*`) — REST HTTP message converters (MVC), - которые Boot 4 авто-конфигурирует на Jackson 3 `JsonMapper`. Регистрируется бином - `tools.jackson.databind.JacksonModule` (`RestJacksonConfig`); `JacksonAutoConfiguration` собирает - все такие бины в общий `JsonMapper` (`builder.addModules(...)`). Лежит локально, т.к. либа даёт - только Jackson-2 модуль; поведение обязано совпадать с библиотечным, правки синхронизировать. - -Контракт слоя закреплён `PcpTimeModuleTest` (pcp-types-lib): оба формата → один и тот же UTC-момент, -плюс проверка авто-подхвата через SPI. - -## 3. Состояние сервисов (срез аудита) - -Формат на проводе определяется Jackson-сериализацией: `LocalDateTime` → ISO без зоны (naive, = UTC), -`OffsetDateTime`/`Instant` → ISO с `Z`. «Потребители» — кто читает время этого сервиса. - -| Сервис | Преобладает на границе API | Naive? | Вердикт | -|---|---|---|---| -| pcp-request-service | `OffsetDateTime` (`Z`) | нет | **эталон**: явная зона на проводе | -| pcp-tgu-service | REST `PlanResponse` — `OffsetDateTime` (`Z`); Kafka/прочие DTO — `LocalDateTime` | частично | `PlanResponse` **мигрирован на `Z`**; баги конвертаций исправлены; Kafka-DTO остаются naive (=UTC) | -| pcp-ui-service | `LocalDateTime` (агрегатор/CZML, summaries); план ТГУ — `OffsetDateTime` (`Z`) | частично | `TguPlanResponse` **мигрирован на `Z`** вслед за tgu; прочие naive DTO — задокументировать | -| pcp-complex-mission-service | `LocalDateTime` (+ часть `Instant`) | смешано | проверить смешение; задокументировать | -| pcp-dynamic-plan-service | `LocalDateTime` (+ offset на входе клиента) | да | задокументировать «=UTC» | -| pcp-ballistics-service | `LocalDateTime` | да | задокументировать «=UTC» | -| pcp-mission-planing-service | `LocalDateTime` (вход — `OffsetDateTime`) | да | задокументировать «=UTC» | -| slots-service | `LocalDateTime` | да | задокументировать «=UTC»; проверить `rs.getTimestamp().toLocalDateTime()` | -| pcp-coverage-scheme-service | `LocalDateTime` | да | задокументировать «=UTC» | -| pcp-route-processing-service | нет времени на границе API | — | — | -| pcp-satellite-catalog-service | нет времени на границе API | — | — | -| pcp-stations-service | нет времени на границе API | — | — | -| tle-monitoring-service | нет времени на границе API | — | — | - -## 4. Известные расхождения и под-задачи (миграция к `Z` — не «большим взрывом») - -Систематический класс: `LocalDateTime.now()` без UTC-зоны (~80 мест: entity-дефолты `createdAt/updatedAt`, -воркеры, use case'ы). На не-UTC JVM пишет локальное «сейчас» в UTC-поля. Низкий риск для audit-колонок, -но формально неверно. Рекомендованные под-задачи по сервисам: - -- **pcp-tgu-service** — заменить оставшиеся `LocalDateTime.now()` (воркеры выдачи/решений, `PlanQueryService` - cutoff, entity-дефолты) на `LocalDateTime.now(ZoneOffset.UTC)`; тест на cutoff истории. -- **slots-service** — проверить `SlotRepositoryImpl.rs.getTimestamp("tn").toLocalDateTime()` (зависит от - зоны драйвера); зафиксировать чтение в UTC. -- **pcp-mission-planing-service / pcp-dynamic-plan-service** — `OffsetDateTime.toLocalDateTime()` теряет - офсет; убедиться, что вход всегда UTC, иначе конвертировать через `withOffsetSameInstant(UTC)`. -- **Все сервисы с naive `LocalDateTime` на границе** — добавить сериализационный тест, фиксирующий формат - на проводе (есть `Z` / нет зоны), затем по согласованию мигрировать DTO к `OffsetDateTime`/`Instant` (UTC). - Фронтовый `parseUtc` уже совместим с обоими форматами, поэтому миграция безопасна при пер-сервисном раскате. - -### Контрактные тесты формата (зафиксировано) - -Оба формата на проводе закреплены сериализационными тестами (mapper зеркалит конфиг Spring Boot — -JavaTimeModule + `disable(WRITE_DATES_AS_TIMESTAMPS)`): - -- `PlanResponseContractTest` (pcp-tgu-service) — `OffsetDateTime` с `Z` (после миграции, см. ниже). -- `RequestResponseDtoContractTest` (pcp-request-service) — `OffsetDateTime` с `Z` (эталон). -- `TguPlanningDtoContractTest` (pcp-ui-service) — план ТГУ `OffsetDateTime` с `Z` + `Instant` с `Z`. - -Прочие сервисы с naive-временем (ballistics, complex-mission, dynamic-plan, slots, coverage-scheme, -mission-planing) используют тот же дефолтный Jackson-конфиг → тот же naive-формат; при желании -пер-сервисный страж копируется по образцу `RequestResponseDtoContractTest`. - -### Миграция к `Z` — выполнено - -- **План ТГУ (tgu → ui-service → фронт).** `PlanResponse` (pcp-tgu-service) и зависящий от него - `TguPlanResponse` (pcp-ui-service) переведены с naive `LocalDateTime` на `OffsetDateTime` в UTC → - на проводе `Z`. Домен/БД (`PlannedPlanEntity`/`CalculatedPlan`) остались naive `LocalDateTime` (=UTC); - конвертация только на границе (`atOffset(ZoneOffset.UTC)`). Потребитель один (ui-service), обновлён - синхронно; фронт `parseUtc` совместим. Контракт закреплён тестами выше. - -- **Слой толерантности (`PcpTimeModule`) — раскатан по флоту.** 12 сервисов на pcp-types-lib - получают его авто-конфигурацией/SPI; dynamic-plan — точечно; tgu — модулем из либы (Camunda, - Jackson 2) плюс локальное Jackson-3 зеркало для REST-границы MVC. Это включатель: дальнейший флип - продюсеров на `Z` больше не требует синхронного деплоя. - -После толерантности продюсеров можно флипать **в любом порядке**. Кандидаты: остальные naive-DTO -ui-service, ballistics, complex-mission, dynamic-plan, slots, coverage-scheme. - -### Исправленные баги формата - -- **`KafkaMessage.time` (общий Kafka-конверт, pcp-types-lib) — исправлено.** Было `time = LocalDateTime.now()` - (локальное!) + сериализация `LocalDateTimeTimeZoneSerializer` через `ZoneId.systemDefault()`: конверт - нёс МСК-стенные часы с `+03:00`, а потребитель (формат `…SSS[xxx]` в `LocalDateTime`) отбрасывал зону - → сдвиг на пояс. Стало `now(ZoneOffset.UTC)` + сериализация `atOffset(ZoneOffset.UTC)` → на проводе - `…+00:00`. **Инстант не изменился** (тот же момент), но стенные часы теперь совпадают с UTC, поэтому - потребитель, дропающий зону, получает корректное время. Закреплено `LocalDateTimeTimeZoneSerializerTest`. - -- **pcp-tgu-service REST-граница на Jackson 3** — теперь толерантна (см. 2bis). Добавлен Jackson-3 - вариант модуля `config/PcpTimeModuleV3.kt` и его регистрация бином `JacksonModule` - (`config/RestJacksonConfig.kt`); входящий REST-JSON читается в обоих форматах (naive=UTC и `Z`). - Закреплено `PcpTimeModuleV3Test`. - -### Миграция tgu на MVC - -- **pcp-tgu-service: WebFlux → servlet/MVC.** Сервис не имел реального реактивного кода — JPA - блокирующая, контроллеры оборачивали блокирующие вызовы в `Mono.fromCallable{}.subscribeOn( - boundedElastic())`, WebClient-вызовы `.block()`-ались сразу. Это был единственный WebFlux-only - сервис флота. Переведён на MVC: подключён pcp-types-lib (тянет `starter-web` → тип приложения - servlet), `springdoc` → `webmvc-ui`, контроллеры возвращают значения напрямую (без `Mono`). - `starter-webflux` оставлен только ради WebClient. Побочный эффект для времени: убрано локальное - Jackson-2 зеркало (`config/PcpTimeModule.kt`) в пользу модуля из либы; Jackson-3 зеркало остаётся - (Boot 4 держит Jackson 3 на HTTP-границе и для MVC). - -### Остаток (под-задачи) - -- Обе Jackson-среды tgu толерантны; отдельных остатков по чтению времени нет. При миграции - оставшихся продьюсеров на `Z` (раздел 4) добавлять сериализационные тесты контракта. - -## 5. Правило для нового кода - -- Frontend: только `utils/utcTime` (страж `utcTime.guard.test.ts` не даст просочиться нарушителю). -- Backend: UTC-`now`; на новых API предпочтителен явный `Z` (`OffsetDateTime`/`Instant`). -- Меняешь формат на проводе — обнови потребителей и добавь сериализационный тест (контракт не должен «уехать» молча). diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt deleted file mode 100644 index 6d1ae8f..0000000 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/configuration/PcpTimeJacksonAutoConfiguration.kt +++ /dev/null @@ -1,23 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.configuration - -import com.fasterxml.jackson.databind.Module -import org.springframework.boot.autoconfigure.AutoConfiguration -import org.springframework.context.annotation.Bean -import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule - -/** - * Регистрирует [PcpTimeModule] в Boot-managed `ObjectMapper` каждого сервиса, который подключает - * pcp-types-lib. Spring Boot собирает все бины типа [Module] из контекста в авто-конфигурируемый - * маппер, поэтому отдельный `@Bean` доезжает до всех сервисов без явного component-scan пакета либы. - * - * Ручные мапперы (вызывающие `findAndRegisterModules()`) подхватывают модуль через Jackson SPI — - * см. META-INF/services/com.fasterxml.jackson.databind.Module. - * - * Слой толерантности — см. [PcpTimeModule] и docs/standards/DATETIME_UTC.md. - */ -@AutoConfiguration -class PcpTimeJacksonAutoConfiguration { - - @Bean - fun pcpTimeModule(): Module = PcpTimeModule() -} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt index a14befc..9ca3e7f 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt @@ -3,7 +3,7 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import io.swagger.v3.oas.annotations.media.Schema -import java.time.OffsetDateTime +import java.time.LocalDateTime @Schema( description = "Базовая модель режима", @@ -28,7 +28,7 @@ sealed interface ModeResponseDTO { val planId: Long @get:Schema(description = "Время начала режима") - val timeStart: OffsetDateTime? + val timeStart: LocalDateTime? @get:Schema(description = "Номер витка") val revolution: Long @@ -41,7 +41,7 @@ sealed interface ModeResponseDTO { data class SurveyModeResponseDTO( override val id: Long = 0, override val planId: Long = 0, - override val timeStart: OffsetDateTime? = null, + override val timeStart: LocalDateTime? = null, override val revolution: Long = 0, override val type: String = "SURVEY", val status: String = "", @@ -60,7 +60,7 @@ data class SurveyModeResponseDTO( data class DropModeResponseDTO( override val id: Long = 0, override val planId: Long = 0, - override val timeStart: OffsetDateTime? = null, + override val timeStart: LocalDateTime? = null, override val revolution: Long = 0, override val type: String = "DROP", val station: String? = "", diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt index 55a7589..d2349a2 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/message/KafkaMessage.kt @@ -2,8 +2,6 @@ package space.nstart.pcp.pcp_types_lib.message import com.fasterxml.jackson.annotation.JsonFormat import com.fasterxml.jackson.annotation.JsonAlias -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import space.nstart.pcp.pcp_types_lib.utils.json.LocalDateTimeTimeZoneSerializer import java.time.LocalDateTime import java.time.ZoneOffset import java.util.* @@ -21,11 +19,9 @@ class KafkaMessage ( val id = UUID.randomUUID().toString() var source = "Example" - // Время конверта в UTC: naive LocalDateTime трактуется как UTC (см. docs/standards/DATETIME_UTC.md), - // сериализатор помечает его офсетом +00:00. Раньше now() + systemDefault давали локальные часы и - // несовпадение момента у потребителей, дропающих зону. - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") - @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) + // Время конверта — naive LocalDateTime, штампуется по настенным часам UTC (now(ZoneOffset.UTC)), + // сериализуется как ISO без зоны. + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") val time: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) val dataContentType = "application/json" diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt deleted file mode 100755 index 65b5373..0000000 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt +++ /dev/null @@ -1,26 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import java.time.LocalDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter - -/** - * Сериализует naive [LocalDateTime] как момент в **UTC** (офсет `+00:00`). - * По соглашению группы `pcp` naive LocalDateTime = UTC (см. docs/standards/DATETIME_UTC.md), - * поэтому зона навешивается через [ZoneOffset.UTC], а не через пояс машины. - * Прежняя реализация (`ZoneId.systemDefault()`) помечала те же часы локальным офсетом, из-за чего - * потребитель, дропающий зону, получал сдвиг на пояс. - */ -class LocalDateTimeTimeZoneSerializer: JsonSerializer() { - - companion object { - val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx") - } - - override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { - p1.writeString(p0?.atOffset(ZoneOffset.UTC)?.format(fmt)) - } -} \ No newline at end of file diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt deleted file mode 100644 index 3ad19eb..0000000 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModule.kt +++ /dev/null @@ -1,124 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.core.JsonToken -import com.fasterxml.jackson.databind.DeserializationContext -import com.fasterxml.jackson.databind.JsonDeserializer -import com.fasterxml.jackson.databind.module.SimpleModule -import com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer -import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeParseException - -/** - * Слой толерантности времени для всей группы `pcp` (бэковый аналог фронтового `parseUtc`). - * - * Любой потребитель с этим модулем читает время в **обоих** форматах на проводе: - * naive ISO без зоны (исторический формат, трактуется как UTC по соглашению - * docs/standards/DATETIME_UTC.md) и ISO с явной зоной/`Z` (формат после миграции). - * Все значения нормализуются к UTC. - * - * Зачем: микросервисы деплоятся независимо. Если включить толерантность во всём флоте, - * продюсер можно перевести `LocalDateTime` → `OffsetDateTime` (на проводе появляется `Z`) - * в любом порядке — потребитель не упадёт на парсинге офсета. Без этого слоя миграция - * формата требовала бы синхронного деплоя продюсера и всех потребителей. - * - * Модуль трогает **только десериализацию** (чтение). Формат записи определяется типом поля - * (`LocalDateTime` → naive, `OffsetDateTime`/`Instant` → `Z`) и здесь не меняется, чтобы - * подключение слоя само по себе не сдвигало ни один контракт. - * - * Подключается через Spring Boot auto-configuration ([PcpTimeJacksonAutoConfiguration]) для - * Boot-managed `ObjectMapper` и через Jackson SPI - * (`META-INF/services/com.fasterxml.jackson.databind.Module`) для ручных мапперов, - * вызывающих `findAndRegisterModules()`. - */ -class PcpTimeModule : SimpleModule(NAME) { - - init { - addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializer()) - addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializer()) - addDeserializer(Instant::class.java, TolerantInstantDeserializer()) - } - - companion object { - const val NAME = "PcpTimeModule" - } -} - -/** - * Читает [LocalDateTime] из обоих форматов: строка с зоной/`Z` нормализуется к UTC и теряет зону, - * naive-строка парсится как есть (= UTC). Нестроковые токены (массивы/таймстемпы) делегируются - * штатному [LocalDateTimeDeserializer], чтобы не сломать прочие представления. - */ -class TolerantLocalDateTimeDeserializer : JsonDeserializer() { - - private val default = LocalDateTimeDeserializer.INSTANCE - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - tryParseToUtc(text)?.let { return it.toLocalDateTime() } - } - return default.deserialize(p, ctxt) - } - - /** Возвращает момент в UTC, если строка несёт зону/офсет; иначе null (строка naive). */ - private fun tryParseToUtc(text: String): OffsetDateTime? = - try { - OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - null - } -} - -/** - * Читает [OffsetDateTime] из обоих форматов: naive-строка получает зону UTC, - * строка с зоной приводится к UTC. - */ -class TolerantOffsetDateTimeDeserializer : JsonDeserializer() { - - @Suppress("UNCHECKED_CAST") - private val default = InstantDeserializer.OFFSET_DATE_TIME as JsonDeserializer - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} - -/** - * Читает [Instant] из обоих форматов: строка с `Z`/офсетом — штатно, naive-строка трактуется - * как момент в UTC. - */ -class TolerantInstantDeserializer : JsonDeserializer() { - - @Suppress("UNCHECKED_CAST") - private val default = InstantDeserializer.INSTANT as JsonDeserializer - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.text.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).toInstant() - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module b/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module deleted file mode 100644 index 6e96a8a..0000000 --- a/libs/pcp-types-lib/src/main/resources/META-INF/services/com.fasterxml.jackson.databind.Module +++ /dev/null @@ -1 +0,0 @@ -space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule diff --git a/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports deleted file mode 100644 index 32f1b41..0000000 --- a/libs/pcp-types-lib/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ /dev/null @@ -1 +0,0 @@ -space.nstart.pcp.pcp_types_lib.configuration.PcpTimeJacksonAutoConfiguration diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt deleted file mode 100644 index 69de0d6..0000000 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializerTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.annotation.JsonFormat -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import java.time.LocalDateTime - -/** - * Контракт сериализатора времени Kafka-конверта ([LocalDateTimeTimeZoneSerializer]): - * naive LocalDateTime помечается офсетом UTC (`+00:00`), часы не сдвигаются. - * Закрывает баг прежней реализации через `ZoneId.systemDefault()` - * (см. docs/standards/DATETIME_UTC.md). - */ -class LocalDateTimeTimeZoneSerializerTest { - - private val mapper = jacksonObjectMapper().findAndRegisterModules() - - // Зеркалит аннотации реального поля KafkaMessage.time (паттерн с опциональным офсетом). - private data class Holder( - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS[xxx]") - @JsonSerialize(using = LocalDateTimeTimeZoneSerializer::class) - val t: LocalDateTime - ) - - @Test - fun `serializes naive LocalDateTime with UTC offset, no wall-clock shift`() { - val json = mapper.writeValueAsString(Holder(LocalDateTime.of(2026, 5, 29, 1, 0, 0))) - - assertTrue( - json.contains("2026-05-29T01:00:00.000+00:00"), - "ожидался UTC-офсет без сдвига часов, получено: $json" - ) - } - - @Test - fun `round-trips through the tolerance layer to the same UTC moment`() { - val original = LocalDateTime.of(2026, 5, 29, 1, 0, 0) - - val json = mapper.writeValueAsString(Holder(original)) - val back: Holder = mapper.readValue(json) - - assertEquals(original, back.t) - } -} diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt deleted file mode 100644 index c14c4fe..0000000 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/PcpTimeModuleTest.kt +++ /dev/null @@ -1,81 +0,0 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset - -/** - * Контракт слоя толерантности времени ([PcpTimeModule]): любой потребитель читает время - * в обоих форматах на проводе (naive=UTC и `Z`/офсет), всё нормализуется к UTC. - * Это и есть то, что делает безопасным пер-сервисный флип продюсера на `Z` - * без синхронного деплоя (см. docs/standards/DATETIME_UTC.md). - */ -class PcpTimeModuleTest { - - private val mapper = jacksonObjectMapper().registerModule(PcpTimeModule()) - - private data class LdtHolder(val t: LocalDateTime) - private data class OdtHolder(val t: OffsetDateTime) - private data class InstantHolder(val t: Instant) - - @Test - fun `LocalDateTime field reads naive and Z as the same UTC moment`() { - val naive: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") - val zoned: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - - val expected = LocalDateTime.of(2026, 5, 29, 1, 0, 0) - assertEquals(expected, naive.t) - assertEquals(expected, zoned.t, "Z-строка должна дать тот же UTC-момент, что naive") - } - - @Test - fun `LocalDateTime field converts explicit offset to UTC`() { - // +03:00 01:00 == 22:00 UTC предыдущего дня - val moscow: LdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00+03:00"}""") - - assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), moscow.t) - } - - @Test - fun `LocalDateTime field accepts Z without throwing (the regression the layer prevents)`() { - // Штатный LocalDateTimeDeserializer кинул бы исключение на офсете/Z. - assertDoesNotThrow { - mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - } - } - - @Test - fun `OffsetDateTime field reads naive as UTC and normalizes offset to UTC`() { - val naive: OdtHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") - val moscow: OdtHolder = mapper.readValue("""{"t":"2026-05-29T04:00:00+03:00"}""") - - assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), naive.t) - assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), moscow.t) - } - - @Test - fun `Instant field reads naive as UTC and Z as is`() { - val naive: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00"}""") - val zoned: InstantHolder = mapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - - assertEquals(Instant.parse("2026-05-29T01:00:00Z"), naive.t) - assertEquals(Instant.parse("2026-05-29T01:00:00Z"), zoned.t) - } - - @Test - fun `module is auto-discovered via Jackson SPI (findAndRegisterModules)`() { - // Подтверждает, что META-INF/services/com.fasterxml.jackson.databind.Module работает: - // ручной маппер с findAndRegisterModules() получает толерантность без явной регистрации. - val spiMapper = jacksonObjectMapper().findAndRegisterModules() - - val holder: LdtHolder = spiMapper.readValue("""{"t":"2026-05-29T01:00:00Z"}""") - - assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), holder.t) - } -} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt index 56c5887..42ed9c9 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import java.time.LocalDateTime -import java.time.OffsetDateTime data class ComplexPlanRunResponseDTO( val runId: Long, @@ -11,9 +10,9 @@ data class ComplexPlanRunResponseDTO( val intervalEnd: LocalDateTime, val satelliteIds: List, val requestedBy: String?, - val createdAt: OffsetDateTime, - val startedAt: OffsetDateTime?, - val finishedAt: OffsetDateTime?, + val createdAt: LocalDateTime, + val startedAt: LocalDateTime?, + val finishedAt: LocalDateTime?, val durationMs: Long?, val errorMessage: String?, val calculatedModesCount: Int, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt index d401132..aa5d252 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import java.time.LocalDateTime -import java.time.OffsetDateTime data class ComplexPlanRunSummaryDTO( val runId: Long, @@ -11,9 +10,9 @@ data class ComplexPlanRunSummaryDTO( val intervalEnd: LocalDateTime, val satelliteIds: List, val requestedBy: String?, - val createdAt: OffsetDateTime, - val startedAt: OffsetDateTime?, - val finishedAt: OffsetDateTime?, + val createdAt: LocalDateTime, + val startedAt: LocalDateTime?, + val finishedAt: LocalDateTime?, val durationMs: Long?, val errorMessage: String?, val calculatedModesCount: Int, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt index 7d6bfd1..37d2c55 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt @@ -4,9 +4,6 @@ import org.springframework.stereotype.Component import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset @Component class ComplexPlanRunMapper { @@ -19,9 +16,9 @@ class ComplexPlanRunMapper { intervalEnd = entity.intervalEnd, satelliteIds = satelliteIds(entity), requestedBy = entity.requestedBy, - createdAt = entity.createdAt.toUtcOffset(), - startedAt = entity.startedAt?.toUtcOffset(), - finishedAt = entity.finishedAt?.toUtcOffset(), + createdAt = entity.createdAt, + startedAt = entity.startedAt, + finishedAt = entity.finishedAt, durationMs = entity.durationMs, errorMessage = entity.errorMessage, calculatedModesCount = entity.calculatedModesCount, @@ -46,9 +43,9 @@ class ComplexPlanRunMapper { intervalEnd = entity.intervalEnd, satelliteIds = satelliteIds(entity), requestedBy = entity.requestedBy, - createdAt = entity.createdAt.toUtcOffset(), - startedAt = entity.startedAt?.toUtcOffset(), - finishedAt = entity.finishedAt?.toUtcOffset(), + createdAt = entity.createdAt, + startedAt = entity.startedAt, + finishedAt = entity.finishedAt, durationMs = entity.durationMs, errorMessage = entity.errorMessage, calculatedModesCount = entity.calculatedModesCount, @@ -75,8 +72,4 @@ class ComplexPlanRunMapper { .mapNotNull { it.id } .distinct() .sorted() - - private fun LocalDateTime.toUtcOffset(): OffsetDateTime = - atOffset(ZoneOffset.UTC) - } diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt index 669a3db..b1b9915 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt @@ -50,9 +50,9 @@ class ComplexPlanControllerTest { intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC), - startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC), - finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC), + createdAt = LocalDateTime.of(2026, 3, 26, 9, 59), + startedAt = LocalDateTime.of(2026, 3, 26, 10, 0), + finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1), durationMs = 60000, errorMessage = null, calculatedModesCount = 1, diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt index 83a23c6..9fdbebc 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt @@ -22,9 +22,9 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistence import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ComplexPlanRunApiTest { @@ -81,7 +81,7 @@ class ComplexPlanRunApiTest { assertEquals(10.0, response.totalRequestsArea) assertEquals(4.0, response.coveredArea) assertEquals(40.0, response.coveredAreaPercent) - assertEquals(ZoneOffset.UTC, response.createdAt.offset) + assertNotNull(response.createdAt) } @Test diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt index 7ad27df..0956d92 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt @@ -16,7 +16,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -191,9 +190,9 @@ class ComplexPlanRunOrchestrationServiceTest { intervalEnd = request.intervalEnd, satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), - startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), - finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + createdAt = request.intervalStart.minusMinutes(1), + startedAt = request.intervalStart, + finishedAt = request.intervalStart.plusMinutes(1), durationMs = 60_000, errorMessage = null, calculatedModesCount = 1, @@ -301,9 +300,9 @@ class ComplexPlanRunOrchestrationServiceTest { intervalEnd = request.intervalEnd, satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), - startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), - finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + createdAt = request.intervalStart.minusMinutes(1), + startedAt = request.intervalStart, + finishedAt = request.intervalStart.plusMinutes(1), durationMs = 60_000, errorMessage = null, calculatedModesCount = 0, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt index b7e6727..6c33e87 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt @@ -6,34 +6,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule - - @Configuration class JacksonConfig { @Bean fun objectMapper(): ObjectMapper { val mapper = ObjectMapper() - - // Настраиваем формат для LocalDateTime -// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz") - -// module.addSerializer( -// LocalDateTime::class.java, -// LocalDateTimeSerializer(formatter) -// ) -// module.addDeserializer( -// LocalDateTime::class.java, -// LocalDateTimeDeserializer(formatter) -// ) mapper.registerModule(JavaTimeModule()) .registerKotlinModule() - // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. - // Этот маппер создаётся вручную и не подхватывает модуль ни через Boot-бин, ни через SPI. - .registerModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - return mapper } } diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt index 2bdcfff..78f7bff 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt @@ -48,6 +48,7 @@ import java.time.Duration import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneOffset import java.util.UUID import java.util.concurrent.ExecutionException import java.util.concurrent.Executors @@ -841,7 +842,7 @@ class BasicPlanCalculator( val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) { observationParametersDebugFileWriter.write( ObservationParametersDebugFileDTO( - createdAt = Instant.now(), + createdAt = LocalDateTime.now(ZoneOffset.UTC), requestId = request.requestId, satelliteIds = selectedSatelliteIds, missingSatelliteIds = missingSatelliteIds, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt index da31900..98cccf2 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt @@ -7,8 +7,7 @@ import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestClient import org.springframework.web.util.UriComponentsBuilder import space.nstart.pcp.complan.types.RequestItemDTO -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** Получение заявок из pcp-request-service /v1 API. */ @@ -44,10 +43,9 @@ class RequestServiceClient { id = id, name = name, geometry = geometry, - // Нормализуем к UTC: pcp-request-service шлёт `Z`, но голый toLocalDateTime() уехал бы - // при любом ненулевом офсете. Все naive LocalDateTime в системе — UTC (docs/standards/DATETIME_UTC.md). - intervalBegin = beginDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), - intervalEnd = endDateTime.withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime(), + // pcp-request-service шлёт naive LocalDateTime без зоны — берём как есть (настенные часы UTC). + intervalBegin = beginDateTime, + intervalEnd = endDateTime, resolution = resolution(), importance = importance, ) @@ -66,16 +64,16 @@ data class RequestResponseDto( val surveyType: String = "", val geometry: String, val importance: Double, - val beginDateTime: OffsetDateTime, - val endDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, + val endDateTime: LocalDateTime, val kpp: List = emptyList(), val highPriorityTransmit: Boolean = false, val optics: SurveyParamsDto? = null, val rsa: SurveyParamsDto? = null, val coverage: CoverageStateDto = CoverageStateDto(), - val createdAt: OffsetDateTime? = null, - val updatedAt: OffsetDateTime? = null, - val deletedAt: OffsetDateTime? = null, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, + val deletedAt: LocalDateTime? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt index 6db84b0..76620ae 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt @@ -3,7 +3,6 @@ package space.nstart.pcp.complan.types import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty import io.swagger.v3.oas.annotations.media.Schema -import java.time.Instant import java.time.LocalDateTime import java.util.UUID @@ -116,7 +115,7 @@ data class ObservationParametersDebugFileDTO( @get:JsonProperty("createdAt") @param:JsonProperty("createdAt") - val createdAt: Instant, + val createdAt: LocalDateTime, @get:JsonProperty("requestId") @param:JsonProperty("requestId") diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt index 2f59ed7..b2217ee 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt @@ -9,7 +9,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO import java.nio.file.Files import java.nio.file.Path -import java.time.Instant import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test @@ -29,7 +28,7 @@ class ObservationParametersDebugFileWriterTest { outputDir = tempDir.resolve("observation-parameters").toString() ) val artifact = ObservationParametersDebugFileDTO( - createdAt = Instant.parse("2026-05-05T10:15:30Z"), + createdAt = LocalDateTime.parse("2026-05-05T10:15:30"), requestId = requestId, satelliteIds = listOf(1001L), missingSatelliteIds = emptyList(), diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt index ad1fafd..193a85c 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt @@ -62,29 +62,11 @@ class RequestServiceClientTest { assertFalse(requestedUris.any { uri -> uri.path.startsWith("/api/v1/requests") }) } - @Test - fun `getRequest normalizes non-UTC offsets to UTC LocalDateTime`() { - val requestId = UUID.fromString("00000000-0000-0000-0000-000000000002") - // Источник прислал то же мгновение, но со смещением +03:00. Голый toLocalDateTime() оставил бы - // локальную стенку (06:04:05); нормализация к UTC обязана дать 03:04:05. - server = serverWithRequestResponse( - mutableListOf(), - requestId, - beginDateTime = "2026-01-02T06:04:05+03:00", - endDateTime = "2026-01-03T07:05:06+03:00", - ) - - val request = requestServiceClient().getRequest(requestId)!! - - assertEquals(LocalDateTime.of(2026, 1, 2, 3, 4, 5), request.intervalBegin) - assertEquals(LocalDateTime.of(2026, 1, 3, 4, 5, 6), request.intervalEnd) - } - private fun serverWithRequestResponse( requestedUris: MutableList, requestId: UUID, - beginDateTime: String = "2026-01-02T03:04:05Z", - endDateTime: String = "2026-01-03T04:05:06Z", + beginDateTime: String = "2026-01-02T03:04:05", + endDateTime: String = "2026-01-03T04:05:06", ): HttpServer = HttpServer.create(InetSocketAddress(0), 0).apply { createContext("/v1/requests") { exchange -> @@ -110,8 +92,8 @@ class RequestServiceClientTest { }, "rsa": null, "coverage": {"currentPercent": 0.0}, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z", + "createdAt": "2026-01-01T00:00:00", + "updatedAt": "2026-01-01T00:00:00", "deletedAt": null } """.trimIndent() diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt index 8a614ea..0b065db 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt @@ -4,7 +4,7 @@ import jakarta.persistence.Column import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity import jakarta.persistence.Table -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "drop_modes") @@ -12,7 +12,7 @@ import java.time.OffsetDateTime class DropModeEntity( id: Long? = null, plan: PlanEntity? = null, - timeStart: OffsetDateTime? = null, + timeStart: LocalDateTime? = null, revolution: Long = 0, @Column(name = "station") diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt index 75daeec..ab7e87d 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt @@ -13,7 +13,7 @@ import jakarta.persistence.InheritanceType import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "modes") @@ -29,7 +29,7 @@ abstract class ModeEntity( open var plan: PlanEntity? = null, @Column(name = "time_start") - open var timeStart: OffsetDateTime? = null, + open var timeStart: LocalDateTime? = null, @Column(name = "revolution", nullable = false) open var revolution: Long = 0 diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt index ee053fc..cdeaacc 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt @@ -7,7 +7,7 @@ import jakarta.persistence.EnumType import jakarta.persistence.Enumerated import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "survey_modes") @@ -15,7 +15,7 @@ import java.time.OffsetDateTime class SurveyModeEntity( id: Long? = null, plan: PlanEntity? = null, - timeStart: OffsetDateTime? = null, + timeStart: LocalDateTime? = null, revolution: Long = 0, @Column(name = "lat", nullable = false) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt index e75ccfc..f199ba3 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt @@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID interface ModeRepository : JpaRepository { @@ -14,7 +14,7 @@ interface ModeRepository : JpaRepository { fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( satelliteId: Long, - timeStop: OffsetDateTime + timeStop: LocalDateTime ): List fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt index b264ef1..064a71b 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt @@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.query.Param import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID interface SurveyModeRepository : JpaRepository { @@ -26,7 +26,7 @@ interface SurveyModeRepository : JpaRepository { fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( @Param("satelliteNumber") satelliteNumber: String, @Param("revolution") revolution: Long, - @Param("timeStartFrom") timeStartFrom: OffsetDateTime, - @Param("timeStartTo") timeStartTo: OffsetDateTime + @Param("timeStartFrom") timeStartFrom: LocalDateTime, + @Param("timeStartTo") timeStartTo: LocalDateTime ): List } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index 18b29aa..d48476a 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -62,7 +62,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.Duration import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI @@ -305,7 +304,7 @@ class MissionPlaningService( val modes = modeRepository .findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( satelliteId, - timeStop.atOffset(ZoneOffset.UTC) + timeStop ) .filter { it.intersects(timeStart, timeStop) } @@ -346,27 +345,18 @@ class MissionPlaningService( .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } } - /** - * Нормализует `OffsetDateTime` к UTC и отбрасывает зону. Все naive `LocalDateTime` в сервисе — UTC - * (см. docs/standards/DATETIME_UTC.md), поэтому сравнивать их можно только с UTC-стенкой времени мода, - * а не с локальной стенкой произвольного офсета. Голый `toLocalDateTime()` был корректен лишь пока - * источник слал офсет 0 (`Z`). - */ - private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = - withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - /** * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт). * Применяется при fork-forward-клоне: включения строго вне окна не клонируются. */ private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { - val modeStart = timeStart?.toUtcLocalDateTime() ?: return false + val modeStart = timeStart ?: return false val modeStop = modeStop() ?: return false return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { - val modeStart = timeStart?.toUtcLocalDateTime() ?: return null + val modeStart = timeStart ?: return null val durationSeconds = when (this) { is SurveyModeEntity -> duration is DropModeEntity -> duration @@ -731,7 +721,7 @@ class MissionPlaningService( val saved = surveyModeRepository.save( SurveyModeEntity( plan = plan, - timeStart = built.timeStart.atOffset(ZoneOffset.UTC), + timeStart = built.timeStart, revolution = built.revolution, lat = built.lat, long = built.longitude, @@ -805,7 +795,7 @@ class MissionPlaningService( } val start = survey.timeStart ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") - val surveyEnd = start.toUtcLocalDateTime() + val surveyEnd = start .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(req.zoneStart)) { throw CustomErrorException( @@ -826,7 +816,7 @@ class MissionPlaningService( val dropMode = dropModeRepository.save( DropModeEntity( plan = plan, - timeStart = req.zoneStart.atOffset(ZoneOffset.UTC), + timeStart = req.zoneStart, revolution = req.revolution, station = req.station, duration = usedDuration @@ -867,7 +857,7 @@ class MissionPlaningService( } val built = buildContour(req) - survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC) + survey.timeStart = built.timeStart survey.revolution = built.revolution survey.lat = built.lat survey.long = built.longitude @@ -1164,12 +1154,11 @@ class MissionPlaningService( val iterator = unassigned.iterator() while (iterator.hasNext()) { val survey = iterator.next() - val surveyStartOffset = survey.timeStart - if (surveyStartOffset == null) { + val surveyStart = survey.timeStart + if (surveyStart == null) { iterator.remove() continue } - val surveyStart = surveyStartOffset.toUtcLocalDateTime() val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(zoneStart)) { @@ -1193,7 +1182,7 @@ class MissionPlaningService( val dropMode = dropModeRepository.save( DropModeEntity( plan = plan, - timeStart = zoneStart.atOffset(ZoneOffset.UTC), + timeStart = zoneStart, revolution = zone.revolution, station = zone.stationId.toString(), duration = usedDuration @@ -1294,8 +1283,8 @@ class MissionPlaningService( @Transactional(readOnly = true) fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? { - val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1) - val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1) + val searchStart = routePassport.intervalBegin.minusSeconds(1) + val searchEnd = routePassport.intervalBegin.plusSeconds(1) val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( satelliteNumber = routePassport.kaShort.trim(), revolution = routePassport.orbitNumber, @@ -1305,7 +1294,7 @@ class MissionPlaningService( return candidates.minByOrNull { candidate -> val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE - abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis()) + abs(Duration.between(routePassport.intervalBegin, candidateStart).toMillis()) } } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt index 4b02ce8..9f1868c 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt @@ -6,7 +6,6 @@ import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO -import java.time.ZoneOffset abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { override fun prepareSurvey( @@ -15,7 +14,7 @@ abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { plan: PlanEntity ): SurveyModeEntity = SurveyModeEntity( plan = plan, - timeStart = surv.startTime.atOffset(ZoneOffset.UTC), + timeStart = surv.startTime, revolution = surv.revolution, lat = surv.lat, long = surv.longitude, diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt index e0fcb57..3500d55 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -27,8 +27,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.Optional import java.util.UUID @@ -263,27 +261,24 @@ class MissionCloneTest { val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1) planStore[plan.id!!] = plan val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0 - val s1 = survey(plan, surveyStart.atOffset(ZoneOffset.UTC), duration) - val s2 = survey(plan, surveyStart.plusMinutes(1).atOffset(ZoneOffset.UTC), duration) + val s1 = survey(plan, surveyStart, duration) + val s2 = survey(plan, surveyStart.plusMinutes(1), duration) val d = drop(plan, surveyStart.plusMinutes(5)) return SourceFixture(mission.missionId, plan, s1, s2, d) } - private fun survey(plan: PlanEntity, startOffset: OffsetDateTime, duration: Double = 300.0): SurveyModeEntity { + private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0): SurveyModeEntity { val id = ++modeAutoId - val s = SurveyModeEntity(id = id, plan = plan, timeStart = startOffset, revolution = id, + val s = SurveyModeEntity(id = id, plan = plan, timeStart = start, revolution = id, lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL, status = SurveyModeStatus.PLANNED) surveyStore[id] = s return s } - private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0) = - survey(plan, start.atOffset(ZoneOffset.UTC), duration) - private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity { val id = ++modeAutoId - val d = DropModeEntity(id = id, plan = plan, timeStart = start.atOffset(ZoneOffset.UTC), + val d = DropModeEntity(id = id, plan = plan, timeStart = start, revolution = id, station = "102", duration = 60.0) dropStore[id] = d return d diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt index 05c9ab0..639e5e7 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -32,7 +32,6 @@ import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import java.time.LocalDateTime -import java.time.ZoneOffset import java.util.UUID class MissionEditModesTest { @@ -262,7 +261,7 @@ class MissionEditModesTest { private fun survey(): SurveyModeEntity { val id = ++autoId val s = SurveyModeEntity( - id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0).atOffset(ZoneOffset.UTC), + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0), revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0, status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL ) @@ -273,7 +272,7 @@ class MissionEditModesTest { private fun drop(): DropModeEntity { val id = ++autoId val d = DropModeEntity( - id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0).atOffset(ZoneOffset.UTC), + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0), revolution = id, station = "102", duration = 60.0 ) dropStore[id] = d diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt index 2991f1a..54f43f8 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt @@ -41,8 +41,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID import java.math.BigDecimal @@ -93,7 +91,7 @@ class MissionPlaningServiceTest { val overlappingSurvey = SurveyModeEntity( id = 1L, plan = plan, - timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.minusMinutes(5), revolution = 10, lat = 55.0, long = 37.0, @@ -105,7 +103,7 @@ class MissionPlaningServiceTest { val inWindowSurvey = SurveyModeEntity( id = 2L, plan = plan, - timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.plusMinutes(10), revolution = 11, lat = 56.0, long = 38.0, @@ -117,7 +115,7 @@ class MissionPlaningServiceTest { val inWindowDrop = DropModeEntity( id = 3L, plan = plan, - timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.plusMinutes(20), revolution = 12, station = "station-1", duration = 120.0 @@ -126,7 +124,7 @@ class MissionPlaningServiceTest { `when`( modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( 77, - intervalStop.atOffset(ZoneOffset.UTC) + intervalStop ) ).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop)) `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn( @@ -153,41 +151,6 @@ class MissionPlaningServiceTest { assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys) } - @Test - fun `modesBySatelliteAndInterval normalizes non-UTC mode offsets to UTC before matching`() { - val mission = MissionEntity(missionId = UUID.randomUUID(), satelliteId = 77) - val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1) - val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0) - val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0) - - // То же мгновение, что 10:10 UTC (внутри окна), но выражено со смещением +03:00 → 13:10. - // Голый toLocalDateTime() дал бы 13:10 и выкинул мод из окна; нормализация к UTC оставляет его. - val offsetSurvey = SurveyModeEntity( - id = 1L, - plan = plan, - timeStart = OffsetDateTime.of(2026, 3, 16, 13, 10, 0, 0, ZoneOffset.ofHours(3)), - revolution = 11, - lat = 56.0, - long = 38.0, - duration = 300.0, - contourWkt = "POLYGON((0 0,1 0,1 1,0 0))", - roll = 1.0, - source = SurveyModeSource.SLOT - ) - - `when`( - modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( - 77, - intervalStop.atOffset(ZoneOffset.UTC) - ) - ).thenReturn(listOf(offsetSurvey)) - `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L))).thenReturn(emptyList()) - - val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop) - - assertEquals(listOf(1L), result.map { it.id }) - } - @Test fun `modesBySatelliteAndInterval rejects inverted interval`() { val exception = assertThrows(ResponseStatusException::class.java) { @@ -223,12 +186,12 @@ class MissionPlaningServiceTest { ) val exactMode = SurveyModeEntity( id = 10L, - timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC), + timeStart = routePassport.intervalBegin, revolution = 321 ) val nearMode = SurveyModeEntity( id = 11L, - timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC), + timeStart = routePassport.intervalBegin.plusSeconds(1), revolution = 321 ) @@ -236,8 +199,8 @@ class MissionPlaningServiceTest { surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( "22", 321, - OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC), - OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC) + routePassport.intervalBegin.minusSeconds(1), + routePassport.intervalBegin.plusSeconds(1) ) ).thenReturn(listOf(nearMode, exactMode)) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt index c1d5367..dd56310 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID @RestController @@ -41,10 +41,10 @@ class RequestController( @RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) kpp: Int?, @RequestParam(required = false) highPriorityTransmit: Boolean?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?, @RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int, @@ -73,10 +73,10 @@ class RequestController( @RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) kpp: Int?, @RequestParam(required = false) highPriorityTransmit: Boolean?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?, @RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt index 669b84d..7c4cabb 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt @@ -3,7 +3,7 @@ package org.nstart.dep265.requestservice.dto import com.fasterxml.jackson.annotation.JsonFormat import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.NotNull -import java.time.OffsetDateTime +import java.time.LocalDateTime /** * Текущие настройки сетки. @@ -38,5 +38,5 @@ data class GridRebuildResponseDto( /** Дата-время завершения перестроения в UTC. */ @field:JsonFormat(shape = JsonFormat.Shape.STRING) - val rebuiltAt: OffsetDateTime, + val rebuiltAt: LocalDateTime, ) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt index 2131f87..a67df9a 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt @@ -11,7 +11,7 @@ import jakarta.validation.constraints.Min import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID /** @@ -48,11 +48,11 @@ data class CreateRequestDto( /** Начало временного окна выполнения заявки. */ @field:NotNull - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ @field:NotNull - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -120,10 +120,10 @@ data class RequestResponseDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -141,13 +141,13 @@ data class RequestResponseDto( val coverage: CoverageStateDto, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -170,10 +170,10 @@ data class RequestSummaryResponseDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -185,13 +185,13 @@ data class RequestSummaryResponseDto( val coveragePercent: Double, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -237,10 +237,10 @@ data class RequestMapItemDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -252,13 +252,13 @@ data class RequestMapItemDto( val coveragePercent: Double, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -317,7 +317,7 @@ data class DeleteRequestResponseDto( val id: UUID, /** Время soft delete. */ - val deletedAt: OffsetDateTime, + val deletedAt: LocalDateTime, ) /** @@ -421,16 +421,16 @@ data class RequestListFilter( val highPriorityTransmit: Boolean? = null, /** Нижняя граница beginDateTime, включительно. */ - val beginFrom: OffsetDateTime? = null, + val beginFrom: LocalDateTime? = null, /** Верхняя граница beginDateTime, включительно. */ - val beginTo: OffsetDateTime? = null, + val beginTo: LocalDateTime? = null, /** Нижняя граница endDateTime, включительно. */ - val endFrom: OffsetDateTime? = null, + val endFrom: LocalDateTime? = null, /** Верхняя граница endDateTime, включительно. */ - val endTo: OffsetDateTime? = null, + val endTo: LocalDateTime? = null, /** Включать soft-deleted заявки в результат. */ val includeDeleted: Boolean = false, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt index 7cf002b..eee1af5 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt @@ -7,7 +7,7 @@ import org.nstart.dep265.requestservice.repository.RequestCellRepository import org.nstart.dep265.requestservice.repository.RequestRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.time.ZoneOffset class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running") @@ -41,7 +41,7 @@ class GridRebuildService( return GridRebuildResponseDto( cellsCount = earthCellRepository.count(), rebuiltRequestProjectionsCount = activeRequests.size.toLong(), - rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC), + rebuiltAt = LocalDateTime.now(ZoneOffset.UTC), ) } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index c4f3d73..a59ead0 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -39,7 +39,6 @@ import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime -import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.UUID @@ -82,8 +81,8 @@ class RequestService( remainingArea = geometryArea, coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, importance = importance, - beginDateTime = request.beginDateTime.toUtcLocalDateTime(), - endDateTime = request.endDateTime.toUtcLocalDateTime(), + beginDateTime = request.beginDateTime, + endDateTime = request.endDateTime, kpp = request.kpp.toMutableList(), highPriorityTransmit = request.highPriorityTransmit, surveyType = surveyType, @@ -162,7 +161,7 @@ class RequestService( requestCellRepository.deleteByRequestId(request.id) return DeleteRequestResponseDto( id = request.id, - deletedAt = deletedAt.toOffsetDateTime(), + deletedAt = deletedAt, ) } @@ -206,10 +205,10 @@ class RequestService( surveyType = query.surveyType?.toEntity(), kpp = query.kpp, highPriorityTransmit = query.highPriorityTransmit, - beginFrom = query.beginFrom?.toUtcLocalDateTime(), - beginTo = query.beginTo?.toUtcLocalDateTime(), - endFrom = query.endFrom?.toUtcLocalDateTime(), - endTo = query.endTo?.toUtcLocalDateTime(), + beginFrom = query.beginFrom, + beginTo = query.beginTo, + endFrom = query.endFrom, + endTo = query.endTo, pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()), ) @@ -223,16 +222,16 @@ class RequestService( surveyType = surveyType.toDto(), geometry = geometry, importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, optics = optics, rsa = rsa, coverage = coverage(), - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -252,14 +251,14 @@ class RequestService( status = status.toDto(), surveyType = surveyType.toDto(), importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, coveragePercent = coverage().currentPercent, - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -271,14 +270,14 @@ class RequestService( surveyType = surveyType.toDto(), geometry = geometry, importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, coveragePercent = coverage().currentPercent, - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -361,10 +360,6 @@ class RequestService( return Sort.by(direction, property) } - private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - - private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC) - private fun Throwable.isDuplicateRequestId(): Boolean { return generateSequence(this) { throwable -> throwable.cause } .filterIsInstance() diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt index 71ef3cd..9a094e1 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt @@ -21,7 +21,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.Optional class GridManagementControllerTest { @@ -97,7 +97,7 @@ class GridManagementControllerTest { GridRebuildResponseDto( cellsCount = 2, rebuiltRequestProjectionsCount = 2, - rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"), + rebuiltAt = LocalDateTime.of(2026, 5, 20, 10, 15, 30), ) ).`when`(gridRebuildService).rebuildGrid() @@ -105,7 +105,7 @@ class GridManagementControllerTest { .andExpect(status().isOk) .andExpect(jsonPath("$.cellsCount").value(2)) .andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2)) - .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z")) + .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30")) } @Test diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt index fcec078..3996a45 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -25,8 +25,8 @@ class RequestApiDtoContractTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", "kpp": [1, 2, 3], "highPriorityTransmit": false, "optics": { @@ -43,7 +43,7 @@ class RequestApiDtoContractTest { assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id) assertEquals("Тест1", request.name) assertEquals(10.0, request.importance) - assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime) + assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime) assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType) } @@ -55,8 +55,8 @@ class RequestApiDtoContractTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", "surveyType": "OPTICS", "optics": { "resultType": "PANCHROMATIC", @@ -78,8 +78,8 @@ class RequestApiDtoContractTest { name = "Тест1", geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", importance = 10.0, - beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), - endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), + endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, @@ -87,40 +87,7 @@ class RequestApiDtoContractTest { ), ) - assertFalse(json.contains("surveyType")) - assertFalse(json.contains("app_type")) - assertFalse(json.contains("appType")) - assertFalse(json.contains("AppType")) - assertFalse(json.contains("requestType")) - } - - @Test - fun `cells list response dto matches openapi fields`() { - val json = objectMapper.writeValueAsString( - CellsListResponseDto( - items = listOf( - CellSummaryResponseDto( - cellNum = 42, - latitude = 55.0, - longitude = 37.0, - importance = 10.0, - contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", - ) - ), - page = 0, - size = 50, - totalItems = 1, - totalPages = 1, - ) - ) - - assertEquals( - setOf("items", "page", "size", "totalItems", "totalPages"), - objectMapper.readTree(json).fieldNames().asSequence().toSet(), - ) - assertEquals( - setOf("cellNum", "latitude", "longitude", "importance", "contour"), - objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(), - ) + val node = objectMapper.readTree(json) + assertFalse(node.has("surveyType"), "surveyType should not be serialized") } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt index 86bb075..e65ddb2 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt @@ -3,18 +3,14 @@ package org.nstart.dep265.requestservice.dto import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** - * Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime` - * сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф - * (смену типа поля на naive `LocalDateTime` или Jackson-конфига). - * - * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + * Контракт формата времени на проводе для [RequestResponseDto]: naive `LocalDateTime` → ISO без зоны + * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам. */ class RequestResponseDtoContractTest { @@ -22,7 +18,7 @@ class RequestResponseDtoContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes request times as ISO with explicit Z zone`() { + fun `serializes request times as naive ISO without zone`() { val dto = RequestResponseDto( id = UUID.fromString("00000000-0000-0000-0000-000000000001"), name = "request", @@ -30,18 +26,18 @@ class RequestResponseDtoContractTest { surveyType = SurveyTypeDto.OPTICS, geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", importance = 1.0, - beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), - endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + beginDateTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endDateTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), highPriorityTransmit = false, coverage = CoverageStateDto(currentPercent = 0.0), - createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC), - updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC) + createdAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0), + updatedAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0) ) val node = mapper.readTree(mapper.writeValueAsString(dto)) - assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText()) - assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText()) - assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z") + assertEquals("2026-05-29T01:00:00", node.get("beginDateTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endDateTime").asText()) + assertFalse(node.get("createdAt").asText().endsWith("Z"), "не ожидалась зона Z") } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt index 50d8f8b..5603f62 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt @@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.UUID import kotlin.test.assertEquals @@ -137,8 +136,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor( val response = service.listRequests( RequestListFilter( - beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), - beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0), + beginTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59), ) ) @@ -153,8 +152,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor( val response = service.listRequests( RequestListFilter( - endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), - endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + endFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0), + endTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59), ) ) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt index d6e071e..3f02619 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt @@ -30,7 +30,6 @@ import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.Optional import java.util.UUID import kotlin.test.assertEquals @@ -286,8 +285,8 @@ class RequestServiceTest { name = REQUEST_NAME, geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", importance = REQUEST_IMPORTANCE, - beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), - endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), + endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index b9b5637..e897cae 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -86,8 +86,8 @@ export function Tgu2DMapTab({ try { const result = await fetchRequestsForMap( { - endFrom: new Date(fromMs).toISOString(), - beginTo: new Date(toMs).toISOString() + endFrom: new Date(fromMs).toISOString().slice(0, 19), + beginTo: new Date(toMs).toISOString().slice(0, 19) }, (loaded, total) => { setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx index c111700..5372fc3 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -82,7 +82,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); try { const result = await fetchRequestsForMap( - { endFrom: new Date(fromMs).toISOString(), beginTo: new Date(toMs).toISOString() }, + { endFrom: new Date(fromMs).toISOString().slice(0, 19), beginTo: new Date(toMs).toISOString().slice(0, 19) }, (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })) ); setRequestsState((current) => ({ diff --git a/services/pcp-tgu-ops-ui/src/utils/utcTime.ts b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts index 117c03e..351f904 100644 --- a/services/pcp-tgu-ops-ui/src/utils/utcTime.ts +++ b/services/pcp-tgu-ops-ui/src/utils/utcTime.ts @@ -42,10 +42,9 @@ export function toUtcInputValue(ms: number): string { } /** - * Naive-строку (из `datetime-local`) трактует как UTC и возвращает ISO с `Z`. - * Для бэкендов с `OffsetDateTime` на границе API (напр. pcp-request-service): - * пользователь вводит время по UTC, отправляем его же как UTC-смещение. + * Naive-строку (из `datetime-local`) возвращает как naive ISO без зоны (`YYYY-MM-DDTHH:mm:ss`). + * Все бэкенды ожидают `LocalDateTime` без зоны. */ export function toUtcIso(value: string): string { - return new Date(parseUtc(value)).toISOString(); + return new Date(parseUtc(value)).toISOString().slice(0, 19); } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt index 0c04f21..ecc1537 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/CamundaJacksonConfig.kt @@ -10,7 +10,6 @@ import io.camunda.client.api.JsonMapper as CamundaJsonMapper import io.camunda.client.impl.CamundaObjectMapper import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule @Configuration class CamundaJacksonConfig { @@ -25,10 +24,6 @@ class CamundaJacksonConfig { return JsonMapper.builder() .addModule(KotlinModule.Builder().build()) .addModule(JavaTimeModule()) - // Слой толерантности времени (читать naive=UTC и Z) — см. docs/standards/DATETIME_UTC.md. - // Модуль из pcp-types-lib; покрывает Jackson-2 путь (этот ручной маппер для Camunda). - // REST-границу (Jackson 3) покрывает PcpTimeModuleV3 (см. RestJacksonConfig). - .addModule(PcpTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .build() } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt deleted file mode 100644 index bed6d43..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3.kt +++ /dev/null @@ -1,94 +0,0 @@ -package space.nstart.pcp_tgu_service.config - -import tools.jackson.core.JsonParser -import tools.jackson.core.JsonToken -import tools.jackson.databind.DeserializationContext -import tools.jackson.databind.ValueDeserializer -import tools.jackson.databind.ext.javatime.deser.InstantDeserializer -import tools.jackson.databind.ext.javatime.deser.LocalDateTimeDeserializer -import tools.jackson.databind.module.SimpleModule -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset -import java.time.format.DateTimeParseException - -/** - * Слой толерантности времени для **Jackson 3** (`tools.jackson.*`). - * - * Близнец библиотечного `PcpTimeModule` (Jackson 2): то же поведение — читать время в обоих - * форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC; трогает только десериализацию. - * Нужен отдельно, потому что Boot 4 авто-конфигурирует Jackson 3 `JsonMapper` для REST HTTP message - * converters (MVC), а он не видит Jackson-2 модули. Регистрируется как бин - * `tools.jackson.databind.JacksonModule` ([RestJacksonConfig]) — `JacksonAutoConfiguration` собирает - * такие бины в общий `JsonMapper`. - * - * Канонический источник поведения — `space.nstart.pcp.pcp_types_lib.utils.json.PcpTimeModule`; - * правки синхронизировать. См. docs/standards/DATETIME_UTC.md. - */ -class PcpTimeModuleV3 : SimpleModule(NAME) { - - init { - addDeserializer(LocalDateTime::class.java, TolerantLocalDateTimeDeserializerV3()) - addDeserializer(OffsetDateTime::class.java, TolerantOffsetDateTimeDeserializerV3()) - addDeserializer(Instant::class.java, TolerantInstantDeserializerV3()) - } - - companion object { - const val NAME = "PcpTimeModuleV3" - } -} - -private class TolerantLocalDateTimeDeserializerV3 : ValueDeserializer() { - - private val default: ValueDeserializer = LocalDateTimeDeserializer.INSTANCE - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.string.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - } catch (_: DateTimeParseException) { - // naive-строка без зоны — трактуем как UTC - } - } - return default.deserialize(p, ctxt) - } -} - -private class TolerantOffsetDateTimeDeserializerV3 : ValueDeserializer() { - - private val default: ValueDeserializer = InstantDeserializer.OFFSET_DATE_TIME - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): OffsetDateTime? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.string.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).withOffsetSameInstant(ZoneOffset.UTC) - } catch (_: DateTimeParseException) { - return LocalDateTime.parse(text).atOffset(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} - -private class TolerantInstantDeserializerV3 : ValueDeserializer() { - - private val default: ValueDeserializer = InstantDeserializer.INSTANT - - override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Instant? { - if (p.currentToken() == JsonToken.VALUE_STRING) { - val text = p.string.trim() - if (text.isEmpty()) return null - try { - return OffsetDateTime.parse(text).toInstant() - } catch (_: DateTimeParseException) { - return LocalDateTime.parse(text).toInstant(ZoneOffset.UTC) - } - } - return default.deserialize(p, ctxt) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt index 4dcb2e9..2bb3eaa 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt @@ -1,21 +1,4 @@ package space.nstart.pcp_tgu_service.config -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import tools.jackson.databind.JacksonModule - -/** - * Регистрирует слой толерантности времени ([PcpTimeModuleV3]) в авто-конфигурируемом Jackson 3 - * `JsonMapper`, который Boot 4 использует для REST HTTP message converters (MVC). - * - * `JacksonAutoConfiguration` собирает все бины `tools.jackson.databind.JacksonModule` из контекста - * и добавляет их в общий `JsonMapper` (`builder.addModules(...)`), поэтому достаточно объявить бин - * этого типа. Входящий REST-JSON читается толерантно к обоим форматам времени (naive=UTC и `Z`), - * как и Jackson-2 путь Camunda ([CamundaJacksonConfig]). См. docs/standards/DATETIME_UTC.md. - */ -@Configuration -class RestJacksonConfig { - - @Bean - fun pcpTimeModuleV3(): JacksonModule = PcpTimeModuleV3() -} +// Пустой — Jackson 3 регистрирует JavaTimeModule/KotlinModule автоматически через Boot-автоконфиг. +// LocalDateTime сериализуется как ISO-строка без зоны при disable(WRITE_DATES_AS_TIMESTAMPS). diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt index f313778..2fdbdcd 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt @@ -2,24 +2,19 @@ package space.nstart.pcp_tgu_service.dto import space.nstart.pcp_tgu_service.domain.CalculatedPlan import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID -/** - * «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — UTC ISO с `Z` - * (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC). - */ +/** «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — naive ISO без зоны. */ data class ZrvWindowResponse( - val startTime: OffsetDateTime, - val endTime: OffsetDateTime, + val startTime: LocalDateTime, + val endTime: LocalDateTime, val kppId: String ) { companion object { fun from(window: CalculatedPlan): ZrvWindowResponse = ZrvWindowResponse( - startTime = window.startTime.atOffset(ZoneOffset.UTC), - endTime = window.endTime.atOffset(ZoneOffset.UTC), + startTime = window.startTime, + endTime = window.endTime, kppId = window.kppId ) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index 6d746f0..71b21a1 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -4,17 +4,14 @@ import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.nio.charset.StandardCharsets -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** * DTO ответа REST API для плана. * - * Времена ([startTime]/[endTime]) — `OffsetDateTime` в UTC и сериализуются как ISO-строка с явной - * зоной `Z` (напр. `"2026-05-29T01:00:00Z"`), см. `docs/standards/DATETIME_UTC.md`. Внутреннее - * хранение и домен (`PlannedPlanEntity`/`CalculatedPlan`) остаются naive `LocalDateTime` (= UTC); - * конвертация в `Z` происходит только на границе API. + * Времена ([startTime]/[endTime]) — naive `LocalDateTime`, сериализуются как ISO-строка без зоны + * (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах. */ data class PlanResponse( @@ -24,11 +21,11 @@ data class PlanResponse( /** Идентификатор космического аппарата */ val spacecraftId: String, - /** Время начала плана (UTC, ISO с `Z`). */ - val startTime: OffsetDateTime, + /** Время начала плана (ISO без зоны). */ + val startTime: LocalDateTime, - /** Время окончания плана (UTC, ISO с `Z`). */ - val endTime: OffsetDateTime, + /** Время окончания плана (ISO без зоны). */ + val endTime: LocalDateTime, /** Идентификатор пункта закладки */ val kppId: String, @@ -46,9 +43,8 @@ data class PlanResponse( PlanResponse( planId = plan.planId, spacecraftId = plan.spacecraftId, - // naive LocalDateTime в БД = UTC → отдаём с явной зоной Z. - startTime = plan.startTime.atOffset(ZoneOffset.UTC), - endTime = plan.endTime.atOffset(ZoneOffset.UTC), + startTime = plan.startTime, + endTime = plan.endTime, kppId = plan.kppId, status = plan.status ) @@ -63,9 +59,8 @@ data class PlanResponse( PlanResponse( planId = deterministicPlanId(plan), spacecraftId = plan.spacecraftId, - // naive LocalDateTime расчёта = UTC → отдаём с явной зоной Z. - startTime = plan.startTime.atOffset(ZoneOffset.UTC), - endTime = plan.endTime.atOffset(ZoneOffset.UTC), + startTime = plan.startTime, + endTime = plan.endTime, kppId = plan.kppId, status = PlannedPlanStatus.PLANNED ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt deleted file mode 100644 index 4679474..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/config/PcpTimeModuleV3Test.kt +++ /dev/null @@ -1,55 +0,0 @@ -package space.nstart.pcp_tgu_service.config - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tools.jackson.databind.json.JsonMapper -import java.time.Instant -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset - -/** - * Слой толерантности времени на Jackson-3 пути (REST-кодеки WebFlux): [PcpTimeModuleV3] читает - * время в обоих форматах на проводе (naive=UTC и `Z`/офсет), нормализуя к UTC. Близнец - * библиотечного PcpTimeModuleTest на Jackson 2. См. docs/standards/DATETIME_UTC.md. - */ -class PcpTimeModuleV3Test { - - private val mapper = JsonMapper.builder().addModule(PcpTimeModuleV3()).build() - - @Test - fun `LocalDateTime reads naive and Z as the same UTC wall-clock`() { - val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", LocalDateTime::class.java) - val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", LocalDateTime::class.java) - - assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromNaive) - assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0, 0), fromZ) - } - - @Test - fun `LocalDateTime normalizes an explicit offset to UTC`() { - // 01:00+03:00 == 28-го 22:00 UTC - val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", LocalDateTime::class.java) - - assertEquals(LocalDateTime.of(2026, 5, 28, 22, 0, 0), fromOffset) - } - - @Test - fun `OffsetDateTime reads naive as UTC and normalizes offset to UTC`() { - val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", OffsetDateTime::class.java) - val fromOffset = mapper.readValue("\"2026-05-29T01:00:00+03:00\"", OffsetDateTime::class.java) - - assertEquals(OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), fromNaive) - assertEquals(OffsetDateTime.of(2026, 5, 28, 22, 0, 0, 0, ZoneOffset.UTC), fromOffset) - } - - @Test - fun `Instant reads naive(=UTC) and Z to the same moment`() { - val fromNaive = mapper.readValue("\"2026-05-29T01:00:00\"", Instant::class.java) - val fromZ = mapper.readValue("\"2026-05-29T01:00:00Z\"", Instant::class.java) - - val expected = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC).toInstant() - assertEquals(expected, fromNaive) - assertEquals(expected, fromZ) - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt index cf2a44b..18e0dd8 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -3,19 +3,15 @@ package space.nstart.pcp_tgu_service.dto import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** - * Контракт формата времени на проводе для [PlanResponse]: `OffsetDateTime` в UTC → ISO с явной зоной `Z` - * (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф контракта — смену типа поля - * (назад на naive `LocalDateTime`) или Jackson-конфига, которые поломали бы потребителей. - * - * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + * Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` → ISO без зоны + * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам. */ class PlanResponseContractTest { @@ -23,20 +19,20 @@ class PlanResponseContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes plan times as ISO with explicit Z zone`() { + fun `serializes plan times as naive ISO without zone`() { val response = PlanResponse( planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), spacecraftId = "25544", - startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), - endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), kppId = "KPP-1", status = PlannedPlanStatus.PLANNED ) val node = mapper.readTree(mapper.writeValueAsString(response)) - assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) - assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) - assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z") } } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index 9adb4e7..345f0be 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -39,10 +39,9 @@ class PlanQueryServiceTest { val result = service.getAllPlans(historyDays = 7) assertEquals(2, result.size) - // PlanResponse.startTime — OffsetDateTime(UTC); доменные времена naive LocalDateTime (= UTC). - val persistedResponse = result.first { it.startTime == persisted.startTime.atOffset(ZoneOffset.UTC) } + val persistedResponse = result.first { it.startTime == persisted.startTime } assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) - val projectedResponse = result.first { it.startTime == freshProjected.startTime.atOffset(ZoneOffset.UTC) } + val projectedResponse = result.first { it.startTime == freshProjected.startTime } assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status) // Отсортировано по startTime. assertTrue(result[0].startTime <= result[1].startTime) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index a0be157..ee32dcf 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -2,13 +2,11 @@ package space.nstart.pcp.slots_service.dto.tgu import java.time.Instant import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.UUID /* - * Времена в этом файле — UTC (см. docs/standards/DATETIME_UTC.md). Времена плана ([TguPlanResponse]) — - * `OffsetDateTime` с явной зоной `Z` (как отдаёт pcp-tgu-service); `Instant` — тоже с `Z`. `LocalDateTime` - * (напр. [TguPlanDecisionMessage.decisionTime]) — naive ISO без зоны (= UTC). Фронт парсит через utils/utcTime. + * Времена плана ([TguPlanResponse]) — naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service). + * `Instant` ([TguPlatformResponse.validFrom/validTo]) — с `Z` (как отдаёт НСИ-прокси). */ data class TguPlatformResponse( @@ -25,8 +23,8 @@ data class TguPlatformResponse( data class TguPlanResponse( val planId: UUID, val spacecraftId: String, - val startTime: OffsetDateTime, - val endTime: OffsetDateTime, + val startTime: LocalDateTime, + val endTime: LocalDateTime, val kppId: String, val status: String ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index 13d99ca..d219125 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -45,8 +45,6 @@ import space.nstart.pcp.slots_service.service.SlotService import space.nstart.pcp.slots_service.service.StationService import space.nstart.pcp.slots_service.service.TguPlanningService import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -212,8 +210,8 @@ class CatalogControllerTest { TguPlanResponse( planId = planId, spacecraftId = "56756", - startTime = OffsetDateTime.of(2026, 5, 29, 10, 0, 0, 0, ZoneOffset.UTC), - endTime = OffsetDateTime.of(2026, 5, 29, 10, 15, 0, 0, ZoneOffset.UTC), + startTime = LocalDateTime.of(2026, 5, 29, 10, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 10, 15, 0), kppId = "KPP-1", status = "WAITING_DECISION" ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt index 63225ec..d166025 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -3,20 +3,17 @@ package space.nstart.pcp.slots_service.dto.tgu import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.time.Instant -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime import java.util.UUID /** * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: - * времена плана ([TguPlanResponse]) — `OffsetDateTime` с `Z` (как отдаёт pcp-tgu-service), - * `Instant` ([TguPlatformResponse.validFrom]) — тоже с `Z` (см. docs/standards/DATETIME_UTC.md). - * Тест ловит молчаливый дрейф контракта. - * - * Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS). + * времена плана ([TguPlanResponse]) — naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service), + * `Instant` ([TguPlatformResponse.validFrom]) — с `Z` (как отдаёт НСИ-прокси). */ class TguPlanningDtoContractTest { @@ -24,21 +21,21 @@ class TguPlanningDtoContractTest { .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) @Test - fun `serializes plan times as ISO with explicit Z zone`() { + fun `serializes plan times as naive ISO without zone`() { val plan = TguPlanResponse( planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), spacecraftId = "25544", - startTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC), - endTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC), + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), kppId = "KPP-1", status = "PLANNED" ) val node = mapper.readTree(mapper.writeValueAsString(plan)) - assertEquals("2026-05-29T01:00:00Z", node.get("startTime").asText()) - assertEquals("2026-05-29T02:00:00Z", node.get("endTime").asText()) - assertTrue(node.get("startTime").asText().endsWith("Z"), "ожидалась явная зона Z") + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z") } @Test From 5049395911981fc9b9a7cff6cb3b407d9645c29d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 00:04:47 +0300 Subject: [PATCH 47/79] =?UTF-8?q?pcp-tgu:=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0?= =?UTF-8?q?=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20REJECTED=20+=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=20=C2=AB=D0=A7=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=C2=BB=20+=20UI=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D0=BA/=D0=97=D0=A0=D0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд (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. --- ...TASK_tgu-plan-versioning-manual-reissue.md | 322 ------------------ services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts | 21 ++ .../src/features/tgu-editor/TguEditorTab.tsx | 102 +++++- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 50 ++- .../features/tgu-map-2d/canvas/drawTracks.ts | 14 +- .../src/features/tgu-map-2d/model/mapTypes.ts | 6 + .../tgu-planning/CreateChainPlanDialog.tsx | 5 +- .../features/tgu-planning/TguPlanDetails.tsx | 57 +++- .../features/tgu-planning/TguPlanningPage.tsx | 175 ++++++++-- .../src/features/tgu-planning/TguTimeline.tsx | 129 ++++--- .../src/features/tgu-planning/TguZrvPanel.tsx | 74 ++++ .../src/features/tgu-planning/tguStatus.ts | 3 +- .../tgu-planning/tguTimelineLayout.test.ts | 59 +++- .../tgu-planning/tguTimelineLayout.ts | 105 ++++-- .../tgu-planning/tguTimelineMapper.test.ts | 53 ++- .../tgu-planning/tguTimelineMapper.ts | 41 +++ .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 17 +- .../src/styles/tgu-planning.css | 66 +++- .../controller/PlanController.kt | 9 + .../entity/SpacecraftPlanningStateEntity.kt | 7 + .../repository/PlannedPlanRepository.kt | 11 + .../service/CreateChainPlanUseCase.kt | 15 + .../service/HandlePlanDecisionUseCase.kt | 21 +- .../service/IssueDuePlanWorker.kt | 7 + .../service/PlanProjectionService.kt | 9 +- .../service/PlanQueryService.kt | 17 + .../service/ZrvWindowMatcher.kt | 18 +- ...13__planning_state_paused_after_reject.sql | 6 + .../service/CreateChainPlanUseCaseTest.kt | 18 + .../service/IssueDuePlanWorkerOnDemandTest.kt | 16 + .../service/PlanIssueAndDecisionFlowTest.kt | 7 +- .../service/PlanProjectionServiceTest.kt | 15 + .../service/PlanQueryServiceTest.kt | 32 ++ .../service/ZrvWindowMatcherTest.kt | 13 + 34 files changed, 1062 insertions(+), 458 deletions(-) delete mode 100644 docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx create mode 100644 services/pcp-tgu-service/src/main/resources/db/migration/V13__planning_state_paused_after_reject.sql diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md deleted file mode 100644 index 600365d..0000000 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ /dev/null @@ -1,322 +0,0 @@ -# Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ - -> **Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 9).** -> Источник — разбор кода `pcp-tgu-service` и `pcp-mission-planing-service` (см. раздел 2). -> Цель документа — зафиксировать модель, API, статусы, миграции и риски, чтобы дальше резать на фазы. - ---- - -## 1. Контекст и цель - -Оператор в `pcp-tgu-ops-ui` (редактор включений, `src/features/tgu-editor/`) правит съёмку и сброс -актуального плана: строит новую съёмку, корректирует или удаляет существующую, формирует сброс. -План — **живой**: он выдаётся на борт КА и исполняется. Поэтому: - -- **Нельзя** писать правки сразу в действующий план (сейчас `saveSurveyRoute`/`saveDropRoute` именно так и делают — мгновенный персист в живую миссию). -- Нужна **версия-черновик**: правки идут в неё, на каждое изменение считаются конфликты/наложения, оператор видит результат, и только по явному действию **«Применить» отправляет план вручную**. -- Нужна **возможность отката**: прежняя версия сохраняется. -- **Гард с первой кладки:** перевыдать план можно, только если он **строго в будущем и ещё не заложен**. - -Сценарий оператора: *план поправили → всё проверили → отправили вручную*. - ---- - -## 2. Что уже есть в бэке (находки разбора) - -Версионирование/цепочка и ручная выдача **существуют наполовину** — строим поверх, не дублируем. - -### Ось 1 — расписание/выдача: `pcp-tgu-service`, таблица `planned_plan` -- `PlannedPlanEntity` уже несёт: `base_plan_id`, `chain_version`, `issue_not_before`, `accepted_at`, `status`. -- `PlannedPlanStatus`: `PLANNED`(виртуальный, не персистится) → `ISSUING` → `WAITING_DECISION` → `ACCEPTED` → `AWAITING_LAYIN` → `LAID_IN`; терминалы `REJECTED/LAYIN_FAILED/NOT_ACCEPTED/START_AMBIGUOUS`. -- Цепочка на КА — `SpacecraftPlanningStateEntity` (`currentBasePlanId`, `chainVersion`, `activePlanId`, `urgentReissueFrom`). -- **Ручная выдача уже есть:** `IssueDuePlanWorker.issuePlanNow(planId)` → `prepareSpecificPlan` → BPMN. Наружу — `POST /test/tgu/plans/{planId}/issue` (за флагом `tgu.test-controller.enabled`, по умолчанию выключен). -- Гард перевыдачи: `REISSUABLE_STATUSES = {PLANNED, REJECTED, LAYIN_FAILED, NOT_ACCEPTED, START_AMBIGUOUS}`. -- Супершединг на приём: `HandlePlanDecisionUseCase.acceptPlan` → `status=AWAITING_LAYIN`, `currentBasePlanId=plan`, `chainVersion++`; старые планы цепочки **остаются** (это и есть история/откат). -- Выдача склеивает слот и контент по одному id: `BpmnPlanIssueProcessStarter` стартует `createSatelliteMission` с `satellitePlanId = planId`; BPMN тянет контент миссии по этому id. -- Проекция (`PlanProjectionService`) считает будущее из снапшота ЗРВ; `CalculatedPlan` = только слот `{spacecraftId, startTime, endTime, kppId}`, **без контента**. - -### Ось 2 — содержимое: `pcp-mission-planing-service` -- Модель: `Mission(status="NEW") → Plan(plan_number, stage4_snapshot_id) → Mode(SURVEY|DROP)`. -- `SurveyModeEntity`: `status: SurveyModeStatus`, `source: SurveyModeSource` (`MANUAL` для ручных), `contourWkt`, `roll`, `lat/long`, `duration`. -- API уже есть: `survey-route/build` (превью), `{planId}/survey-route` и `{planId}/drop-route` (**персист в живую миссию сразу**, авто-создание миссии/плана при отсутствии), `{id}/modes`, `confirm`, `update-status`, `delete mission`. -- **Нет**: операции клонирования миссии, update/delete отдельного режима, расчёта конфликтов/наложений (`mode_booking` — это бронь слота, не конфликт). - -**Связь осей: `planId` (tgu) == `missionId` (mission-planing).** - ---- - -## 3. Модель версионирования: «клон миссии = версия» - -Выбрано (согласовано): **одна правка-сессия = новая версия с новым id**, клонирующая контент. -Поскольку `planId == missionId`, новая версия = новый `planId` = новый `missionId`. Это совмещает обе оси: -один новый id и в расписании (`planned_plan`), и в контенте (`missions/plans/modes`). - -``` -Жизненный цикл версии: - -P0 (актуальная, в будущем, не заложена) - │ оператор начинает правку → CLONE - ▼ -P1 = DRAFT (planned_plan: status=DRAFT, base_plan_id=P0, chain_version=след.) - mission M1 = клон M0 (все modes скопированы) - │ правки идут только в M1; на каждую — VALIDATE (конфликты) - │ «Применить» (ручная отправка) → ISSUE(P1) [гард: P0 строго в будущем и не заложен] - ▼ -P1: ISSUING → WAITING_DECISION → (внешний ACCEPT) → AWAITING_LAYIN - supersede: currentBasePlanId=P1, chain_version++ - P0 остаётся в БД как история → откат = клонировать P0 в новый DRAFT и применить -``` - -Ключевые свойства: -- **Живой план не трогается** до «Применить»: правки только в `DRAFT`-клоне. -- **Откат**: прежние версии — обычные записи цепочки (`base_plan_id`), восстановление = новый клон из старой версии → ревалидация против «сейчас» → отправка. Не реактивация устаревшего снапшота. -- **Оптимистичная конкуренция**: на «Применить» проверяем, что база `P0` всё ещё актуальна (не уехала вперёд, не заложена); иначе — отказ «план изменился, посмотрите дифф». - -### 3a. Два режима порождения версии - -Клон-черновик создаётся одним из двух способов в зависимости от состояния базы: - -**A. Перевыдача (replace)** — база **строго в будущем и не заложена**. -Новая версия занимает **тот же слот** и супершедит базу (раздел 3, основной флоу). Окно/КПП копируются из базы. - -**B. Форк вперёд (fork-forward)** — база **уже заложена (`LAID_IN`)**. -Тот же план перевыдать нельзя (он на борту). Но можно **взять его за основу нового плана на следующую закладку** и от него повести новую цепочку: -- Создаётся новый план (`base_plan_id = заложенный`, новая цепочка), это **не** замена слота, а продолжение вперёд. -- **Окно нового плана задаёт оператор, но только выбором из доступных ЗРВ, строго в будущем** (не произвольный ввод). Так окно гарантированно совпадает с реальным выдаваемым слотом (1:1 с ЗРВ), и проблема «окно ↔ слот» снимается на входе. -- Чтобы не заполнять с нуля, черновик **преднаполняется маршрутами заложенного плана, попавшими в окно нового плана**: переносятся те `Mode`(SURVEY/DROP), чей интервал `[timeStart, timeStart+duration)` пересекается с заданным окном `[start, stop)`. Дальше оператор правит/дополняет и отправляет вручную. - -``` -LAID_IN P0 ──(нельзя replace) - │ оператор: «создать план на след. закладку на основе P0», задаёт окно W - ▼ -P2 = DRAFT (base_plan_id=P0, окно=W) - mission M2: скопированы только modes из M0, пересёкшиеся с W - │ правки + VALIDATE → отправка вручную → ISSUE(P2) → ... → ACCEPT → новая база цепочки -``` - -Граница между A и B — точка невозврата из раздела 4: пока база правима в своём слоте — `replace`; как только заложена — только `fork-forward`. - ---- - -## 4. Статусы и точка невозврата - -### Новый статус -Добавить `PlannedPlanStatus.DRAFT` — персистится, но **не часть проекции/головы цепочки**: не выдаётся воркером, -не учитывается как «голова» в `PlanProjectionService`, помечается отдельно в `PlanQueryService` (видна как черновик, а не как запланированный слот). - -### Гард перевыдачи / точка невозврата - -**План становится невозвратным (заменить в своём слоте нельзя — доступен только `fork-forward`), когда выполнено любое из:** -- `now ≥ startTime − 10 мин` — порог заморозки перед стартом (в конфиг, напр. `layinFreezeMinutes = 10`); **или** -- `status = LAID_IN` — пришло подтверждение закладки. - -Соответственно **`replace`-перевыдача** базы `P0` разрешена, только если `now < startTime − 10 мин` **и** `status ≠ LAID_IN`. -Лок конкуренции по КА действует **только для чужого плана**: нельзя стартовать выдачу, пока по КА летит attempt **другого** плана (`state.activeAttemptId != null && state.activePlanId != planId`). Перевыдача **того же** плана разрешена даже поверх его собственной in-flight attempt (в т.ч. авто-выдачи за `notificationBeforeStartMinutes`) — новая attempt замещает летящую. Это и сохраняет право «перевыдать именно этот план» до порога заморозки, несмотря на то что авто-выдача стартует сильно раньше (`notificationBeforeStartMinutes ≫ layinFreezeMinutes`). - -Важно: статус `AWAITING_LAYIN` **сам по себе НЕ** делает план невозвратным — принятый план правим, пока до старта больше 10 минут и нет подтверждения закладки. (Прежняя неоднозначность снята.) - -**Невозвратный план — не тупик:** `replace` запрещён, но доступен **`fork-forward`** (раздел 3a) — создать новый план на следующую ЗРВ на основе текущего и повести новую цепочку. То есть невозвратность закрывает замену слота, но не закрывает развитие плана вперёд. - ---- - -## 5. API — что добавить - -### `pcp-mission-planing-service` (контент) -1. `POST /api/missions/{id}/clone` → `{ newMissionId }`. Клон миссии в новый `missionId` (= новый `planId`). Два варианта наполнения: - - **replace**: глубокий клон — все `Mode`(SURVEY/DROP) с контурами. - - **fork-forward**: клон **только тех `Mode`**, чей интервал `[timeStart, timeStart+duration)` пересекается с переданным окном `{ windowStart, windowStop }` (окно задаёт оператор). Маршруты вне окна не переносятся. -2. `PATCH /api/missions/survey-modes/{modeId}` — правка съёмки (перестроить маршрут: окно/крен → новый контур). Сейчас правки нет, есть только смена статуса. -3. `DELETE /api/missions/modes/{modeId}` — удалить включение из черновика. -4. `POST /api/missions/{id}/validate` → `{ conflicts: [...] }` — **новая логика** (раздел 6). -5. (опц.) `GET /api/missions/{id}/versions` или связка через tgu — список версий для отката. - -> Существующие `survey-route`/`drop-route` остаются, но **адресуются на DRAFT-миссию**, а не на живую. Авто-создание миссии на лету для DRAFT-сценария отключить/пересмотреть. - -### `pcp-tgu-service` (расписание/выдача) -1. `POST /api/plans/{planId}/draft` → создаёт `DRAFT`-план (`base_plan_id`), триггерит клон миссии, возвращает `{ draftPlanId }`. Параметр `mode`: - - `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку. - - `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ. -2. `POST /api/plans/{planId}/issue` — **боевой** эндпоинт ручной выдачи (поднять из `TestTguController` в `PlanController`), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную». -3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии). ✅ СДЕЛАНО — [DeleteDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt) (404 нет / 422 не DRAFT / 204 успех; удаление клон-миссии best-effort, строка удаляется первой) + [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) `DELETE /api/plans/{draftPlanId}`; 4 unit-теста в `DeleteDraftUseCaseTest`. Фронт: `abandonDraft` + кнопка «Отмена» в редакторе + авто-отмена незавершённого черновика при смене плана/уходе (best-effort, не оставляет осиротевший DRAFT). -4. Цепочка/откат: переиспользуем `base_plan_id`/`chain_version`; восстановление версии = `draft(from=Pk)` → `issue`. - ---- - -## 6. Валидация (конфликты) — «Проверить → бэкенд» - -> **РЕШЕНО (MVP): валидация — номинальная заглушка.** На первом этапе `POST /api/missions/{id}/validate` существует и возвращает корректный контракт (пустой/тривиальный список конфликтов), но **реальных правил не считает**. Главное — endpoint есть и UI на него завязан. Полноценные правила — отдельной задачей. - -**TODO (отдельная задача — реальные правила валидации):** -- [ ] **Пересечение по времени** между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс). -- [ ] **Видимость цели**: окно съёмки внутри окна видимости точки (баллистика). -- [ ] **Сеанс связи**: окно сброса внутри ЗРВ станции. -- [ ] **Порядок сброса**: сбрасываемые съёмки завершаются строго до начала зоны (уже есть в UI как фильтр кандидатов — закрепить на бэке). -- [ ] **Бортовые ограничения**: длительность/скважность, энергетика, память (обсудить). -- [ ] Возврат **диффа конфликтов** на затронутые режимы (для отзывчивости), а не всего плана. - -Сейчас проверка локальная (`editorConflicts.ts`); при переносе на бэк UI делает оптимистичное локальное превью контура сразу + дебаунс-синк с эндпоинтом за авторитетными конфликтами. - ---- - -## 7. Фронт — раскладка флоу (после бэка) - -- **Открытие плана** = read-only текущей версии. Версия/черновик не создаётся. -- **Первая правка** (`+ Съёмка` / `+ Сброс` / правка / удаление) → `POST /plans/{planId}/draft` → редактор переключается на DRAFT-миссию; дальнейшие правки адресуются в неё. -- **Каждая правка** → мутация черновика + `validate` → показ конфликтов. (Заменяет локальный `checkVisibility` и заглушку `saveEditedPlan`.) -- **Кнопки** наконец непротиворечивы: - - `+ Съёмка / + Сброс` — правка в черновик (не молчаливый персист в живой план). - - **Проверить** — полный пересчёт `validate` (или убрать, т.к. валидация на каждой правке). - - **Применить** — `POST /plans/{draftPlanId}/issue` (ручная отправка), кнопка погашена, если база не проходит гард (не в будущем / заложена). -- **Удалить съёмку** = `DELETE mode` в черновике. **Корректировать** = `PATCH survey-mode` (перестроение маршрута). -- **Откат** = выбрать версию из цепочки → «Восстановить» → новый черновик из неё → ревалидация → отправка. - -> Правый инфо-панель включения (виток, аппарат, крен, захват, центр + место под задания) — **уже сделан** в предыдущей итерации (`WorkInspector`), переиспользуется как есть. - ---- - -## 8. Миграции и конфиг - -- **tgu**: значение enum `DRAFT` (хранится строкой — миграция данных не нужна, но проверить CHECK-констрейнты/маппинг в `PlanQueryService` и проекции, чтобы DRAFT не попадал в голову цепочки). Возможна колонка-владелец черновика/`created_by` для GC. Снять флаг `tgu.test-controller` с боевого `issue` (вынести в `PlanController`). -- **mission-planing**: схема не меняется (клон = новые строки; validate/patch/delete — без новых таблиц). Проверить каскады при `DELETE mode` и при удалении DRAFT-миссии. -- **GC/TTL**: брошенные DRAFT (`planned_plan` + клон-миссия) чистить по TTL — иначе копятся. - ---- - -## 9. Открытые решения (нужно закрыть до кода) - -1. **GC-политика черновиков** — TTL и кто чистит (scheduled worker по аналогии с `*UpdateWorker`). -2. **Видимость DRAFT в общем read-пути** — показывать только автору сессии или всем как «черновик»; как метить в `PlanResponse`. - -**Решено (зафиксировано):** -- Версия = **клон миссии** (новый `planId==missionId`). -- Два режима: `replace` (база в будущем, не заложена → замена слота) и `fork-forward` (база невозвратна → новый план на следующую ЗРВ, новая цепочка). -- **Точка невозврата:** план невозвратен, когда `now ≥ startTime − 10 мин` ИЛИ `status = LAID_IN`. До этого (в т.ч. в `AWAITING_LAYIN`) план правим через `replace`. Порог 10 мин — в конфиг (`layinFreezeMinutes`). -- **Отмена прежней базы на борту — не нужна:** при супершединге достаточно того, что новый план перекрывает старый по слоту. Явного сигнала «отменить ранее заложенное» не вводим. -- **Валидация в MVP — номинальная заглушка** (endpoint есть, правил нет); реальные правила — отдельной задачей (TODO в разделе 6). -- При `fork-forward` окно нового плана оператор **выбирает из доступных будущих ЗРВ** (не произвольный ввод) — окно совпадает с реальным слотом, снимая проблему «окно ↔ слот ЗРВ». Преднаполнение — маршруты базы, **пересёкшиеся с окном нового плана** `[start, stop)`. - ---- - -## 10. Риски - -- **Супершединг уже выданного/заложенного** (решено: отмену на борту не вводим, опираемся на перекрытие слота). Остаточный риск: новый план должен надёжно перекрыть старый по слоту/времени — если перекрытие неполное, борт может частично исполнить старую базу. Контроль перекрытия слота при `replace` обязателен. -- **Порог заморозки (10 мин) vs lead-time выдачи** — *снято в Ф1*: `notificationBeforeStartMinutes=263` ≫ `layinFreezeMinutes=10`, поэтому в окне `[startTime − 263м, startTime − 10м)` план обычно уже в авто-выдаче (`ISSUING/WAITING_DECISION`). Лок активной attempt больше этому не мешает: перевыдача **того же** плана разрешена поверх его in-flight attempt (новая замещает старую). Остаточный момент: летят два BPMN-процесса по одному плану, решение по старой attempt запишется как stale (lifecycle не трогает) — для Ф1 приемлемо; явную отмену/cancel старой attempt можно добавить позже, если понадобится. -- **Две оси по одному id**: клон должен атомарно создавать и `planned_plan(DRAFT)`, и клон-миссию; частичный сбой → осиротевший id. Нужна согласованность (saga/компенсация или общий триггер через BPMN). -- **Дрейф снапшота ЗРВ** под DRAFT: окно базы могло сместиться, пока правили. На «Применить» — ревалидация окна против актуальной проекции. -- **Конкуренция операторов**: оптимистичный лок на базе обязателен, иначе тихий клобер. - ---- - -## 11. Фазы реализации - -> Каждая фаза — самостоятельный PR с тестами и критериями приёмки. Помета **«Модель»** — рекомендация, -> кому отдавать: `Sonnet` (механика по чёткому контракту), `Opus` (reasoning-heavy: гонки/согласованность), -> `Sonnet+ревью Opus` (можно Sonnet, но дифф смотрит Opus). Порядок — снизу вверх по зависимостям; -> фазы 2–4 можно вести параллельно после согласования контрактов. - -### Фаза 1 — Боевой endpoint ручной выдачи + гард точки невозврата — ✅ СДЕЛАНО -**Модель: Opus** (тонкое взаимодействие порога, lead-time и лока attempt). - -> **Реализовано** (commit на ветке `tgu-ops-ui`): -> - `POST /api/plans/{planId}/issue` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → `IssueDuePlanWorker.issuePlanNow`; дублирующий endpoint удалён из `TestTguController`. -> - Гард [`assertReissuableInSlot`](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): активная выдача **другого** плана КА → **409**, `LAID_IN` → **422**, `now ≥ startTime − layinFreezeMinutes` → **422**; нет плана → **404**. `REISSUABLE_STATUSES` удалён (гард по времени/статусу заместил список). -> - **Перевыдача того же плана поверх авто-выдачи разрешена:** если план уже ушёл в авто-выдачу за `notificationBeforeStartMinutes` (attempt в полёте по этому же плану), оператор всё равно может перевыдать его вручную вплоть до порога заморозки — новая attempt (`attemptNo++`) замещает летящую, активная attempt КА переключается на неё. Блокирует только активная выдача **другого** плана. Это реализует требование: «в автомате выдаём за 263 мин, но сохраняем возможность перевыдать именно этот план, пока до старта > 10 мин». -> - `layinFreezeMinutes` добавлен в `PlanningProperties` + `config-repo/pcp-tgu-service.yaml` + `application.yml` (дефолт 10). -> - Тесты: 6 граничных кейсов в `IssueDuePlanWorkerOnDemandTest` (вкл. перевыдачу того же плана поверх in-flight attempt и блокировку 409 на чужой план); `PlanIssueAndDecisionFlowTest` переписан под новую семантику (повтор того же плана замещает attempt, а не падает). Весь модуль `:services:pcp-tgu-service:test` зелёный. -- `pcp-tgu-service`: - - Вынести ручную выдачу из [TestTguController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt#L60) в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt): `POST /api/plans/{planId}/issue`. - - Гард в [IssueDuePlanWorker.prepareSpecificPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt#L135): разрешить только если `now < startTime − layinFreezeMinutes` **и** `status ≠ LAID_IN` **и** нет активной attempt по КА (`state.activeAttemptId == null`). Иначе `409/422` с понятной причиной. - - Пересмотреть `REISSUABLE_STATUSES` — гард по времени/статусу замещает узкий список статусов (правимы и `AWAITING_LAYIN`, и `ACCEPTED`, пока до старта > порога). - - `layinFreezeMinutes` в `PlanningProperties` + `config-repo/pcp-tgu-service.yaml` (дефолт 10). -- **Приёмка:** будущий не-`LAID_IN` план > порога → attempt создаётся, BPMN стартует; `now ≥ startTime−10мин` → отказ; `LAID_IN` → отказ; активная attempt по КА → отказ. -- **Тесты:** unit-таблица граничных случаев (время × статус) по образцу `IssueDuePlanWorkerOnDemandTest`. - -### Фаза 2 — Черновик-клон (replace + fork-forward) — ✅ СДЕЛАНО -**Модель: Sonnet+ревью Opus** (механика clone простая, но атомарность двух осей смотрит Opus). - -> **Реализовано:** -> - `POST /api/missions/{id}/clone` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt): replace — полный клон; fork-forward — только включения, перекрывающиеся с окном `(modeStop > windowStart && modeStart < windowStop)`. DropMode клонируется только если хотя бы одна из его съёмок вошла в клон (иначе пустой drop пропускается). Перекрытие: открытый правый конец `[start, stop)`. -> - `PlannedPlanStatus.DRAFT` в [PlannedPlanStatus](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt) — персистируется, не входит в проекцию и read-путь. -> - `POST /api/plans/{planId}/draft` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt): создаёт DRAFT-план с `planId = newMissionId` (инвариант planId==missionId сохраняется); при сбоге save — компенсация (delete клон-миссии). Для fork-forward валидирует, что окно есть в проекции КА. -> - DRAFT отфильтрован в [PlanQueryService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) (оба метода). -> - [MissionPlaningClient](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt) + `MissionPlaningProperties` + `mission-planing.base-url` в yaml. -> - Тесты: 6 кейсов в `MissionCloneTest` (граничные случаи предиката перекрытия); 1 кейс в `PlanQueryServiceTest` (DRAFT не виден). Оба модуля зелёные. -- `pcp-mission-planing-service`: `POST /api/missions/{id}/clone` body `{ mode, windowStart?, windowStop? }` → `{ newMissionId }`. `replace` — полный клон `Plan`+`Mode`; `fork-forward` — только `Mode`, пересёкшиеся с окном `[start,stop)`. Валидировать, что окно `fork-forward` = существующая будущая ЗРВ. -- `pcp-tgu-service`: `POST /api/plans/{planId}/draft` body `{ mode, windowStart?, windowStop? }` → создаёт `planned_plan(DRAFT, base_plan_id)`, триггерит clone миссии, возвращает `{ draftPlanId }`. **Атомарность:** оба создания координируются; при сбое clone — компенсация (не оставлять осиротевший `planned_plan`). -- `PlannedPlanStatus.DRAFT`; [PlanProjectionService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt) и `PlanQueryService` — `DRAFT` не голова цепочки и не в проекции. -- Миграция: проверить enum/CHECK-констрейнт статуса. -- **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша. -- **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе. - -### Фаза 3 — Правки контента в черновик — ✅ СДЕЛАНО -**Модель: Sonnet.** - -> **Реализовано:** -> - `PATCH /api/missions/survey-modes/{modeId}` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [updateSurveyRoute](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): перестраивает контур существующей съёмки тем же `buildContour`, что и `/survey-route/build`, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в `MANUAL` (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит `SurveyRouteBuildRequestDTO`. -> - `DELETE /api/missions/modes/{modeId}` → [deleteMode](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): диспатч SURVEY/DROP. Съёмка — снимаются брони слотов (`mode_booking`) и связи `drop_mode_survey`; сброс, оставшийся без единой съёмки, удаляется (осиротевший); другие съёмки того же сброса не трогаются. Сброс — удаляются только его связи и он сам, съёмки независимы. Нет режима → 404. -> - `saveSurveyRoute`/`saveDropRoute` больше **не создают живую миссию на лету** — адресуется только существующая миссия-черновик; нет миссии → 404. В `saveSurveyRoute` проверка миссии вынесена **до** дорогого `buildContour` (fail-fast). Swagger-описания и kdoc обновлены. -> - Репозиторий: `DropModeSurveyRepository.findAllBySurveyMode_Id` (поиск связей по съёмке). -> - Тесты: `MissionEditModesTest` (6) — каскады DELETE (осиротевший сброс удаляется; общий сброс с другой съёмкой остаётся; удаление сброса не трогает съёмки; 404 на неизвестный режим) + 404 на `saveSurveyRoute`/`saveDropRoute` без миссии. Весь модуль зелёный. -- `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`. -- Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария. -- **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы. -- **Тесты:** PATCH меняет контур; DELETE с каскадами. - -### Фаза 4 — Валидация-заглушка — ✅ СДЕЛАНО -**Модель: Sonnet.** - -> **Реализовано:** -> - `POST /api/missions/{id}/validate` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [validateMission](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): проверяет существование миссии (404, если нет) и возвращает корректный контракт с **пустым** списком конфликтов. Реальные правила — TODO раздела 6. -> - Контракт `MissionValidationResponse { missionId, conflicts: [ModeConflict] }`, где `ModeConflict { modeId, rule, severity, message, relatedModeId? }` ([MissionValidationDTOs.kt](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt)). Коды правил (`ConflictRule`: TIME_OVERLAP/TARGET_VISIBILITY/COMMS_SESSION/DROP_ORDER/ONBOARD_LIMIT) и уровни (`ConflictSeverity`: error/warning) заведены сразу как стабильная схема — появление реальных правил не сломает фронт. Дифф по режимам (а не весь план). -> - Тесты: 2 кейса в `MissionEditModesTest` (валидация существующей миссии → пустой список; неизвестной → 404). Весь модуль зелёный. -- `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6. -- **Приёмка:** `200` с пустым diff; контракт стабилен для фронта. - -### Фаза 5 — «Применить» = promote — ✅ СДЕЛАНО -**Модель: Opus** (сердце корректности живого плана). - -> **Реализовано:** -> - `POST /api/plans/{draftPlanId}/promote` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [IssueDuePlanWorker.promoteDraftNow](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуя `startPreparedAttempt`). -> - **Оптимистичный лок базы:** [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt) снимает `spacecraft_planning_state.chain_version` на момент создания черновика и хранит его в `planned_plan.chain_version` DRAFT-строки. На promote [assertDraftBaseUnchanged](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) требует совпадения с текущей версией цепочки; любой ACCEPT/grace-сдвиг базы её инкрементит → **409** «база уехала, посмотрите дифф». Прежние версии остаются в БД (откат = новый черновик из старой версии). -> - **Контроль слота/дрейфа ЗРВ** ([assertDraftSlotStillValid](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt)): окно черновика должно совпадать с реальной будущей ЗРВ — для fork-forward слот берётся из проекции, для replace учитывается окно заменяемого базового плана (проекция, заякоренная после плана в полёте, его не возвращает). Сместился снапшот → **422**. -> - **Гард точки невозврата** переиспользован (`assertReissuableInSlot`): заморожено / активная выдача другого плана → 422 / 409. Вход в цепочку штатный: на внешний ACCEPT существующий `acceptPlan` делает супершединг (`chainVersion++`); `basePlanId` черновика не трогаем (провенанс — что замещаем/продолжаем). -> - Не-DRAFT план → 422, неизвестный черновик → 404. -> - Тесты: 7 кейсов promote в `IssueDuePlanWorkerOnDemandTest` (успех; конкурентный сдвиг базы → 409; не-DRAFT → 422; неизвестный → 404; заморозка → 422; чужая активная выдача → 409; дрейф окна → 422) + 1 кейс снимка версии в `CreateDraftUseCaseTest`. Весь модуль зелёный. -- `pcp-tgu-service`: promote `draftPlanId` → выдача с **оптимистичным локом базы** (проверить, что `P0`/`currentBasePlanId` не изменились с момента создания черновика; иначе `409` «план изменился, посмотрите дифф»). Вход в цепочку: при внешнем `ACCEPT` существующий [acceptPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt#L142) делает супершединг (`chainVersion++`); для `fork-forward` `base_plan_id` уже выставлен. -- Контроль **полноты перекрытия слота** при `replace` (остаточный риск раздела 10). -- **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен). -- **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки. - -### Фаза 6 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote) -**Модель: Sonnet.** - -> **Реализовано (ядро):** -> - API-слой: [missionPlanningApi.ts](services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts) — `updateSurveyRoute` (PATCH), `deleteMode` (DELETE), `validateMission` (POST validate, тип `MissionValidation`/`ModeConflict`); новый [tguPlanApi.ts](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts) — `createDraft`/`promoteDraft`. Прямые вызовы через Vite-прокси: `/api/pcp-mission`→:7010, новый `/api/pcp-tgu`→:7011 (без BFF, как и было решено). -> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): ленивое создание DRAFT (`ensureDraft`, mode=replace) на первую сохранённую правку; `saveSurveyRoute`/`saveDropRoute`/`deleteMode` адресуются в `draftPlanId`, а не в живой план; после каждой серверной правки — перечитывание включений DRAFT + `validate`; смена выбранного плана сбрасывает черновик; дедуп включения (локальная правка ⊕ перечитанная plan-origin копия по `planModeId`). -> - Маппинг авторитетных конфликтов бэка на включения редактора: [editorValidation.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts) (по `planModeId`) + 3 теста. -> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением. -> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные. -> -> **fork-forward в UI — ✅ СДЕЛАНО:** -> - Селектор «Целевая ЗРВ» в [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Текущий слот (правка)» (replace, по умолчанию) ⊕ будущие ЗРВ КА. Список — из проекции pcp-tgu-service ([fetchSpacecraftPlans](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts), `GET /api/plans/{spacecraftId}`), отфильтрованной до PLANNED строго в будущем, кроме базы ([editorForkForward.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts) `futureZrvWindows` + 5 тестов). Это та же проекция, с которой бэк сверяет окно (`assertWindowMatchesProjection`). -> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): выбор окна переводит обзор редактора на него; `ensureDraft` шлёт `toCreateDraftRequest(targetWindow)` — fork-forward (naive-окно + kpp) при выбранной ЗРВ, иначе replace. Селектор блокируется после создания DRAFT (режим зафиксирован). Окна — naive-UTC (без зоны), как ждёт `LocalDateTime` бэка. -> - Приёмка: `tsc -b`, `vitest` (46) и `vite build` — зелёные. -> -> **Вынесено в отдельные подзадачи (не входит в ядро):** -> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote). -> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора. -- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`. -- [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): lazy-создание черновика на первую правку; адресация правок в `DRAFT`; на каждой правке — `validate`. -- [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = `validate` (или скрыть), «Применить» = `promote` (гасить по гарду точки невозврата). -- Выбор будущей ЗРВ для `fork-forward` (мини-панель/диалог); откат: список версий цепочки → «восстановить». -- **Приёмка:** ручной прогон сценария (правка → проверка → применить); кнопки соответствуют состоянию; `tsc`/`vitest` зелёные. - -### Сводка по моделям -| Фаза | Тема | Модель | -|---|---|---| -| 1 | Endpoint выдачи + гард | **Opus** | -| 2 | Черновик-клон | Sonnet + ревью Opus | -| 3 | Правки контента | Sonnet | -| 4 | Валидация-заглушка | Sonnet | -| 5 | Promote | **Opus** | -| 6 | Фронт | Sonnet | diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index 456e91c..bb73e52 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -109,6 +109,27 @@ export async function fetchZrvWindows(spacecraftId: string): Promise { + const url = spacecraftId + ? `${BASE}/api/plans/drafts?spacecraftId=${encodeURIComponent(spacecraftId)}` + : `${BASE}/api/plans/drafts`; + 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[]) : []; +} + /** * Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат * (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index cacc962..ebe5c2c 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -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([]); + // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению). + 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(() => { @@ -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 => { 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} />
@@ -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): 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 }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 89a07f3..2c58958 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -20,6 +20,11 @@ type Tgu2DMapViewProps = { onRequestsAtPoint?: (polygons: MapPolygon[]) => void; hoveredRequestPolygonId?: string; selectedRequestPolygonId?: string; + /** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */ + selectedObjectId?: string; + /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ + focusPoint?: GeoPoint; + focusToken?: number; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; @@ -68,6 +73,9 @@ export function Tgu2DMapView({ onRequestsAtPoint, hoveredRequestPolygonId, selectedRequestPolygonId, + selectedObjectId, + focusPoint, + focusToken, pickMode = false, onPickPoint, targets = [], @@ -139,6 +147,19 @@ export function Tgu2DMapView({ return () => resizeObserver.disconnect(); }, []); + // Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту + // на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken, + // поэтому повторный клик по тому же включению снова возвращает карту к объекту. + useEffect(() => { + if (!focusPoint || focusToken === undefined) return; + setView((current) => ({ + centerLon: focusPoint.lon, + centerLat: Math.max(-82, Math.min(82, focusPoint.lat)), + zoom: Math.max(current.zoom, 4.5) + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusToken]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -344,9 +365,14 @@ export function Tgu2DMapView({ const selectedPolygon = selectedPolygonRef.current; const selectedMarker = selectedMarkerRef.current; - const externalSelectedPolygon = useMemo( - () => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined, - [selectedRequestPolygonId, activePolygons] + const externalSelectedPolygon = useMemo(() => { + const id = selectedRequestPolygonId ?? selectedObjectId; + return id ? activePolygons.find((p) => p.id === id) : undefined; + }, [selectedRequestPolygonId, selectedObjectId, activePolygons]); + // Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons. + const externalSelectedLine = useMemo( + () => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined), + [selectedObjectId, scene.lines] ); const externalHoveredPolygon = useMemo( () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, @@ -396,6 +422,24 @@ export function Tgu2DMapView({ ); }); })} + {externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => { + const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize); + return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { + const visible = pts.some((p) => { + const x = p.x + dx; + return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64; + }); + if (!visible) return []; + return ( + `${p.x + dx},${p.y}`).join(" ")} + className="is-selected" + fill="none" + /> + ); + }); + })()} {overlayMarkers.map((marker) => { const pt = projection.project(marker.point); const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts index c8084f4..f428eaa 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts @@ -1,16 +1,23 @@ import type { MapProjection } from "../geometry/mapProjection"; import type { MapLine } from "../model/mapTypes"; +const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 }; +// Сброс — яркая толстая линия поверх трассы, на отрезке от начала до конца сеанса. +const DROP_STYLE = { stroke: "#ffcf3f", width: 5 }; + export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) { ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; - ctx.strokeStyle = "rgba(104, 195, 189, 0.76)"; - ctx.lineWidth = 1.2; const wrapThreshold = projection.worldSize / 2; - for (const line of lines) { + // Сбросы рисуем после трасс, чтобы их яркая линия легла поверх. + const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop")); + for (const line of ordered) { if (line.points.length < 2) continue; + const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE; + ctx.strokeStyle = style.stroke; + ctx.lineWidth = style.width; ctx.beginPath(); let prevX: number | undefined; for (const point of line.points) { @@ -27,4 +34,3 @@ export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], proj ctx.restore(); } - diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index 020bbec..b885ddd 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -32,6 +32,8 @@ export type MapPolygon = { spacecraftId?: string; requestId?: string; surveyType?: string; + /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ + modeId?: number; zIndex: number; }; @@ -42,6 +44,10 @@ export type MapLine = { spacecraftId?: string; passIndex?: number; zIndex: number; + /** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */ + kind?: "track" | "drop"; + /** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */ + modeId?: number; }; export type MapMarker = { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx index 81c7520..4b8e03b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx @@ -17,6 +17,8 @@ type CreateChainPlanDialogProps = { onClose: () => void; /** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */ onSent: () => void; + /** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */ + onCreated?: () => void; }; /** @@ -25,7 +27,7 @@ type CreateChainPlanDialogProps = { * показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки * («Отправить вручную» = promote) и последующей приёмки. */ -export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) { +export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, onCreated }: CreateChainPlanDialogProps) { const [zrvOptions, setZrvOptions] = useState([]); const [zrvLoading, setZrvLoading] = useState(true); const [zrvError, setZrvError] = useState(); @@ -78,6 +80,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: try { const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end); setResult(await createChainPlan(request)); + onCreated?.(); } catch (err) { setError( err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план." diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index ab0b4ed..caae391 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -13,14 +13,33 @@ const DECISION_GRACE_MINUTES = 30; type TguPlanDetailsProps = { plan?: TguPlanUi; + /** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */ + chainPaused?: boolean; decisionInFlight: boolean; notice?: string; error?: string; onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void; + /** Действия над черновиком (показываются только для status === "DRAFT"). */ + draftActionInFlight?: boolean; + onEditDraft?: (planId: string) => void; + onPromoteDraft?: (planId: string) => void; + onDeleteDraft?: (planId: string) => void; onClose: () => void; }; -export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) { +export function TguPlanDetails({ + plan, + chainPaused, + decisionInFlight, + notice, + error, + onDecision, + draftActionInFlight, + onEditDraft, + onPromoteDraft, + onDeleteDraft, + onClose +}: TguPlanDetailsProps) { if (!plan) { return ( ); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 75d0b68..4cf3435 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -7,6 +7,7 @@ import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; import { abandonDraft, + createDraft, fetchDraftPlans, fetchZrvWindows, promoteDraft, @@ -300,6 +301,33 @@ export function TguPlanningPage() { } }; + // Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения) и + // открыть его в редакторе. Это путь «сделать исправленный план», а не повторная отправка того же. + const handleCreateDraftFromPlan = async (planId: string) => { + // Один открытый черновик на базу: если он уже есть — открываем его, второй не плодим. + const existing = drafts.find((item) => item.basePlanId === planId); + if (existing) { + handleEditDraft(existing.planId); + return; + } + setDraftActionInFlight(true); + setDecisionError(undefined); + setDecisionNotice(undefined); + try { + const { draftPlanId } = await createDraft(planId, { mode: "replace" }); + await loadData(appliedRange); + const base = allPlans.find((item) => item.planId === planId); + setShowDrafts(true); + if (base) setSelectedSpacecraftId(base.spacecraftId); + setSelectedPlanId(draftPlanId); + setActiveTab("editor"); + } catch (error) { + setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана."); + } finally { + setDraftActionInFlight(false); + } + }; + // Удалить черновик (abandon): убирает DRAFT и его клон-миссию. const handleDeleteDraft = async (planId: string) => { setDraftActionInFlight(true); @@ -414,6 +442,7 @@ export function TguPlanningPage() { onEditDraft={handleEditDraft} onPromoteDraft={handlePromoteDraft} onDeleteDraft={handleDeleteDraft} + onCreateDraftFromPlan={handleCreateDraftFromPlan} onClose={() => setSelectedPlanId(undefined)} /> )} @@ -423,11 +452,16 @@ export function TguPlanningPage() { spacecraftId={selectedSpacecraftId} plans={plans} onClose={() => setCreateChainOpen(false)} - onSent={() => void loadData(appliedRange)} onCreated={() => { setShowDrafts(true); void loadData(appliedRange); }} + onEdit={(draftPlanId) => { + setCreateChainOpen(false); + setSelectedPlanId(draftPlanId); + setDecisionNotice(undefined); + setActiveTab("editor"); + }} /> )}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx index 213a665..adffc70 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguSidebar.tsx @@ -53,7 +53,7 @@ export function TguSidebar({ className={`tgu-spacecraft ${selected ? "is-selected" : ""}`} type="button" key={platform.spacecraftId} - onClick={() => onSelectSpacecraft(platform.spacecraftId)} + onClick={() => onSelectSpacecraft(selected ? undefined : platform.spacecraftId)} > From 9e5460d646417ba4ee8650309272eff555a04482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 13:08:08 +0300 Subject: [PATCH 52/79] =?UTF-8?q?pcp-tgu:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80/=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20=E2=80=94?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BE=D0=B1=D1=8A=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0=20(=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8/=D1=81?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D1=81=D1=8B)=20=D0=B8=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D1=81=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Карта 2D: клик по линии (трасса КА / сеанс сброса) выбирает объект — общий инспектор с таймлайном; MapLine.name, тип selection "line" (kind track/drop), связь с работой через modeId; spatialIndex учитывает линии. - Редактор (TguEditorTab/EditorPanels): единый WorkInspector для клика по карте и по таймлайну, подсветка соответствующего включения, стили tgu-editor.css. - config-repo: server.error.include-message=always для pcp-tgu-service и pcp-mission-planing-service — UI показывает причину 4xx (reason/exception), а не только HTTP-фразу. --- config-repo/pcp-mission-planing-service.yaml | 6 + config-repo/pcp-tgu-service.yaml | 7 + .../src/features/tgu-editor/EditorPanels.tsx | 83 +++++++- .../src/features/tgu-editor/TguEditorTab.tsx | 166 ++++++++++++--- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 195 +++++++++++++++++- .../tgu-map-2d/geometry/spatialIndex.test.ts | 23 +++ .../tgu-map-2d/geometry/spatialIndex.ts | 9 +- .../tgu-map-2d/model/mapSelectionTypes.ts | 12 ++ .../src/features/tgu-map-2d/model/mapTypes.ts | 2 + .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 74 +++++++ 10 files changed, 542 insertions(+), 35 deletions(-) diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index 935d86b..0daf469 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -24,6 +24,12 @@ logging: level: root: INFO +# Возвращаем текст причины ошибки клиенту (reason/exception message в поле `message` тела ответа), +# иначе UI видит только HTTP-фразу и не понимает, почему сохранение съёмки/сброса отклонено. +server: + error: + include-message: always + management: endpoints: web: diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 9aec2ab..f84c7b6 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -25,6 +25,13 @@ management: exposure: include: health,info,metrics,prometheus +# Возвращаем текст причины ошибки клиенту (reason из ResponseStatusException попадает в поле +# `message` тела ответа). Без этого UI видит только HTTP-фразу «Unprocessable Content» и не +# понимает, почему действие отклонено (напр. «план заложен — используйте fork-forward»). +server: + error: + include-message: always + springdoc: swagger-ui: path: /swagger/ui diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 55f69fe..74769bc 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { statusStyle } from "../tgu-planning/tguStatus"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; @@ -63,6 +64,8 @@ type ShootPanelProps = { routeLoading?: boolean; routeError?: string; route?: SurveyRoute; + /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ + addBlockedReason?: string; onRouteStartChange: (startMs: number) => void; onRouteEndChange: (endMs: number) => void; /** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */ @@ -114,6 +117,8 @@ type DropPanelProps = { onToggleSurvey: (id: number) => void; saving?: boolean; saveError?: string; + /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ + addBlockedReason?: string; onAdd: () => void; onClose: () => void; }; @@ -287,6 +292,7 @@ export function ShootPanel({ routeLoading, routeError, route, + addBlockedReason, onRouteStartChange, onRouteEndChange, onRouteShift, @@ -436,7 +442,14 @@ export function ShootPanel({ )} -
@@ -499,10 +512,11 @@ export function DropPanel({ onToggleSurvey, saving, saveError, + addBlockedReason, onAdd, onClose }: DropPanelProps) { - const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving; + const canAdd = !!selectedZone && selectedSurveyIds.size > 0 && !saving && !addBlockedReason; return (
@@ -572,6 +586,7 @@ export function DropPanel({ )} + {addBlockedReason &&
{addBlockedReason}
} {saveError &&
{saveError}
} + {open &&
{renderDetail(hit)}
} +
+ ); + })} +
+
+ ); +} + +/** Класс цветовой метки объекта в списке выбора. */ +function selectionSwatch(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") return "station"; + if (selection.type === "line") return "drop"; + return selection.layer; +} + +/** Подпись типа объекта в списке выбора. */ +function selectionSubtitle(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") { + return selection.layer === "stations" ? "Станция" : "Маркер КА"; + } + if (selection.type === "line") { + return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА"; + } + switch (selection.layer) { + case "requests": return "Заявка на съёмку"; + case "planWorks": return "Маршрут съёмки"; + case "swath": return "Полоса обзора"; + case "tracks": return "Трасса КА"; + default: return "Объект карты"; + } +} + function mapInfoTitle(selection: Tgu2DMapSelection): string { if (selection.type === "marker") { return selection.layer === "stations" ? "Станция" : "Маркер КА"; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index d9ab3f3..1426371 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -37,7 +37,7 @@ import { } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; -import { DropPanel, type DropCandidateSurvey, MapInfoPanel, ShootPanel, WorkInspector } from "./EditorPanels"; +import { DropPanel, type DropCandidateSurvey, MapInfoPanel, MapPickPanel, ShootPanel, WorkInspector } from "./EditorPanels"; import { MapPassSelector } from "./MapPassSelector"; import { EditorRail } from "./EditorRail"; import { EditorTimeline } from "./EditorTimeline"; @@ -55,7 +55,7 @@ import type { TguEditorTabProps } from "./model/editorTypes"; -type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; +type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick"; const HOUR_MS = 60 * 60 * 1000; /** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ @@ -107,6 +107,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [dropSaving, setDropSaving] = useState(false); const [dropError, setDropError] = useState(); const [mapInfoSelection, setMapInfoSelection] = useState(); + // Несколько объектов под точкой клика на карте: список для правой панели + раскрытый элемент. + const [mapPickHits, setMapPickHits] = useState([]); + const [mapPickActiveId, setMapPickActiveId] = useState(); const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); @@ -240,6 +243,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]); const planDownlinkCount = useMemo(() => planModes.filter((mode) => mode.type === "DROP").length, [planModes]); + // Первая правка живого плана клонирует его в DRAFT (replace). Бэк это запрещает (422) для + // заложенного (LAID_IN) или уже стартовавшего плана — нужен fork-forward со вкладки «Планы». + // Предупреждаем заранее и гасим «Добавить», чтобы не доводить до ошибки сохранения. Если + // черновик уже создан (draftPlanId) или сам план — DRAFT, правки идут напрямую и блок не нужен. + const inPlaceEditBlockedReason = useMemo(() => { + if (!selectedPlan || draftPlanId || selectedPlan.status === "DRAFT") return undefined; + const blocked = selectedPlan.status === "LAID_IN" || selectedPlan.startMs <= Date.now(); + return blocked + ? "План заложен или уже стартовал — править его на месте нельзя. Создайте исправленный план через fork-forward на вкладке «Планы»." + : undefined; + }, [selectedPlan, draftPlanId]); + useEffect(() => { setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); setWindow(defaultEditorWindow(selectedPlan, appliedRange)); @@ -254,6 +269,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setDropError(undefined); setRvaZones([]); setMapInfoSelection(undefined); + setMapPickHits([]); + setMapPickActiveId(undefined); setServiceConflicts({}); setNotice(undefined); setApplyError(undefined); @@ -747,20 +764,81 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); }; + // Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или + // линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined. + const workForSelection = useCallback( + (selection: Tgu2DMapSelection): EditorWork | undefined => { + const modeId = + selection.type === "polygon" && selection.layer === "planWorks" + ? selection.modeId + : selection.type === "line" && selection.kind === "drop" + ? selection.modeId + : undefined; + if (modeId == null) return undefined; + return displayWorks.find((w) => w.planModeId === modeId); + }, + [displayWorks] + ); + + const clearMapPick = useCallback(() => { + setMapPickHits([]); + setMapPickActiveId(undefined); + }, []); + const handleMapObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { if (rightMode === "shoot") return; + clearMapPick(); if (!selection) { - if (rightMode === "mapInfo") { + if (rightMode === "mapInfo" || rightMode === "mapPick") { setRightMode("none"); setMapInfoSelection(undefined); } return; } + // Клик по маршруту съёмки (контур) или сбросу (линия) на карте = клик по включению на + // таймлайне: открываем тот же инспектор включения (одинаковая информация в обе стороны), + // а не краткую карточку объекта. + const work = workForSelection(selection); + if (work) { + setMapInfoSelection(undefined); + setSelectedWorkId(work.id); + setRightMode("inspect"); + setNotice(undefined); + return; + } setMapInfoSelection(selection); setRightMode("mapInfo"); setSelectedWorkId(undefined); + }, [rightMode, clearMapPick, workForSelection]); + + // Несколько объектов под точкой клика — показываем их списком в правой панели (оператор + // раскрывает нужный, чтобы увидеть его данные). Здесь только наполняем список. + const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => { + if (rightMode === "shoot") return; + setMapPickHits(selections); + setMapPickActiveId(undefined); + setRightMode("mapPick"); + setSelectedWorkId(undefined); + setMapInfoSelection(undefined); + setNotice(undefined); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); }, [rightMode]); + // Раскрытие/сворачивание элемента списка: подсвечиваем объект на карте (без перецентрирования) + // и выделяем соответствующее включение на таймлайне. + const togglePickItem = useCallback((selection: Tgu2DMapSelection) => { + setMapPickActiveId((current) => { + if (current === selection.id) { + setSelectedWorkId(undefined); + setMapFocus((prev) => ({ ...prev, objectId: undefined })); + return undefined; + } + setSelectedWorkId(workForSelection(selection)?.id); + setMapFocus((prev) => ({ ...prev, objectId: selection.id })); + return selection.id; + }); + }, [workForSelection]); + const handleResizerDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); const startY = e.clientY; @@ -782,6 +860,41 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, document.addEventListener("mouseup", onUp); }, [mapRatio]); + // Инспектор включения — общий и для одиночного выбора, и для раскрытого элемента в списке выбора. + const renderWorkInspector = (work: EditorWork, onClose: () => void) => ( + { void handleDeleteWork(workId); }} + onDuplicate={(workId) => { + const id = nextWorkId(idCounterRef); + changeDraft((current) => duplicateWork(current, workId, id)); + setSelectedWorkId(id); + }} + onClose={onClose} + /> + ); + + // Детальная карточка объекта из списка выбора: включение (съёмка/сброс) → инспектор, + // прочее (заявка/станция) → карточка объекта карты. + const renderPickDetail = (selection: Tgu2DMapSelection) => { + const work = workForSelection(selection); + if (work) return renderWorkInspector(work, () => togglePickItem(selection)); + return ( + togglePickItem(selection)} + /> + ); + }; + if (!selectedSpacecraftId && !selectedPlan) { return (
@@ -810,6 +923,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRightMode(mode); setSelectedWorkId(undefined); setMapInfoSelection(undefined); + clearMapPick(); setNotice(undefined); }} onUndo={() => setHistory((current) => undo(current))} @@ -868,6 +982,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, scene={mapScene} layers={EDITOR_MAP_LAYERS} redrawToken={0} + enableObjectChooser + onObjectsAtPoint={handleObjectsAtPoint} pickMode={rightMode === "shoot"} pickTarget={target ?? undefined} onPickPoint={(point) => { @@ -919,28 +1035,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, {rightMode !== "none" && ( )}
@@ -1216,6 +1331,7 @@ function buildEditorMapScene( if (pts.length < 2) continue; lines.push({ id: `drop:${mode.id}`, + name: `Сброс · виток ${mode.revolution}`, layer: "tracks", kind: "drop", modeId: mode.id, diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 2c58958..69d9515 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -6,10 +6,10 @@ import { drawStations } from "./canvas/drawStations"; import { drawSwath } from "./canvas/drawSwath"; import { drawTiles } from "./canvas/drawTiles"; import { drawTracks } from "./canvas/drawTracks"; -import { createMapProjection } from "./geometry/mapProjection"; +import { createMapProjection, normalizeLon } from "./geometry/mapProjection"; import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { GeoPoint, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; +import type { GeoPoint, MapLine, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; type Tgu2DMapViewProps = { @@ -25,6 +25,14 @@ type Tgu2DMapViewProps = { /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ focusPoint?: GeoPoint; focusToken?: number; + /** + * Включает выбор объекта под курсором: если в точке клика лежит несколько объектов + * (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо + * «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса. + */ + enableObjectChooser?: boolean; + /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ + onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; @@ -76,6 +84,8 @@ export function Tgu2DMapView({ selectedObjectId, focusPoint, focusToken, + enableObjectChooser = false, + onObjectsAtPoint, pickMode = false, onPickPoint, targets = [], @@ -98,7 +108,10 @@ export function Tgu2DMapView({ const [tileVersion, setTileVersion] = useState(0); const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); const [tooltip, setTooltip] = useState(); + // Гео-координаты под курсором — показываем по центру внизу карты. + const [cursorGeo, setCursorGeo] = useState(); const selectedPolygonRef = useRef(undefined); + const selectedLineRef = useRef(undefined); const activePolygons = useMemo( () => scene.polygons.filter((polygon) => layers[polygon.layer]), [scene.polygons, layers] @@ -210,6 +223,7 @@ export function Tgu2DMapView({ const updateHover = useCallback( (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); + setCursorGeo(projection.unproject(screenPoint)); const marker = hitTestMarkers(activeMarkers, screenPoint, projection); const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); @@ -254,6 +268,7 @@ export function Tgu2DMapView({ } const screenPoint = screenPointFromEvent(event); + setCursorGeo(projection.unproject(screenPoint)); const dx = screenPoint.x - drag.startX; const dy = screenPoint.y - drag.startY; if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { @@ -280,6 +295,56 @@ export function Tgu2DMapView({ wrapperRef.current?.releasePointerCapture(event.pointerId); }; + // Применить выбор конкретного объекта (из меню выбора либо при единственном попадании): + // выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect. + const applyEditorHit = useCallback((hit: EditorHit) => { + selectedPolygonRef.current = hit.kind === "polygon" ? hit.polygon : undefined; + selectedMarkerRef.current = hit.kind === "marker" ? hit.marker : undefined; + selectedLineRef.current = hit.kind === "line" ? hit.line : undefined; + onObjectSelect(hit.selection); + }, [onObjectSelect]); + + // Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки, + // сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу; + // ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели). + const handleEditorClick = useCallback((screenPoint: ScreenPoint) => { + const geoPoint = projection.unproject(screenPoint); + const hits: EditorHit[] = []; + + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); + + for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { + if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue; + hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); + } + + const dropLines = scene.lines.filter((line) => line.kind === "drop" && layers[line.layer]); + for (const line of hitTestLines(dropLines, screenPoint, projection)) { + hits.push({ kind: "line", line, selection: lineToSelection(line) }); + } + + if (hits.length >= 2 && onObjectsAtPoint) { + // Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectsAtPoint(hits.map((hit) => hit.selection)); + return; + } + if (hits.length >= 1) { + applyEditorHit(hits[0]); + return; + } + + // Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор. + const fallback = hitTestPolygons(polygonIndex, geoPoint); + selectedPolygonRef.current = fallback; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectSelect(fallback ? polygonToSelection(fallback) : undefined); + }, [activeMarkers, applyEditorHit, layers, onObjectSelect, onObjectsAtPoint, polygonIndex, projection, scene.lines]); + const onClick = (event: React.MouseEvent) => { if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); @@ -293,6 +358,11 @@ export function Tgu2DMapView({ return; } + if (enableObjectChooser) { + handleEditorClick(screenPoint); + return; + } + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); if (marker) { selectedMarkerRef.current = marker; @@ -343,7 +413,8 @@ export function Tgu2DMapView({ spacecraftId: polygon.spacecraftId, requestId: polygon.requestId, surveyType: polygon.surveyType, - status: polygon.status + status: polygon.status, + modeId: polygon.modeId }); }; @@ -374,6 +445,8 @@ export function Tgu2DMapView({ () => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined), [selectedObjectId, scene.lines] ); + // Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId). + const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]); const externalHoveredPolygon = useMemo( () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, [hoveredRequestPolygonId, activePolygons] @@ -395,6 +468,7 @@ export function Tgu2DMapView({ hoverRef.current = undefined; hoverMarkerRef.current = undefined; setTooltip(undefined); + setCursorGeo(undefined); }} onWheel={onWheel} > @@ -422,8 +496,9 @@ export function Tgu2DMapView({ ); }); })} - {externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => { - const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize); + {overlayLines.flatMap((line) => { + if (line.points.length < 2) return []; + const pts = unwrapX(line.points.map((point) => projection.project(point)), projection.worldSize); return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => { const visible = pts.some((p) => { const x = p.x + dx; @@ -432,14 +507,14 @@ export function Tgu2DMapView({ if (!visible) return []; return ( `${p.x + dx},${p.y}`).join(" ")} className="is-selected" fill="none" /> ); }); - })()} + })} {overlayMarkers.map((marker) => { const pt = projection.project(marker.point); const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered"; @@ -496,6 +571,7 @@ export function Tgu2DMapView({ {tooltip.subtitle}
)} + {cursorGeo &&
{formatLatLon(cursorGeo)}
}
© OpenStreetMap contributors · © CARTO
@@ -506,6 +582,15 @@ export function Tgu2DMapView({ ); } +/** Подпись координат под курсором: широта/долгота с полушариями. */ +function formatLatLon(point: GeoPoint): string { + const lat = point.lat; + const lon = normalizeLon(point.lon); + const latStr = `${Math.abs(lat).toFixed(4)}° ${lat >= 0 ? "с.ш." : "ю.ш."}`; + const lonStr = `${Math.abs(lon).toFixed(4)}° ${lon >= 0 ? "в.д." : "з.д."}`; + return `${latStr}, ${lonStr}`; +} + function layerTitle(layer: MapPolygon["layer"]): string { switch (layer) { case "planWorks": @@ -573,3 +658,99 @@ function uniquePolygons(polygons: Array): MapPolygon[] { } return result; } + +function uniqueLines(lines: Array): MapLine[] { + const result: MapLine[] = []; + const ids = new Set(); + for (const line of lines) { + if (!line || ids.has(line.id)) continue; + ids.add(line.id); + result.push(line); + } + return result; +} + +/** Один объект-кандидат под курсором: несёт исходный объект (для подсветки) и его выделение. */ +type EditorHit = + | { kind: "marker"; marker: MapMarker; selection: Tgu2DMapSelection } + | { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection } + | { kind: "line"; line: MapLine; selection: Tgu2DMapSelection }; + +function markerToSelection(marker: MapMarker): Tgu2DMapSelection { + return { + type: "marker", + id: marker.id, + name: marker.label, + layer: marker.layer, + stationNumber: marker.stationNumber, + lat: marker.point.lat, + lon: marker.point.lon, + altitude: marker.altitude, + elevationMin: marker.elevationMin, + elevationMax: marker.elevationMax + }; +} + +function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { + return { + type: "polygon", + id: polygon.id, + name: polygon.name, + layer: polygon.layer, + planId: polygon.planId, + spacecraftId: polygon.spacecraftId, + requestId: polygon.requestId, + surveyType: polygon.surveyType, + status: polygon.status, + modeId: polygon.modeId + }; +} + +function lineToSelection(line: MapLine): Tgu2DMapSelection { + return { + type: "line", + id: line.id, + name: line.name ?? "Сброс", + layer: line.layer, + kind: line.kind, + modeId: line.modeId + }; +} + +/** Линии, проходящие в пределах допуска (px) от точки клика, отсортированные по близости. */ +function hitTestLines( + lines: MapLine[], + screenPoint: ScreenPoint, + projection: ReturnType, + tolerance = 7 +): MapLine[] { + const worldSize = projection.worldSize; + const hits: Array<{ line: MapLine; dist: number }> = []; + for (const line of lines) { + if (line.points.length < 2) continue; + const pts = unwrapX(line.points.map((point) => projection.project(point)), worldSize); + let best = Number.POSITIVE_INFINITY; + for (const dx of [0, worldSize, -worldSize]) { + for (let i = 1; i < pts.length; i++) { + const d = distToSegment( + screenPoint, + { x: pts[i - 1].x + dx, y: pts[i - 1].y }, + { x: pts[i].x + dx, y: pts[i].y } + ); + if (d < best) best = d; + } + } + if (best <= tolerance) hits.push({ line, dist: best }); + } + return hits.sort((a, b) => a.dist - b.dist).map((h) => h.line); +} + +function distToSegment(p: ScreenPoint, a: ScreenPoint, b: ScreenPoint): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts index 6f2d1b2..4c2b25e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts @@ -45,5 +45,28 @@ describe("polygon spatial index", () => { expect(queryPolygonsByBBox(index, { minLon: 11, minLat: 11, maxLon: 12, maxLat: 12 }).map((polygon) => polygon.id)) .toEqual(["top"]); }); + + it("matches polygons whose longitudes are in a different 360° window (western hemisphere)", () => { + // Контур в долготах 0..360: западное полушарие ~234° вместо −126°. + const western: MapPolygon = { + id: "western", + name: "Western survey route", + kind: "planWork", + layer: "planWorks", + zIndex: 1, + points: [ + { lon: 233, lat: 48 }, + { lon: 235, lat: 48 }, + { lon: 235, lat: 50 }, + { lon: 233, lat: 50 } + ] + }; + const index = createPolygonSpatialIndex([western]); + + // Видовой bbox в нормализованных долготах [−128, −124] — те же координаты, что и контур, со сдвигом −360°. + expect( + queryPolygonsByBBox(index, { minLon: -128, minLat: 47, maxLon: -124, maxLat: 51 }).map((polygon) => polygon.id) + ).toEqual(["western"]); + }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts index 6cb11e0..31463fb 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -70,8 +70,15 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] { return index.entries - .filter((entry) => entry.bbox.minLon <= bbox.maxLon && entry.bbox.maxLon >= bbox.minLon) .filter((entry) => entry.bbox.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat) + // Долготу сверяем с учётом ±360°: контуры из бэкенда могут приходить в долготах 0..360 + // (западное полушарие как ~234° вместо −126°). Без этого они выпадают из набора отрисовки, + // хотя остаются кликабельными (hit-test уже учитывает сдвиги), — контур «пропадает» на карте. + .filter((entry) => + [0, 360, -360].some( + (shift) => entry.bbox.minLon + shift <= bbox.maxLon && entry.bbox.maxLon + shift >= bbox.minLon + ) + ) .map((entry) => entry.polygon); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts index bc67087..89f14d3 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -11,6 +11,18 @@ export type Tgu2DMapSelection = requestId?: string; surveyType?: string; status?: TguPlanStatus; + /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ + modeId?: number; + } + | { + type: "line"; + id: string; + name: string; + layer: "tracks"; + /** Вид линии: трасса КА или сеанс сброса. */ + kind?: "track" | "drop"; + /** id режима плана (drop) — связь с работой таймлайна. */ + modeId?: number; } | { type: "marker"; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index b885ddd..e7981e1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -39,6 +39,8 @@ export type MapPolygon = { export type MapLine = { id: string; + /** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */ + name?: string; layer: "tracks"; points: GeoPoint[]; spacecraftId?: string; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index 680ec9a..bf875db 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -1094,3 +1094,77 @@ max-height: 24rem; } } + +/* Список объектов под точкой клика на карте (раскрывающиеся карточки). */ +.tgu-editor-pick { + display: grid; + gap: 0.35rem; +} + +.tgu-editor-pick__row { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + column-gap: 0.5rem; + align-items: center; + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.55rem; + background: var(--bg-panel-2); + color: var(--text); + text-align: left; + cursor: pointer; +} + +.tgu-editor-pick__row:hover { + border-color: rgba(104, 195, 189, 0.45); +} + +.tgu-editor-pick__item.is-open .tgu-editor-pick__row { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.14); +} + +.tgu-editor-pick__swatch { + grid-row: 1 / span 2; + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--text-dim); +} + +.tgu-editor-pick__swatch--requests { background: #ffa040; } +.tgu-editor-pick__swatch--planWorks { background: #4fd2b0; } +.tgu-editor-pick__swatch--drop { background: #ffcf3f; } +.tgu-editor-pick__swatch--station { background: #d5e2e2; } +.tgu-editor-pick__swatch--swath { background: #6fb7ff; } + +.tgu-editor-pick__title { + font-size: 0.74rem; + font-weight: 600; +} + +.tgu-editor-pick__sub { + grid-column: 2; + color: var(--text-dim); + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tgu-editor-pick__chevron { + grid-row: 1 / span 2; + color: var(--text-dim); + font-size: 0.7rem; +} + +/* Вложенная карточка детали внутри раскрытого элемента — без двойной рамки/отступов. */ +.tgu-editor-pick__detail { + padding: 0.35rem 0 0.1rem; +} + +.tgu-editor-pick__detail .tgu-editor-panel { + border: 0; + padding: 0; +} From f0e56b6e2a697d66325ad8cb2e6cf9d5c30c0fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 13:30:12 +0300 Subject: [PATCH 53/79] =?UTF-8?q?pcp-tgu:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=E2=80=94=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=B2=D0=B8=D1=82=D0=BA=D0=B8?= =?UTF-8?q?,=20=D0=BF=D0=BB=D0=B5=D0=B9=D1=81=D1=85=D0=BE=D0=BB=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0,=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вкладка «Планы»: - «Создать черновик на основе плана» больше не перекидывает в редактор: черновик создаётся, выбирается на схеме (тумблер «Черновики» вкл.) и показывается подсказка — оператор сам переходит в «Редактор». Редактор: - Витки больше не пропадают «иногда»: жизненный цикл flightLineData теперь полностью внутри fetch-эффекта на [noradId, mapRange] (сброс + перезагрузка атомарны). Раньше трассу сбрасывал общий reset по ссылке selectedPlan, а перезагружал отдельный эффект — фоновый loadData пересобирал selectedPlan с тем же окном, mapRange по значению не менялся, фетч не перезапускался, витки молча исчезали. - Явный индикатор вместо немой пустоты: «Загрузка трассы…» / «Нет данных трассы на это окно». - После удаления открытого черновика редактор не «всплывал» обратно: guard загрузки включений не тянет режимы по устаревшему draftPlanId, когда план не выбран. - Редактор включений гейтится на наличие плана (план == миссия): без плана — плейсхолдер «Выберите КА и план…». Иначе после удаления черновика вкладка оставалась активной по КА (витки, режим съёмки), хотя редактировать нечего. --- .../src/features/tgu-editor/TguEditorTab.tsx | 49 +++++++++++++++---- .../features/tgu-planning/TguPlanningPage.tsx | 14 ++++-- .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 14 ++++++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 1426371..b7cddc8 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -127,6 +127,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); const [flightLineData, setFlightLineData] = useState([]); + // Статус загрузки трассы (витков): отличаем «ещё грузится» от «бэк вернул пусто на это окно», + // чтобы при отсутствии данных не было молчаливой пустоты вместо панели витков. + const [flightLineLoading, setFlightLineLoading] = useState(false); // Команда карте центрироваться/подсветить объект выбранного включения (клик по включению). const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 }); const draft = history.draft; @@ -277,15 +280,26 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setPlanModes([]); setPassFrom(1); setPassTo(999); - setFlightLineData([]); }, [appliedRange, selectedPlan, selectedSpacecraftId]); + // Линия трассы (витки) живёт исключительно по своим входам запроса — noradId и mapRange. + // НЕ сбрасываем её в общем reset-эффекте по ссылке selectedPlan: фоновый loadData (после + // решения/создания черновика/обновления интервала) пересобирает объект selectedPlan с теми же + // временами окна, mapRange по значению не меняется, и фетч бы не перезапустился — витки + // молча пропадали бы до реальной смены окна. Привязка к [noradId, mapRange] делает это + // идемпотентным: сброс и перезагрузка происходят вместе и только когда меняется само окно/КА. useEffect(() => { - if (!noradId) return; + if (!noradId) { + setFlightLineData([]); + setFlightLineLoading(false); + return; + } let cancelled = false; + setFlightLineLoading(true); void fetchFlightLine(noradId, mapRange.fromMs, mapRange.toMs) .then((data) => { if (!cancelled) setFlightLineData(data); }) - .catch(() => { if (!cancelled) setFlightLineData([]); }); + .catch(() => { if (!cancelled) setFlightLineData([]); }) + .finally(() => { if (!cancelled) setFlightLineLoading(false); }); return () => { cancelled = true; }; }, [noradId, mapRange]); @@ -464,8 +478,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). useEffect(() => { - const missionId = draftPlanId ?? selectedPlan?.planId; - if (!missionId) return; + // Нет выбранного плана (например, открытый черновик удалили) — чистим включения и НЕ тянем + // режимы по устаревшему draftPlanId. Он обнулится следующим коммитом (эффект выше), но в этом + // ещё указывает на удалённый черновик; без гарда fetchPlanModes(удалённый id) вернул бы его + // включения обратно — редактор «не чистился» бы после удаления. + if (!selectedPlan) { + setPlanModes([]); + return; + } + const missionId = draftPlanId ?? selectedPlan.planId; let cancelled = false; void fetchPlanModes(missionId).then((modes) => { if (!cancelled) setPlanModes(modes); @@ -895,10 +916,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, ); }; - if (!selectedSpacecraftId && !selectedPlan) { + // Редактор включений работает только при выбранном плане (план == миссия, в которую кладутся + // включения). Без плана — плейсхолдер: иначе после удаления открытого черновика вкладка + // оставалась бы «активной» по выбранному КА (витки, режим съёмки), хотя редактировать нечего. + if (!selectedPlan) { return (
-
Выберите КА или план, чтобы открыть редактор включений.
+
Выберите КА и план, чтобы открыть редактор включений.
); } @@ -969,7 +993,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, >
{rightMode === "shoot" &&
Кликните точку съёмки на карте.
} - {orbitPasses.length > 0 && ( + {orbitPasses.length > 0 ? ( - )} + ) : noradId ? ( +
+ Витки + + {flightLineLoading ? "Загрузка трассы…" : "Нет данных трассы на это окно"} + +
+ ) : null} { - // Один открытый черновик на базу: если он уже есть — открываем его, второй не плодим. + // Один открытый черновик на базу: если он уже есть — выбираем его, второй не плодим. const existing = drafts.find((item) => item.basePlanId === planId); if (existing) { - handleEditDraft(existing.planId); + setShowDrafts(true); + setSelectedSpacecraftId(existing.spacecraftId); + setSelectedPlanId(existing.planId); + setDecisionNotice("Черновик на основе этого плана уже создан — выбран на схеме."); return; } setDraftActionInFlight(true); @@ -320,7 +324,7 @@ export function TguPlanningPage() { setShowDrafts(true); if (base) setSelectedSpacecraftId(base.spacecraftId); setSelectedPlanId(draftPlanId); - setActiveTab("editor"); + setDecisionNotice("Черновик создан и выбран на схеме. Откройте «Редактор», чтобы поправить и отправить его."); } catch (error) { setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана."); } finally { diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index bf875db..f64d496 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -308,6 +308,20 @@ user-select: none; } +.map-pass-selector--empty { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0.65rem; + width: auto; + max-width: 28rem; +} + +.map-pass-selector__empty-note { + color: var(--text-muted, #98a2ac); + font-size: 0.74rem; +} + .map-pass-selector__header { display: flex; align-items: baseline; From 9e7cae4a56752ff561acde38722b288a3ebf4645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 13:30:21 +0300 Subject: [PATCH 54/79] =?UTF-8?q?docs:=20=D0=B0=D0=BA=D1=82=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20pcp-tgu-o?= =?UTF-8?q?ps-ui=20(=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D1=8B,=20=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D0=BA=D0=B8/=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D1=8B=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=B7=D0=BE=D1=80=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/задачи для pcp-tgu-ops-ui.txt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/задачи для pcp-tgu-ops-ui.txt b/docs/задачи для pcp-tgu-ops-ui.txt index a3498a0..acb83f4 100644 --- a/docs/задачи для pcp-tgu-ops-ui.txt +++ b/docs/задачи для pcp-tgu-ops-ui.txt @@ -1,13 +1,15 @@ 1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu 2. Ошибка при попытке принять план во вкладке планы -3. Происходит накопление целей съемки, этого делать не нужно, пусть будет просто перекрестие на карте -А на панели справа пусть будет написано опорная точка прицеливания и ее координаты -4. Дата время в формате 24 часов а не AM PM, во всем интерфейсе. + + +4. Необходимо обновить статусы во вкладке планы (слева внизу), чтобы они были актуальные. И нужно пусть для меня хотя бы пояснение по каждому из них в какой момент какой статус наступает и что он означает +Эту информацию лучше положить в docs, чтобы заново не выискивать потом +5. Пропадают витки с вкладки редактор + +6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте. -5. Добавить маску важности, чтобы можно было легко ввести 0.1 или 0.3 -6. При нажатии на включение нужно центрировать экран на него и подсвечивать выбор, будто кликнули по самому объекту на карте -7. Сбросы нужно отобрадать на картооснове как толстую яркую линию на трассе, время начала конца линии - время начала конца сброса -8. При нажатии на ЗРВ в окне Планы нужно справа отображать информацию по этим ЗРВ и если их несколько то по нескольких по примеру завок на вкладке Карта -10. Проблема того, что в одном и том же месте есть план Rejected и план WAIting_decision (скриншот сохранен) +TODO: +- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца. +- From 5235142fa6b7d3b9d061b142553cf3b927daf2bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 14:20:01 +0300 Subject: [PATCH 55/79] =?UTF-8?q?pcp-tgu:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=E2=80=94=20=D1=82=D0=B0=D0=B9=D0=BC=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D0=BD=20=D0=B7=D1=83=D0=BC/=D0=B4=D1=80=D0=B0?= =?UTF-8?q?=D0=B3/=D1=81=D0=B5=D0=BA=D1=83=D0=BD=D0=B4=D0=BD=D0=B0=D1=8F?= =?UTF-8?q?=20=D1=88=D0=BA=D0=B0=D0=BB=D0=B0,=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8F=D1=85=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0,=20=D0=B1=D0=B5=D0=B7=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE-=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика - редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара - окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор) - таймлайн: drag по горизонтали как карту; минимум окна ~10с - ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе --- docs/задачи для pcp-tgu-ops-ui.txt | 8 +- .../features/tgu-editor/EditorTimeline.tsx | 93 +++++++++- .../src/features/tgu-editor/EditorToolbar.tsx | 52 +----- .../src/features/tgu-editor/TguEditorTab.tsx | 166 +++++++++--------- .../features/tgu-editor/editorPresentation.ts | 27 ++- .../features/tgu-planning/TguPlanDetails.tsx | 14 +- 6 files changed, 211 insertions(+), 149 deletions(-) diff --git a/docs/задачи для pcp-tgu-ops-ui.txt b/docs/задачи для pcp-tgu-ops-ui.txt index acb83f4..44d3da3 100644 --- a/docs/задачи для pcp-tgu-ops-ui.txt +++ b/docs/задачи для pcp-tgu-ops-ui.txt @@ -1,15 +1,11 @@ 1. Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu 2. Ошибка при попытке принять план во вкладке планы - -4. Необходимо обновить статусы во вкладке планы (слева внизу), чтобы они были актуальные. И нужно пусть для меня хотя бы пояснение по каждому из них в какой момент какой статус наступает и что он означает -Эту информацию лучше положить в docs, чтобы заново не выискивать потом -5. Пропадают витки с вкладки редактор - 6. Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте. TODO: - Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца. -- +- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например) +- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx index 8e6f105..8aaca74 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorTimeline.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation"; +import { formatDate, formatDateTime, formatTick, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation"; import type { EditorContextLane, EditorTimelineWindow, @@ -20,6 +20,10 @@ type EditorTimelineProps = { visibilityTracks: EditorVisibilityTrack[]; onSelectWork: (workId: string) => void; onClearSelection: () => void; + /** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */ + onWheelZoom?: (factor: number, anchorMs: number) => void; + /** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */ + onPanTo?: (fromMs: number) => void; }; const LABEL_WIDTH = 168; @@ -40,15 +44,21 @@ export function EditorTimeline({ contextLanes, visibilityTracks, onSelectWork, - onClearSelection + onClearSelection, + onWheelZoom, + onPanTo }: EditorTimelineProps) { const wrapperRef = useRef(null); const [bodyWidth, setBodyWidth] = useState(900); const [bodyHeight, setBodyHeight] = useState(260); + const [grabbing, setGrabbing] = useState(false); const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs); const pxPerMs = bodyWidth / span; const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs; - const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]); + const { ticks, step: tickStep } = useMemo( + () => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), + [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs] + ); const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]); // Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА). const belowActiveHeight = @@ -81,8 +91,81 @@ export function EditorTimeline({ return () => observer.disconnect(); }, []); + // Свежие значения окна/геометрии для нативного wheel-слушателя (он навешивается один раз). + const wheelStateRef = useRef({ fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom }); + wheelStateRef.current = { fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom }; + + useEffect(() => { + const element = wrapperRef.current; + if (!element) return; + + // Нативный слушатель с passive: false — иначе preventDefault не сработает и страница + // прокрутится вместо масштабирования таймлайна. + const handleWheel = (event: WheelEvent) => { + const state = wheelStateRef.current; + if (!state.onWheelZoom) return; + event.preventDefault(); + const rect = element.getBoundingClientRect(); + const x = event.clientX - rect.left - LABEL_WIDTH; + const clampedX = Math.min(Math.max(x, 0), state.bodyWidth); + const anchorMs = state.fromMs + clampedX / state.pxPerMs; + const factor = event.deltaY > 0 ? 1.1 : 0.9; + state.onWheelZoom(factor, anchorMs); + }; + + element.addEventListener("wheel", handleWheel, { passive: false }); + return () => element.removeEventListener("wheel", handleWheel); + }, []); + + // Перетаскивание окна по горизонтали (как карту). На mousedown фиксируем точку и левую + // границу окна, дальше двигаем абсолютно (без накопления дрейфа). Сдвиг > порога помечаем + // как drag, чтобы погасить последующий click (иначе выделение/сброс включения). + const dragRef = useRef<{ startX: number; startFromMs: number; pxPerMs: number; moved: boolean } | null>(null); + const suppressClickRef = useRef(false); + + const handleMouseDown = (event: React.MouseEvent) => { + if (event.button !== 0 || !onPanTo || pxPerMs <= 0) return; + const drag = { startX: event.clientX, startFromMs: timelineWindow.fromMs, pxPerMs, moved: false }; + dragRef.current = drag; + + const onMove = (move: MouseEvent) => { + const deltaPx = move.clientX - drag.startX; + if (Math.abs(deltaPx) > 3) { + drag.moved = true; + setGrabbing(true); + } + // Тянем содержимое вправо → показываем более раннее время (окно едет влево). + onPanTo(drag.startFromMs - deltaPx / drag.pxPerMs); + }; + const onUp = () => { + if (drag.moved) suppressClickRef.current = true; + dragRef.current = null; + setGrabbing(false); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }; + + // Капче-фаза: если только что был drag — гасим click до того, как он дойдёт до полос/фона. + const handleClickCapture = (event: React.MouseEvent) => { + if (suppressClickRef.current) { + suppressClickRef.current = false; + event.stopPropagation(); + event.preventDefault(); + } + }; + return ( -
+
Время @@ -96,7 +179,7 @@ export function EditorTimeline({ ))} {ticks.map((tick) => (
- {formatTime(tick)} + {formatTick(tick, tickStep)}
))}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 3debbf1..4d0bee5 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -5,48 +5,26 @@ type EditorToolbarProps = { selectedSpacecraftId?: string; selectedPlan?: TguPlanUi; platforms: TguPlatformUi[]; - canUndo: boolean; - canRedo: boolean; checking: boolean; conflictCount: number; - addedCount: number; - dirty: boolean; - applying: boolean; - canceling: boolean; windowLabel: string; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; - onUndo: () => void; - onRedo: () => void; onCheck: () => void; - onApply: () => void; - onCancel: () => void; onZoom: (factor: number) => void; - onPan: (factor: number) => void; }; export function EditorToolbar({ selectedSpacecraftId, selectedPlan, platforms, - canUndo, - canRedo, checking, conflictCount, - addedCount, - dirty, - applying, - canceling, windowLabel, rightMode, onSetRightMode, - onUndo, - onRedo, onCheck, - onApply, - onCancel, - onZoom, - onPan + onZoom }: EditorToolbarProps) { const platformLabel = selectedSpacecraftId ? platforms.find((platform) => platform.spacecraftId === selectedSpacecraftId)?.name ?? selectedSpacecraftId @@ -85,21 +63,10 @@ export function EditorToolbar({
-
- +
{windowLabel || formatDuration(0)} - -
- -
- -
- - {dirty && ( - - )} - -
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index b7cddc8..466798a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -13,7 +13,7 @@ import { validateMission, type SurveyRoute } from "../../api/missionPlanningApi"; -import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi"; +import { abandonDraft, createDraft } from "../../api/tguPlanApi"; import { parseLocal, toLocalIso } from "../../utils/localTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -30,9 +30,7 @@ import { duplicateWork, mutate, planModesToWorks, - redo, seedDraft, - undo, updateWork } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; @@ -58,6 +56,14 @@ import type { type EditorRightMode = "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick"; const HOUR_MS = 60 * 60 * 1000; +/** + * Отступ окна редактора от границ плана при самом отдалённом масштабе: начало и конец + * плана отстоят на час от краёв видимого окна. Это же — предел «отдаления» (zoom out). + */ +const EDITOR_WINDOW_PAD_MS = HOUR_MS; +/** Минимальная ширина окна редактора при «приближении» (zoom in): ~10 с, чтобы на оси + * появлялись секундные деления. */ +const MIN_EDITOR_WINDOW_MS = 10 * 1000; /** Фиксированная длительность окна съёмки, строится вокруг момента видимости. */ const SHOOT_WINDOW_MS = 6 * 60 * 1000; /** @@ -94,6 +100,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, future: [] })); const [window, setWindow] = useState(() => defaultEditorWindow(selectedPlan, appliedRange)); + // Предел отдаления: окно не шире, чем план ± час (тот же диапазон, что и дефолтное окно). + const maxWindow = useMemo( + () => + selectedPlan + ? { fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS } + : { fromMs: appliedRange.fromMs, toMs: appliedRange.toMs }, + [selectedPlan, appliedRange] + ); const [rightMode, setRightMode] = useState("none"); const [selectedWorkId, setSelectedWorkId] = useState(); const [target, setTarget] = useState(); @@ -113,8 +127,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); - const [applying, setApplying] = useState(false); - const [canceling, setCanceling] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -122,7 +134,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [planModes, setPlanModes] = useState([]); // Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace). // Пока null — редактор показывает живой план read-only; после создания все правки и валидация - // адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку. + // адресуются в этот DRAFT. Отправка (promote) и удаление черновика делаются на вкладке «Планы». const [draftPlanId, setDraftPlanId] = useState(); const [passFrom, setPassFrom] = useState(1); const [passTo, setPassTo] = useState(999); @@ -237,10 +249,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, .sort((a, b) => a.startMs - b.startMs); }, [planModes, selectedZone]); - // «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку); - // локальные несохранённые твики без DRAFT применять нечего. - const dirty = Boolean(draftPlanId); - const addedCount = draft.works.filter((work) => work.isNew).length; // Реальный состав плана берём из включений (plan modes), а не из черновика: // черновик сидируется заглушкой, когда у плана нет встроенных works. const planSurveyCount = useMemo(() => planModes.filter((mode) => mode.type === "SURVEY").length, [planModes]); @@ -704,53 +712,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; - // «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под - // оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка). - const applyDraft = async () => { - if (!draftPlanId) { - setApplyError("Нет правок для применения — черновик ещё не создан."); - setNotice(undefined); - return; - } - - setApplying(true); - setApplyError(undefined); - setNotice(undefined); - try { - const result = await promoteDraft(draftPlanId); - setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`); - setDraftPlanId(undefined); - setServiceConflicts({}); - setHistory((current) => ({ ...current, past: [], future: [] })); - } catch (error) { - setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана."); - } finally { - setApplying(false); - } - }; - - // «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает - // редактор к живому плану (read-only) и сбрасывает локальные правки. - const cancelDraft = async () => { - if (!draftPlanId) return; - setCanceling(true); - setApplyError(undefined); - setNotice(undefined); - try { - await abandonDraft(draftPlanId); - setDraftPlanId(undefined); - setServiceConflicts({}); - setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); - setSelectedWorkId(undefined); - setRightMode("none"); - setNotice("Черновик отменён — правки отброшены."); - } catch (error) { - setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик."); - } finally { - setCanceling(false); - } - }; - // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые // (planModeId нет) — только локально. @@ -770,20 +731,68 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRightMode("none"); }; - const zoom = (factor: number) => { - setWindow((current) => { - const center = (current.fromMs + current.toMs) / 2; - const half = ((current.toMs - current.fromMs) / 2) * factor; - return { fromMs: center - half, toMs: center + half }; - }); - }; + // Масштабирование окна относительно точки-якоря (центр окна для кнопок, точка под + // курсором для колеса мыши). Ширину зажимаем в [MIN_EDITOR_WINDOW_MS; ширина maxWindow], + // затем двигаем окно так, чтобы оно не вышло за план ± час. + const zoomAt = useCallback( + (factor: number, anchorMs: number) => { + setWindow((current) => { + const span = current.toMs - current.fromMs; + const maxSpan = maxWindow.toMs - maxWindow.fromMs; + const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan); + const ratio = span > 0 ? (anchorMs - current.fromMs) / span : 0.5; + let fromMs = anchorMs - ratio * nextSpan; + let toMs = fromMs + nextSpan; + if (fromMs < maxWindow.fromMs) { + fromMs = maxWindow.fromMs; + toMs = fromMs + nextSpan; + } + if (toMs > maxWindow.toMs) { + toMs = maxWindow.toMs; + fromMs = toMs - nextSpan; + } + return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs }; + }); + }, + [maxWindow] + ); - const pan = (factor: number) => { - setWindow((current) => { - const delta = (current.toMs - current.fromMs) * factor; - return { fromMs: current.fromMs + delta, toMs: current.toMs + delta }; - }); - }; + const zoom = useCallback( + (factor: number) => { + setWindow((current) => { + const center = (current.fromMs + current.toMs) / 2; + const span = current.toMs - current.fromMs; + const maxSpan = maxWindow.toMs - maxWindow.fromMs; + const nextSpan = Math.min(Math.max(span * factor, MIN_EDITOR_WINDOW_MS), maxSpan); + let fromMs = center - nextSpan / 2; + let toMs = center + nextSpan / 2; + if (fromMs < maxWindow.fromMs) { + fromMs = maxWindow.fromMs; + toMs = fromMs + nextSpan; + } + if (toMs > maxWindow.toMs) { + toMs = maxWindow.toMs; + fromMs = toMs - nextSpan; + } + return { fromMs: Math.max(fromMs, maxWindow.fromMs), toMs }; + }); + }, + [maxWindow] + ); + + // Горизонтальный сдвиг окна перетаскиванием: ширину окна не меняем, левую границу зажимаем + // в [maxWindow.fromMs; maxWindow.toMs − ширина], чтобы не выехать за план ± час. + const panTo = useCallback( + (fromMs: number) => { + setWindow((current) => { + const span = current.toMs - current.fromMs; + let from = Math.min(Math.max(fromMs, maxWindow.fromMs), maxWindow.toMs - span); + if (from < maxWindow.fromMs) from = maxWindow.fromMs; + return { fromMs: from, toMs: from + span }; + }); + }, + [maxWindow] + ); // Включение (съёмка/сброс), соответствующее объекту карты: контур маршрута (planWorks) или // линия сброса (drop) → работа таймлайна по modeId ↔ planModeId. Прочие объекты → undefined. @@ -933,14 +942,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, selectedSpacecraftId={selectedSpacecraftId} selectedPlan={selectedPlan} platforms={platforms} - canUndo={history.past.length > 0} - canRedo={history.future.length > 0} checking={checking} conflictCount={conflictCount(conflicts)} - addedCount={addedCount} - dirty={dirty} - applying={applying} - canceling={canceling} windowLabel={formatDuration(window.toMs - window.fromMs)} rightMode={rightMode} onSetRightMode={(mode) => { @@ -950,13 +953,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, clearMapPick(); setNotice(undefined); }} - onUndo={() => setHistory((current) => undo(current))} - onRedo={() => setHistory((current) => redo(current))} onCheck={runCheck} - onApply={applyDraft} - onCancel={cancelDraft} onZoom={zoom} - onPan={pan} />
@@ -1061,6 +1059,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setMapFocus((prev) => ({ ...prev, objectId: undefined })); } }} + onWheelZoom={zoomAt} + onPanTo={panTo} /> @@ -1217,8 +1217,8 @@ function defaultEditorWindow( ): EditorTimelineWindow { if (selectedPlan) { return { - fromMs: selectedPlan.startMs - 3 * HOUR_MS, - toMs: selectedPlan.endMs + 3 * HOUR_MS + fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, + toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS }; } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index 4417ab9..f56a6dc 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -27,6 +27,18 @@ export function formatTime(ms: number): string { return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } +/** + * Подпись деления оси с учётом шага: при шаге меньше минуты показываем секунды + * (ЧЧ:ММ:СС), иначе — ЧЧ:ММ. Так при сильном зуме видно более мелкий масштаб времени. + */ +export function formatTick(ms: number, stepMs: number): string { + const date = new Date(ms); + if (stepMs < 60_000) { + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + } + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + export function formatDate(ms: number): string { const date = new Date(ms); return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`; @@ -57,10 +69,19 @@ export function formatDuration(ms: number): string { return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`; } -export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): number[] { +/** Деления оси времени: {@link ticks} — отметки, {@link step} — выбранный шаг (мс). */ +export type TimelineTicks = { ticks: number[]; step: number }; + +export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): TimelineTicks { + const minuteMs = 60_000; const hourMs = 3_600_000; const dayMs = 24 * hourMs; - const steps = [hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs]; + // От секунд до недели — чем сильнее зум, тем мельче шаг, который влезает по minPx. + const steps = [ + 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, + minuteMs, 2 * minuteMs, 5 * minuteMs, 10 * minuteMs, 15 * minuteMs, 30 * minuteMs, + hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs + ]; const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1]; const ticks: number[] = []; const firstTick = Math.ceil(fromMs / step) * step; @@ -69,7 +90,7 @@ export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: ticks.push(timeMs); } - return ticks; + return { ticks, step }; } function pad(value: number): string { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index 331e14f..9761f41 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -79,6 +79,16 @@ export function TguPlanDetails({ onDecision(plan.planId, "REJECTED", reason); }; + const promoteDraft = () => { + if (!window.confirm("Отправить черновик на выдачу? Он уйдёт в цепочку и станет рабочим после приёмки.")) return; + onPromoteDraft?.(plan.planId); + }; + + const deleteDraft = () => { + if (!window.confirm("Удалить черновик? Его нельзя будет восстановить.")) return; + onDeleteDraft?.(plan.planId); + }; + return (
); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 4d0bee5..5668e4c 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -6,11 +6,19 @@ type EditorToolbarProps = { selectedPlan?: TguPlanUi; platforms: TguPlatformUi[]; checking: boolean; + /** Идёт расчёт/очистка всего плана — блокирует кнопки операций над планом. */ + planCalcRunning: boolean; + /** Почему нельзя рассчитывать/чистить план (заложен/стартовал) — гасит кнопки. */ + calcBlockedReason?: string; conflictCount: number; windowLabel: string; - rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; + rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick"; onSetRightMode: (mode: "shoot" | "downlink") => void; onCheck: () => void; + /** Рассчитать план съёмки и сброса одной операцией. */ + onCalculatePlan: () => void; + /** Полностью очистить план (с подтверждением внутри обработчика). */ + onClearPlan: () => void; onZoom: (factor: number) => void; }; @@ -19,11 +27,15 @@ export function EditorToolbar({ selectedPlan, platforms, checking, + planCalcRunning, + calcBlockedReason, conflictCount, windowLabel, rightMode, onSetRightMode, onCheck, + onCalculatePlan, + onClearPlan, onZoom }: EditorToolbarProps) { const platformLabel = selectedSpacecraftId @@ -61,6 +73,27 @@ export function EditorToolbar({
+
+ + +
+
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 466798a..ac4e06b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -7,6 +7,9 @@ import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; import { buildSurveyRoute, + calculateDrops, + calculateSurveys, + clearPlanModes, deleteMode, saveDropRoute, saveSurveyRoute, @@ -127,6 +130,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [stationDtos, setStationDtos] = useState([]); const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); + // Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) — + // блокирует повторный запуск и кнопки правок, пока не перечитаем включения. + const [planCalcRunning, setPlanCalcRunning] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -712,12 +718,71 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; - // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, - // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые - // (planModeId нет) — только локально. + // Запуск расчёта плана съёмки и сброса как одной операции (вкладка «Редактор»). Адресуем + // в DRAFT (ленивое создание на первую правку), затем перечитываем включения. Пересчёт на бэке + // сохраняет ручные маршруты съёмки (source=MANUAL) — они не затираются. Источник комплексного + // плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана. + const runPlanCalculation = async () => { + if (!selectedPlan || planCalcRunning) return; + setPlanCalcRunning(true); + setApplyError(undefined); + setNotice(undefined); + try { + const draftId = await ensureDraft(); + const surveyResult = await calculateSurveys(draftId); + await calculateDrops(draftId); + await refreshDraftModes(draftId); + setNotice( + `Расчёт выполнен: съёмок ${surveyResult.surveyModesCount} (ручных сохранено ${surveyResult.manualModesCount}); сбросы пересчитаны.` + ); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось рассчитать план."); + } finally { + setPlanCalcRunning(false); + } + }; + + // Полная очистка плана: сносит ВСЕ включения (съёмки, включая ручные, и сбросы) в БД черновика + // и в локальном представлении. Единственный способ убрать ручные маршруты массово (помимо + // ручного удаления по одному). Подтверждение — действие необратимо. + const clearPlan = async () => { + if (!selectedPlan || planCalcRunning) return; + // window здесь затенён состоянием окна таймлайна — берём confirm из globalThis. + if (!globalThis.confirm("Очистить план полностью? Будут удалены все включения, включая построенные вручную. Действие необратимо.")) { + return; + } + setPlanCalcRunning(true); + setApplyError(undefined); + setNotice(undefined); + try { + const draftId = await ensureDraft(); + await clearPlanModes(draftId); + await refreshDraftModes(draftId); + changeDraft((current) => ({ ...current, works: [] })); + setSelectedWorkId(undefined); + setRightMode("none"); + setNotice("План полностью очищен."); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось очистить план."); + } finally { + setPlanCalcRunning(false); + } + }; + + // Удаление включения (правая панель, с подтверждением): персистнутые режимы (с planModeId) + // удаляются в БД черновика (Ф3, с каскадами drop↔survey), затем убираются из локального + // представления. Plan-origin включения адресуются по planModeId только когда черновик уже + // существует (draftPlanId): тогда включения принадлежат именно ему. Несохранённые (planModeId + // нет) — только локально. const handleDeleteWork = async (workId: string) => { - const work = draft.works.find((item) => item.id === workId); - if (work?.planModeId != null && draftPlanId) { + const work = displayWorks.find((item) => item.id === workId); + if (!work) return; + const confirmMessage = + work.kind === "downlink" + ? "Удалить включение сброса? Действие необратимо." + : "Удалить маршрут съёмки? Действие необратимо."; + if (!globalThis.confirm(confirmMessage)) return; + if (work.planModeId != null && draftPlanId) { try { await deleteMode(work.planModeId); await refreshDraftModes(draftPlanId); @@ -900,6 +965,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, surveyModeLabel={surveyModeLabel} conflicts={conflicts[work.id] ?? []} readOnly={work.origin === "plan"} + canDelete={draftPlanId != null} onUpdate={onChangeWork} onDelete={(workId) => { void handleDeleteWork(workId); }} onDuplicate={(workId) => { @@ -943,6 +1009,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, selectedPlan={selectedPlan} platforms={platforms} checking={checking} + planCalcRunning={planCalcRunning} + calcBlockedReason={inPlaceEditBlockedReason} conflictCount={conflictCount(conflicts)} windowLabel={formatDuration(window.toMs - window.fromMs)} rightMode={rightMode} @@ -954,6 +1022,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setNotice(undefined); }} onCheck={runCheck} + onCalculatePlan={() => { void runPlanCalculation(); }} + onClearPlan={() => { void clearPlan(); }} onZoom={zoom} /> From 69dfd8025d9478e0c5b8c8d3b10355e8b0883873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 4 Jun 2026 21:15:13 +0300 Subject: [PATCH 62/79] =?UTF-8?q?pcp-tgu:=20=D1=81=D0=BB=D0=BE=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=84=D0=B0=D0=BA=D1=82=D1=83=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8,=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D1=82=20=D1=80=D0=B0=D0=BD=D0=BD=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8,=20=D1=88?= =?UTF-8?q?=D0=B8=D1=80=D0=B5=20=D1=80=D0=B5=D0=B9=D0=BB=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - слоты (acceptedPlanSlotsUpdate) занимаются на LAID_IN, а не на ACCEPT — до закладки план ещё может быть вытеснен коррекцией - «Подтвердить закладку» недоступно, пока начало плана в будущем - левая панель редактора 15.5rem → 17rem (цифры статистики у границы) --- .../src/features/tgu-editor/TguEditorTab.tsx | 2 +- .../features/tgu-planning/TguPlanDetails.tsx | 20 ++++++++++++++----- .../service/HandlePlanDecisionUseCase.kt | 6 +++++- .../service/PlanIssueAndDecisionFlowTest.kt | 10 +++++++--- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index ac4e06b..e51ba7f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -1032,7 +1032,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, {applyError &&
{applyError}
}
-
+
plan.startMs + DECISION_GRACE_MINUTES * 60_000; + // Закладку нельзя подтверждать, пока начало плана ещё в будущем: план закладывается на КА + // по факту наступления своего старта, а не заранее. + const layinStartInFuture = plan.startMs > Date.now(); // «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для // персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция, // не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward). @@ -232,17 +235,24 @@ export function TguPlanDetails({ - {layinConflict && ( + {layinStartInFuture && ( +
+ Начало плана ещё в будущем — подтвердить закладку можно только после наступления его старта. +
+ )} + {!layinStartInFuture && layinConflict && (
По КА уже есть заложенный план со стартом в пределах ±10 минут — подтвердить нельзя.
diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt index 230b7a8..a7cf27f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt @@ -149,6 +149,10 @@ class HandlePlanDecisionUseCase( plannedPlanRepository.save(plan) spacecraftPlanningStateJpaRepository.save(state) + + // Статусы слотов проставляем только по факту закладки на КА (LAID_IN), а не на ACCEPT: + // до закладки план ещё может быть вытеснен коррекцией, и занимать слоты рано. + acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) } /** Гасит в SUPERSEDED ещё не заложенные планы КА, чьё окно пересекается с заложенным. */ @@ -247,9 +251,9 @@ class HandlePlanDecisionUseCase( // ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт // закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN // ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна. + // Статусы слотов тоже проставляются не здесь, а на закладке ([handleLayinConfirmed]). planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) - acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) } private fun rejectPlan( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 7e546bb..382f53a 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -61,7 +61,7 @@ class PlanIssueAndDecisionFlowTest { } @Test - fun `accepted decision moves plan to AWAITING_LAYIN and enqueues slots without anchoring base`() { + fun `accepted decision moves plan to AWAITING_LAYIN without anchoring base or touching slots`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) @@ -80,7 +80,8 @@ class PlanIssueAndDecisionFlowTest { // ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN. assertNull(state.currentBasePlanId) assertEquals(0, state.chainVersion) - verify(slotsUpdateJobService).enqueue(plan.planId) + // Слоты проставляются на закладке (LAID_IN), а не на ACCEPT. + verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test @@ -95,12 +96,13 @@ class PlanIssueAndDecisionFlowTest { val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 4) val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) val useCase = HandlePlanDecisionUseCase( processedPlanDecisionEventRepository = processedRepository, planIssueAttemptRepository = attemptRepository(linkedMapOf()), plannedPlanRepository = plannedRepository(store), spacecraftPlanningStateJpaRepository = stateRepository(state), - acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, planningProperties = planningProperties() ) val message = feedbackMessage(spacecraftId, laidIn.planId) @@ -113,6 +115,8 @@ class PlanIssueAndDecisionFlowTest { assertEquals(5, state.chainVersion) assertEquals(PlannedPlanStatus.SUPERSEDED, competitor.status) assertEquals(PlannedPlanStatus.AWAITING_LAYIN, unrelated.status) + // Слоты проставляются по факту закладки — ровно один раз (второй вызов идемпотентен). + verify(slotsUpdateJobService).enqueue(laidIn.planId) } @Test From 995396da3b9aecb2b672dda648e477cceb283118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Fri, 5 Jun 2026 14:51:59 +0300 Subject: [PATCH 63/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=9A=D0=BE=D0=BC=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=C2=BB=20+=20=D1=81=D0=BB=D0=BE=D0=B9=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка планирования «Комплексный план» (TguComplexPlanTab) и одноимённый слой на 2D-карте, выключенный по умолчанию. - TguComplexPlanTab + complexPlanApi (через прокси /api/pcp-complex, добавлен в vite.config.ts) — данные из pcp-complex-mission-service; - слой complexPlan: тип в mapLayerTypes/mapTypes/mapSelectionTypes, отрисовка drawComplexPlan, тоггл и подписи в Tgu2DMapTab/Tgu2DMapView; - вкладка complex в TguActiveTab, кнопка в TguToolbar, рендер в TguPlanningPage; complexPlan: false в дефолтах слоёв и в редакторе/ заявках/тесте интервала. --- .../pcp-tgu-ops-ui/src/api/complexPlanApi.ts | 139 +++++++++++++ .../tgu-complex-plan/TguComplexPlanTab.tsx | 182 ++++++++++++++++++ .../src/features/tgu-editor/TguEditorTab.tsx | 1 + .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 2 + .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 6 + .../tgu-map-2d/canvas/drawComplexPlan.ts | 61 ++++++ .../tgu-map-2d/model/mapLayerTypes.ts | 10 +- .../tgu-map-2d/model/mapSelectionTypes.ts | 2 +- .../src/features/tgu-map-2d/model/mapTypes.ts | 4 +- .../tgu-map-2d/tgu2DMapInterval.test.ts | 1 + .../features/tgu-planning/TguPlanningPage.tsx | 10 + .../src/features/tgu-planning/TguToolbar.tsx | 9 + .../features/tgu-requests/TguRequestsTab.tsx | 2 +- .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 2 +- services/pcp-tgu-ops-ui/vite.config.ts | 5 + 15 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts diff --git a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts new file mode 100644 index 0000000..e5acb13 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts @@ -0,0 +1,139 @@ +import { TguApiError } from "../model/tguTypes"; +import { toLocalIso } from "../utils/localTime"; + +/** + * Расчёт комплексного плана (этап 4) напрямую в pcp-complex-mission-service через Vite-прокси + * (`/api/pcp-complex` → :7002), как и остальные сервисы — без BFF. Это тот самый snapshot, + * который затем потребляет редактор (stage 5, `calculateSurveys`). + */ +const BASE = "/api/pcp-complex"; + +/** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */ +export type ComplexPlanRunStatus = "CREATED" | "RUNNING" | "COMPLETED" | "FAILED"; + +export type RunComplexPlanParams = { + /** Начало интервала расчёта (эпоха-мс, локальное). */ + fromMs: number; + /** Конец интервала расчёта (эпоха-мс, локальное). */ + toMs: number; + /** NORAD-id КА, по которым считаем (все эксплуатируемые КА вкладки «Планы»). */ + satelliteIds: number[]; +}; + +/** + * Ответ синхронного расчёта комплексного плана (ComplexPlanRunResponseDTO). + * Расчёт уже завершён к моменту ответа: пишет snapshot’ы этапа 4 и возвращает их id + статистику. + */ +export type ComplexPlanRunResult = { + runId: number; + status: ComplexPlanRunStatus; + intervalStart: string; + intervalEnd: string; + satelliteIds: number[]; + durationMs: number | null; + errorMessage: string | null; + calculatedModesCount: number; + bookedModesCount: number; + complanModesCount: number; + mixedModesCount: number; + affectedSatellitesCount: number; + processedCellsCount: number; + bookedSlotLinksCount: number; + snapshotIds: number[]; + totalRequestsArea: number; + coveredArea: number; + coveredAreaPercent: number; +}; + +/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */ +export type ComplexPlanMode = { + id: number; + satelliteId: number; + source: string; + type: "SURVEY" | "DROP" | string; + startTime: string; + endTime: string; + revolution: number; + lat: number; + longitude: number; + duration: number; + /** Контур полосы съёмки, WKT POLYGON (lon lat, ...). Для сбросов обычно отсутствует. */ + contourWkt: string | null; + roll: number; + cellNum: number; + bookedSlotIds: number[]; + snapshotId: number | null; +}; + +/** + * Запуск расчёта комплексного плана на интервал по списку КА + * (pcp-complex-mission-service, POST /api/com-plan/process). Вызов синхронный: возвращает + * завершённый run со статистикой и id записанных snapshot’ов. Бэк отклоняет запуск, если по + * одному из КА уже идёт пересекающийся расчёт. + */ +export async function runComplexPlan(params: RunComplexPlanParams): Promise { + const body = { + intervalStart: toLocalIso(params.fromMs), + intervalEnd: toLocalIso(params.toMs), + satelliteIds: params.satelliteIds + }; + + const url = `${BASE}/api/com-plan/process`; + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json", "X-Requested-By": "pcp-tgu-ops-ui" }, + body: JSON.stringify(body) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось рассчитать комплексный план.", "http", response.status); + } + if (!text.trim()) { + throw new TguApiError("Сервис комплексного плана вернул пустой ответ.", "http", response.status); + } + + return JSON.parse(text) as ComplexPlanRunResult; +} + +/** + * Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте + * (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes). + */ +export async function fetchComplexPlanRunModes(runId: number): Promise { + const url = `${BASE}/api/com-plan/runs/${encodeURIComponent(runId)}/modes`; + 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); + } + if (!text.trim()) return []; + return JSON.parse(text) as ComplexPlanMode[]; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx new file mode 100644 index 0000000..fd0725e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -0,0 +1,182 @@ +import { useMemo, useState } from "react"; +import { + fetchComplexPlanRunModes, + runComplexPlan, + type ComplexPlanMode, + type ComplexPlanRunResult +} from "../../api/complexPlanApi"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { DateTime24Field } from "../../components/timeFields"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { parseLocal, toLocalInputValue } from "../../utils/localTime"; + +type TguComplexPlanTabProps = { + range: TimelineRange; + /** Все эксплуатируемые КА вкладки «Планы» — по ним считаем комплексный план. */ + platforms: TguPlatformUi[]; +}; + +// Включён только выделенный слой «Комплексный план» (свой цвет, своя отрисовка с фиксом +// антимеридиана) — контуры не путаются с полосой обзора/работами плана других вкладок. +const COMPLEX_PLAN_LAYERS: Tgu2DMapLayersState = { + tracks: false, + swath: false, + planWorks: false, + complexPlan: true, + stations: false, + spacecraftMarkers: false, + requests: false +}; + +export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) { + const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(); + const [modes, setModes] = useState([]); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). + const satelliteIds = useMemo( + () => + Array.from( + new Set( + platforms + .map((platform) => Number(platform.spacecraftId)) + .filter((id) => Number.isFinite(id)) + ) + ), + [platforms] + ); + + const parsedRange = useMemo( + () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }), + [fromValue, toValue] + ); + const rangeInvalid = + !Number.isFinite(parsedRange.fromMs) || + !Number.isFinite(parsedRange.toMs) || + parsedRange.fromMs >= parsedRange.toMs; + + const scene = useMemo(() => ({ polygons: buildModePolygons(modes), lines: [], markers: [] }), [modes]); + + const runCalculation = async () => { + if (running || rangeInvalid || satelliteIds.length === 0) return; + setRunning(true); + setError(undefined); + setNotice(undefined); + try { + const runResult = await runComplexPlan({ + fromMs: parsedRange.fromMs, + toMs: parsedRange.toMs, + satelliteIds + }); + setResult(runResult); + const runModes = await fetchComplexPlanRunModes(runResult.runId); + setModes(runModes); + const contoursCount = runModes.filter((mode) => mode.contourWkt).length; + setNotice( + runResult.calculatedModesCount > 0 + ? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}. На карте — ${contoursCount} контуров.` + : "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками." + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось рассчитать комплексный план."); + } finally { + setRunning(false); + } + }; + + const canRun = !running && !rangeInvalid && satelliteIds.length > 0; + const overlayMessage = running + ? "Идёт расчёт…" + : rangeInvalid + ? "Некорректный интервал: «С» должно быть раньше «По»." + : modes.length === 0 + ? "Задайте интервал и нажмите «Рассчитать», чтобы построить комплексный план." + : undefined; + + return ( +
+
+
+
Комплексный план
+
+ Расчёт по {satelliteIds.length} КА вкладки «Планы» +
+
+ + + +
+ {result && ( +
+ run #{result.runId} · режимов {result.calculatedModesCount} · охват {formatPercent(result.coveredAreaPercent)} + {result.snapshotIds.length > 0 ? ` · snapshot ${result.snapshotIds.join(", ")}` : ""} +
+ )} +
+ +
+ {overlayMessage &&
{overlayMessage}
} + undefined} + /> +
+ +
+ {satelliteIds.length === 0 && ( +
+ Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен. +
+ )} + {notice &&
{notice}
} + {error &&
{error}
} +
+
+ ); +} + +/** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */ +function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { + const polygons: MapPolygon[] = []; + let zIndex = 0; + for (const mode of modes) { + if (!mode.contourWkt) continue; + const rings = parseWktPolygon(mode.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `complex-mode:${mode.id}:${zIndex}`, + name: `КА ${mode.satelliteId} · виток ${mode.revolution}`, + kind: "complexPlan", + layer: "complexPlan", + points: ring, + spacecraftId: String(mode.satelliteId), + modeId: mode.id, + zIndex: zIndex++ + }); + } + } + return polygons; +} + +function formatPercent(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "—"; + return `${value.toFixed(1)}%`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index e51ba7f..1998bc0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -85,6 +85,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { tracks: true, swath: true, planWorks: true, + complexPlan: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index 62db2bf..b02477a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -312,6 +312,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string { return "Полоса обзора"; case "planWorks": return "Работы плана"; + case "complexPlan": + return "Комплексный план"; case "stations": return "Станции"; case "spacecraftMarkers": diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 69d9515..ac200bb 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; +import { drawComplexPlan } from "./canvas/drawComplexPlan"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; import { drawStations } from "./canvas/drawStations"; @@ -198,6 +199,9 @@ export function Tgu2DMapView({ if (layers.swath) { drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size); } + if (layers.complexPlan) { + drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size); + } if (layers.planWorks) { drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); } @@ -597,6 +601,8 @@ function layerTitle(layer: MapPolygon["layer"]): string { return "Работы плана"; case "swath": return "Полоса обзора"; + case "complexPlan": + return "Комплексный план"; case "tracks": return "Трасса КА"; case "requests": diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts new file mode 100644 index 0000000..367baf2 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts @@ -0,0 +1,61 @@ +import type { MapProjection } from "../geometry/mapProjection"; +import type { MapPolygon } from "../model/mapTypes"; + +type Pt = { x: number; y: number }; + +/** + * «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на + * полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту. + * Та же логика, что в drawSwath/drawPlanWorks — фикс антимеридиана. + */ +function unwrapX(points: Pt[], worldSize: number): Pt[] { + if (points.length === 0) return []; + const out: Pt[] = [points[0]]; + let prevX = points[0].x; + for (let i = 1; i < points.length; i++) { + let x = points[i].x; + while (x - prevX > worldSize / 2) x -= worldSize; + while (prevX - x > worldSize / 2) x += worldSize; + out.push({ x, y: points[i].y }); + prevX = x; + } + return out; +} + +/** + * Контуры комплексного плана (этап 4). Отдельный слой со своим (янтарным) цветом, чтобы + * визуально не путать с «полосой обзора» (swath) и «работами плана» (planWorks). Рисуется + * в трёх копиях мира с отсечением по видимой области — как остальные полигональные слои. + */ +export function drawComplexPlan( + ctx: CanvasRenderingContext2D, + polygons: MapPolygon[], + projection: MapProjection, + viewport: { width: number; height: number } +) { + ctx.save(); + ctx.fillStyle = "rgba(240, 170, 70, 0.14)"; + ctx.strokeStyle = "rgba(240, 170, 70, 0.62)"; + ctx.lineWidth = 1.2; + for (const polygon of polygons) { + if (polygon.points.length < 3) continue; + const raw = polygon.points.map((point) => projection.project(point)); + const pts = unwrapX(raw, projection.worldSize); + for (const dx of [0, projection.worldSize, -projection.worldSize]) { + const visible = pts.some((point) => { + const x = point.x + dx; + return x >= -64 && x <= viewport.width + 64 && point.y >= -64 && point.y <= viewport.height + 64; + }); + if (!visible) continue; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y); + else ctx.lineTo(pts[i].x + dx, pts[i].y); + } + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts index 502cb80..6dff8ab 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -1,4 +1,11 @@ -export type Tgu2DMapLayerKey = "tracks" | "swath" | "planWorks" | "stations" | "spacecraftMarkers" | "requests"; +export type Tgu2DMapLayerKey = + | "tracks" + | "swath" + | "planWorks" + | "complexPlan" + | "stations" + | "spacecraftMarkers" + | "requests"; export type Tgu2DMapLayersState = Record; @@ -30,6 +37,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { tracks: false, swath: false, planWorks: true, + complexPlan: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts index 89f14d3..e2af403 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -5,7 +5,7 @@ export type Tgu2DMapSelection = type: "polygon"; id: string; name: string; - layer: "planWorks" | "swath" | "tracks" | "requests"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; planId?: string; spacecraftId?: string; requestId?: string; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index e7981e1..227bf17 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -24,8 +24,8 @@ export type MapViewState = { export type MapPolygon = { id: string; name: string; - kind: "planWork" | "swath" | "trackZone" | "request"; - layer: "planWorks" | "swath" | "tracks" | "requests"; + kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; points: GeoPoint[]; status?: TguPlanStatus; planId?: string; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 0732a54..1d5f154 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -51,6 +51,7 @@ describe("tgu2DMapInterval", () => { tracks: false, swath: false, planWorks: true, + complexPlan: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index f00b9c1..eccbe4b 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -9,6 +9,7 @@ import { } from "../../api/tguApi"; import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; +import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; @@ -564,6 +565,15 @@ export function TguPlanningPage() {
)} + {visitedTabs.has("complex") && ( +
+ +
+ )} + {visitedTabs.has("requests") && (
Карта + - ))} -
- )} +
{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}
{work.targetName &&
цель: {work.targetName}
} )} - {work.kind === "downlink" && !readOnly && ( -
-
Станция приёма
- -
- )} - - {!readOnly && ( + {(canEdit || !readOnly || canDelete) && (
- - -
- )} - - {readOnly && canDelete && ( -
- + {canEdit && ( + + )} + {(!readOnly || canDelete) && ( + + )}
)} @@ -303,6 +272,9 @@ export function ShootPanel({ routeLoading, routeError, route, + editing, + picking, + onPickTarget, addBlockedReason, onRouteStartChange, onRouteEndChange, @@ -312,32 +284,42 @@ export function ShootPanel({ onSelectPass, onModeChange, onAdd, - onClose, - onClearTarget + onClose }: ShootPanelProps) { return (
- +
1 · опорная точка прицеливания
- {target && ( - )}
- {!target ? ( + {target ? ( + <> +
+ + {target.name} + + {target.lat.toFixed(2)}, {target.lon.toFixed(2)} + +
+ {picking && ( +
+ Кликайте по карте, чтобы переставить точку. Выберите проход ниже, когда точка верна. +
+ )} + + ) : picking ? (
Кликните точку на карте слева, чтобы задать опорную точку прицеливания.
) : ( -
- - {target.name} - - {target.lat.toFixed(2)}, {target.lon.toFixed(2)} - -
+ )}
@@ -461,7 +443,7 @@ export function ShootPanel({ onClick={onAdd} disabled={!target || !selectedPass || !modeId || !!addBlockedReason} > - + Добавить съёмку в план + {editing ? "Сохранить съёмку" : "+ Добавить съёмку в план"}
); @@ -523,6 +505,7 @@ export function DropPanel({ onToggleSurvey, saving, saveError, + editing, addBlockedReason, onAdd, onClose @@ -531,7 +514,7 @@ export function DropPanel({ return (
- +
Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны.
@@ -601,7 +584,7 @@ export function DropPanel({ {saveError &&
{saveError}
}
); @@ -803,38 +786,6 @@ function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { ); } -function TimeStepper({ - label, - value, - min, - max, - onChange -}: { - label: string; - value: number; - min?: number; - max?: number; - onChange: (value: number) => void; -}) { - const change = (deltaMs: number) => { - const next = Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, value + deltaMs)); - onChange(next); - }; - - return ( - - ); -} - /** * Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной * ввод времени `чч:мм:сс` (локальное, без даты — дата берётся из текущего значения). diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 1998bc0..fd19361 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -30,11 +30,9 @@ import { addDownlink, addShoot, deleteWork, - duplicateWork, mutate, planModesToWorks, - seedDraft, - updateWork + seedDraft } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; @@ -52,7 +50,6 @@ import type { EditorVisibilityTrack, EditorVisibilityWindow, EditorWork, - EditorWorkPatch, TguEditorTabProps } from "./model/editorTypes"; @@ -114,7 +111,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, ); const [rightMode, setRightMode] = useState("none"); const [selectedWorkId, setSelectedWorkId] = useState(); + // id редактируемого включения: окно построения открыто для правки существующего маршрута, а не + // создания нового. При сохранении старый маршрут заменяется (удаляется в БД + локально). + const [editingWorkId, setEditingWorkId] = useState(); + // Параметры окна/крена, которыми нужно ОДНОКРАТНО предзаполнить окно построения съёмки при правке + // (иначе эффект дефолтного окна затёр бы их при установке прохода). Сбрасывается после применения. + const editShootPrefillRef = useRef<{ startMs: number; endMs: number; roll: number } | null>(null); + // Аналогично для сброса: предвыбранная зона + сбрасываемые съёмки, применяются после входа в режим. + const editDropPrefillRef = useRef<{ zone: RvaZone; surveyIds: number[] } | null>(null); const [target, setTarget] = useState(); + // Режим прицеливания: карта ждёт клик по опорной точке (перекрестие). Включается явно — кнопкой + // «Указать/Сменить точку», после одного клика гаснет. Иначе карта в обычном режиме («рука»), + // и клики не сбрасывают параметры маршрута. + const [targetPickActive, setTargetPickActive] = useState(false); const [selectedShootPass, setSelectedShootPass] = useState(); const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); const [rvaZones, setRvaZones] = useState([]); @@ -198,6 +207,23 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const selectedWork = displayWorks.find((work) => work.id === selectedWorkId); + // Редактируемое включение и id его режима в БД: на время правки прячем его и из сцены карты, и с + // таймлайна — иначе исходный контур виден одновременно с живым превью (карта 2, таймлайн 1). + const editingWork = useMemo( + () => (editingWorkId ? displayWorks.find((work) => work.id === editingWorkId) : undefined), + [editingWorkId, displayWorks] + ); + const editingModeId = editingWork?.planModeId; + // Включения для таймлайна: при правке исключаем редактируемое (его место занимает превью на карте). + const timelineWorks = useMemo( + () => (editingWorkId ? displayWorks.filter((work) => work.id !== editingWorkId) : displayWorks), + [displayWorks, editingWorkId] + ); + // Режимы для сцены карты: при правке исключаем контур редактируемого режима (показываем превью). + const sceneModes = useMemo( + () => (editingModeId != null ? planModes.filter((mode) => mode.id !== editingModeId) : planModes), + [planModes, editingModeId] + ); const activeLabel = platformLabel(platforms, selectedSpacecraftId); const [shootPasses, setShootPasses] = useState([]); const [shootPassesLoading, setShootPassesLoading] = useState(false); @@ -216,8 +242,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1)); const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1)); const mapScene = useMemo( - () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute), - [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute] + () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute), + [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute] ); // Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем. const focusWorkOnMap = useCallback( @@ -277,8 +303,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); setWindow(defaultEditorWindow(selectedPlan, appliedRange)); setSelectedWorkId(undefined); + setEditingWorkId(undefined); setRightMode("none"); setTarget(undefined); + setTargetPickActive(false); setSelectedShootPass(undefined); setSurveyRoute(undefined); setRouteError(undefined); @@ -386,6 +414,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRouteRoll(undefined); return; } + // Правка существующего маршрута: подставляем его окно/крен один раз, не центрируем по проходу. + const prefill = editShootPrefillRef.current; + if (prefill) { + editShootPrefillRef.current = null; + setRouteWindow({ startMs: prefill.startMs, endMs: prefill.endMs }); + setRouteRoll(Math.round(prefill.roll * 10) / 10); + return; + } const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs; const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2; const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; @@ -449,8 +485,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, let cancelled = false; setRvaLoading(true); setRvaError(undefined); - setSelectedZone(undefined); - setSelectedDropSurveyIds(new Set()); + // Правка существующего сброса: подставляем его зону и сбрасываемые съёмки один раз. + const prefill = editDropPrefillRef.current; + editDropPrefillRef.current = null; + if (prefill) { + setSelectedZone(prefill.zone); + setSelectedDropSurveyIds(new Set(prefill.surveyIds)); + } else { + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + } void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs) .then((zones) => { if (!cancelled) setRvaZones(zones); @@ -518,8 +562,57 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setApplyError(undefined); }; - const onChangeWork = (workId: string, patch: EditorWorkPatch) => { - changeDraft((current) => updateWork(current, workId, patch)); + // «Редактировать» включение: открываем то же окно, что и при построении, предзаполнив его + // параметрами включения. Сохранение заменит исходный маршрут (см. addShootWork/addDropWork). + const beginEditWork = (workId: string) => { + const work = displayWorks.find((item) => item.id === workId); + if (!work) return; + setEditingWorkId(workId); + setSelectedWorkId(undefined); + setMapInfoSelection(undefined); + clearMapPick(); + setNotice(undefined); + setApplyError(undefined); + + if (work.kind === "shoot") { + const lat = work.targetLat ?? work.centerLat; + const lon = work.targetLon ?? work.centerLon; + setTarget( + lat != null && lon != null + ? { id: `edit-${workId}`, name: work.targetName ?? "Цель съёмки", lat, lon } + : undefined + ); + // Окно/крен подставит эффект дефолтного окна по этому ref (иначе он бы их центрировал). + editShootPrefillRef.current = { startMs: work.startMs, endMs: work.endMs, roll: work.rollDeg ?? 0 }; + // Синтетический «проход» задаёт границы окна маршрута (±30 мин) и виток для пересчёта маршрута. + setSelectedShootPass({ + startMs: work.startMs, + endMs: work.endMs, + quality: 1, + revolution: work.revolution, + rollDeg: work.rollDeg + }); + setModeId(work.modeId ?? DEFAULT_EDITOR_MODES[0].id); + // Правка начинается без прицеливания: точка уже есть, карта в режиме «рука». + setTargetPickActive(false); + setRightMode("shoot"); + return; + } + + // Сброс: синтезируем зону из включения (для отображения и сохранения) и предвыбираем съёмки. + const stationNum = Number(work.stationId); + const midMs = (work.startMs + work.endMs) / 2; + const zone: RvaZone = { + noradId: noradId ? Number(noradId) : 0, + stationId: Number.isFinite(stationNum) ? stationNum : 0, + revolution: work.revolution ?? 0, + onStart: { time: toLocalIso(work.startMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + onMaximum: { time: toLocalIso(midMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + onStop: { time: toLocalIso(work.endMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + duration: Math.max(1, (work.endMs - work.startMs) / 1000) + }; + editDropPrefillRef.current = { zone, surveyIds: work.surveyIds ?? [] }; + setRightMode("downlink"); }; // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же @@ -557,9 +650,38 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, void revalidate(missionId, planModesToWorks(modes)); }, [revalidate]); + // Удаляет из черновика маршрут редактируемого включения (замена при правке). Свежепостроенное или + // уже-в-черновике включение адресуется по planModeId; включение из живого плана после клонирования + // получает новый id в черновике — находим его по типу/витку/времени старта. + const deleteEditedModeFromDraft = async ( + draftId: string, + draftExistedBefore: boolean, + work: EditorWork, + type: "SURVEY" | "DROP" + ) => { + if (work.planModeId != null && draftExistedBefore) { + await deleteMode(work.planModeId).catch(() => {}); + return; + } + const draftModes = await fetchPlanModes(draftId); + const match = draftModes.find( + (m) => + m.type === type && + m.revolution === work.revolution && + m.timeStart != null && + Math.abs(parseLocal(m.timeStart) - work.startMs) < 2000 + ); + if (match) await deleteMode(match.id).catch(() => {}); + }; + const addShootWork = async () => { if (!target || !selectedShootPass || !modeId) return; + // Правка существующего маршрута: его исходную версию удаляем при сохранении (замена). + const editId = editingWorkId; + const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined; + const draftExistedBefore = draftPlanId != null; + // Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. @@ -573,6 +695,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, try { // Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план. const draftId = await ensureDraft(); + // Заменяем: сносим исходный маршрут редактируемого включения перед сохранением нового. + if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "SURVEY"); savedRoute = await saveSurveyRoute(draftId, { satelliteId: numericNorad, time: toLocalIso(routeWindow.startMs), @@ -602,7 +726,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const id = nextWorkId(idCounterRef); changeDraft((current) => - addShoot(current, { + addShoot(editId ? deleteWork(current, editId) : current, { id, startMs, endMs, @@ -618,9 +742,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, centerLon: savedRoute?.longitude }) ); + setEditingWorkId(undefined); setSelectedWorkId(id); setRightMode("inspect"); setTarget(undefined); + setTargetPickActive(false); setSelectedShootPass(undefined); }; @@ -652,12 +778,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); + // Правка существующего сброса: его исходную версию удаляем при сохранении (замена). + const editId = editingWorkId; + const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined; + const draftExistedBefore = draftPlanId != null; + setDropSaving(true); setDropError(undefined); let savedDropId: number | undefined; try { // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. const draftId = await ensureDraft(); + // Заменяем: сносим исходный сброс редактируемого включения перед сохранением нового. + if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "DROP"); const savedDrop = await saveDropRoute(draftId, { satelliteId: numericNorad, station: String(selectedZone.stationId), @@ -683,7 +816,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const stationNm = stationNameById(selectedZone.stationId); const id = nextWorkId(idCounterRef); changeDraft((current) => - addDownlink(current, { + addDownlink(editId ? deleteWork(current, editId) : current, { id, startMs: zoneStartMsVal, endMs: zoneStopMsVal, @@ -692,6 +825,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planModeId: savedDropId }) ); + setEditingWorkId(undefined); setSelectedWorkId(id); setRightMode("inspect"); setSelectedZone(undefined); @@ -761,6 +895,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, await refreshDraftModes(draftId); changeDraft((current) => ({ ...current, works: [] })); setSelectedWorkId(undefined); + setEditingWorkId(undefined); setRightMode("none"); setNotice("План полностью очищен."); } catch (error) { @@ -794,6 +929,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } changeDraft((current) => deleteWork(current, workId)); setSelectedWorkId(undefined); + if (editingWorkId === workId) setEditingWorkId(undefined); setRightMode("none"); }; @@ -967,13 +1103,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, conflicts={conflicts[work.id] ?? []} readOnly={work.origin === "plan"} canDelete={draftPlanId != null} - onUpdate={onChangeWork} + canEdit={!inPlaceEditBlockedReason} + onEdit={beginEditWork} onDelete={(workId) => { void handleDeleteWork(workId); }} - onDuplicate={(workId) => { - const id = nextWorkId(idCounterRef); - changeDraft((current) => duplicateWork(current, workId, id)); - setSelectedWorkId(id); - }} onClose={onClose} /> ); @@ -1018,6 +1150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onSetRightMode={(mode) => { setRightMode(mode); setSelectedWorkId(undefined); + setEditingWorkId(undefined); + // Новая съёмка начинается с прицеливания (точки ещё нет); прочие режимы — без перекрестия. + setTargetPickActive(mode === "shoot"); + setTarget(undefined); setMapInfoSelection(undefined); clearMapPick(); setNotice(undefined); @@ -1061,7 +1197,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, style={{ gridTemplateRows: `${(mapRatio * 100).toFixed(1)}% minmax(0, 1fr)` }} >
- {rightMode === "shoot" &&
Кликните точку съёмки на карте.
} + {rightMode === "shoot" && targetPickActive &&
Кликните точку съёмки на карте.
} {orbitPasses.length > 0 ? ( { // Опорная точка прицеливания — всегда одна: каждый клик заменяет // предыдущее перекрестие, цели не накапливаются. @@ -1099,6 +1235,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedShootPass(undefined); setModeId(DEFAULT_EDITOR_MODES[0].id); setNotice(undefined); + // Прицеливание НЕ гасим: пока не выбран проход, точку можно свободно переставлять + // повторными кликами по карте. «Рука» включится при выборе строки прохождения. }} onObjectSelect={handleMapObjectSelect} focusPoint={mapFocus.point} @@ -1109,7 +1247,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setTargetPickActive(true)} addBlockedReason={inPlaceEditBlockedReason} onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))} onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))} @@ -1186,16 +1327,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current ) } - onSelectPass={setSelectedShootPass} + onSelectPass={(pass) => { + setSelectedShootPass(pass); + // Проход выбран — точка зафиксирована: выходим из прицеливания, карта снова «рука». + if (pass) setTargetPickActive(false); + }} onModeChange={setModeId} onAdd={addShootWork} onClose={() => { setRightMode("none"); + setEditingWorkId(undefined); setTarget(undefined); - setSelectedShootPass(undefined); - }} - onClearTarget={() => { - setTarget(undefined); + setTargetPickActive(false); setSelectedShootPass(undefined); }} /> @@ -1226,10 +1369,12 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } saving={dropSaving} saveError={dropError} + editing={editingWorkId != null} addBlockedReason={inPlaceEditBlockedReason} onAdd={addDropWork} onClose={() => { setRightMode("none"); + setEditingWorkId(undefined); setSelectedZone(undefined); setSelectedDropSurveyIds(new Set()); setDropError(undefined); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index 5c133e6..041f186 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -4,7 +4,6 @@ import { addDownlink, addShoot, deleteWork, - duplicateWork, mutate, planModesToWorks, redo, @@ -155,7 +154,7 @@ describe("editorDraft", () => { expect(redone.future).toHaveLength(0); }); - it("updates, duplicates and deletes works without generating ids internally", () => { + it("updates and deletes works without generating ids internally", () => { const draft = addDownlink(seedDraft(plan), { id: "downlink-1", label: "Сброс на НКПОР", @@ -164,17 +163,9 @@ describe("editorDraft", () => { station: { id: "KPP-1", name: "KPP-1" } }); const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 }); - const duplicated = duplicateWork(updated, "downlink-1", "copy-1", 120_000); - const deleted = deleteWork(duplicated, "downlink-1"); + const deleted = deleteWork(updated, "downlink-1"); expect(updated.works[0].label).toBe("Сеанс"); - expect(duplicated.works[1]).toMatchObject({ - id: "copy-1", - startMs: updated.works[0].startMs + 120_000, - endMs: updated.works[0].endMs + 120_000, - origin: "added", - isNew: true - }); - expect(deleted.works.map((work) => work.id)).toEqual(["copy-1"]); + expect(deleted.works.map((work) => work.id)).toEqual([]); }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 962f2fd..280cc32 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -12,7 +12,6 @@ import type { EditorWorkPatch } from "./model/editorTypes"; -const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000; const DEFAULT_HISTORY_LIMIT = 50; export type AddShootInput = { @@ -198,29 +197,6 @@ export function deleteWork(draft: Draft, workId: string): Draft { }; } -export function duplicateWork( - draft: Draft, - workId: string, - newId: string, - offsetMs = DEFAULT_DUPLICATE_OFFSET_MS -): Draft { - const source = draft.works.find((work) => work.id === workId); - if (!source) { - return draft; - } - - const duplicated: EditorWork = { - ...source, - id: newId, - startMs: source.startMs + offsetMs, - endMs: source.endMs + offsetMs, - origin: "added", - isNew: true - }; - - return { ...draft, works: [...draft.works, duplicated] }; -} - function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] { const rawWorks = (plan as TguPlanUi & { works?: unknown }).works; if (!Array.isArray(rawWorks)) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index f56a6dc..99b3765 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -48,6 +48,18 @@ export function formatDateTime(ms: number): string { return `${formatDate(ms)} ${formatTime(ms)}`; } +/** Время с секундами и одной десятой долей: ЧЧ:ММ:СС.с (локальное). */ +export function formatTimeSeconds(ms: number): string { + const date = new Date(ms); + const tenths = Math.floor((((ms % 1000) + 1000) % 1000) / 100); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${tenths}`; +} + +/** Дата и время с десятыми долями секунды: «дд мес ЧЧ:ММ:СС.с» (локальное). */ +export function formatDateTimeSeconds(ms: number): string { + return `${formatDate(ms)} ${formatTimeSeconds(ms)}`; +} + export function formatDuration(ms: number): string { const totalSeconds = Math.max(0, Math.round(ms / 1000)); if (totalSeconds < 60) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index eccbe4b..4230d3f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -220,9 +220,11 @@ export function TguPlanningPage() { // Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели. // planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор. + // Зависим и от activeTab: при возврате на вкладку «Планы» перечитываем состав, иначе + // счётчик маршрутов остаётся прежним после правок в редакторе (selectedPlanId не менялся). const [planModeCounts, setPlanModeCounts] = useState(); useEffect(() => { - if (!selectedPlanId) { + if (!selectedPlanId || activeTab !== "timeline") { setPlanModeCounts(undefined); return; } @@ -242,7 +244,7 @@ export function TguPlanningPage() { return () => { cancelled = true; }; - }, [selectedPlanId]); + }, [selectedPlanId, activeTab]); const applyRange = () => { setDecisionNotice(undefined); diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index f64d496..bf9e6de 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -863,10 +863,16 @@ .tgu-editor-panel__actions { display: flex; align-items: center; - justify-content: space-between; + flex-wrap: wrap; gap: 0.6rem; } +.tgu-editor-panel__actions > .tgu-button { + flex: 1 1 0; + min-width: 0; + white-space: nowrap; +} + .tgu-editor-panel__head h2 { margin: 0; font-size: 0.95rem; From 821de2b0c027880b44e3527c58ba0fc9b3b0660b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Fri, 5 Jun 2026 23:37:47 +0300 Subject: [PATCH 65/79] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=93=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=C2=BB=20(=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8)=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Встраивание дизайн-хэндоффа constellation dashboard отдельной вкладкой рядом с Планы/Карта/Комплексный план/Редактор/Заявки. - модуль src/features/constellation/ (прототип на JSX + realData.ts/ConstellationTab.tsx) - реальные данные вместо симуляции: платформы (OPERATIONAL), станции, заявки→ROI (WKT), трасса flight-line как эфемерида (сэмплер позиции через env.subSat), ЗРВ→сброс, съёмка по пролёту над ROI; оптика/локация — по имени КА из НСИ - d3-geo + topojson-client; карта world-atlas локально (без CDN) - изоляция стилей на .cdash (конфликт --line с theme.css), высота .app=100% - локальное время в часах и оси таймлайна (конвенция проекта) - allowJs:true в tsconfig.app.json под JSX-модуль --- services/pcp-tgu-ops-ui/package-lock.json | 50 +++ services/pcp-tgu-ops-ui/package.json | 2 + .../constellation/ConstellationDashboard.jsx | 217 +++++++++++ .../constellation/ConstellationTab.tsx | 75 ++++ .../src/features/constellation/MapView.jsx | 340 ++++++++++++++++++ .../src/features/constellation/Panels.jsx | 182 ++++++++++ .../src/features/constellation/Timeline.jsx | 160 +++++++++ .../constellation/assets/land-110m.json | 1 + .../src/features/constellation/dashboard.css | 184 ++++++++++ .../src/features/constellation/data.js | 73 ++++ .../src/features/constellation/index.js | 10 + .../src/features/constellation/realData.ts | 270 ++++++++++++++ .../src/features/constellation/sim.js | 164 +++++++++ .../src/features/constellation/util.js | 9 + .../features/tgu-planning/TguPlanningPage.tsx | 7 + .../src/features/tgu-planning/TguToolbar.tsx | 9 + .../pcp-tgu-ops-ui/src/model/timelineTypes.ts | 2 +- services/pcp-tgu-ops-ui/tsconfig.app.json | 2 +- 18 files changed, 1755 insertions(+), 2 deletions(-) create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/data.js create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/index.js create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/realData.ts create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/sim.js create mode 100644 services/pcp-tgu-ops-ui/src/features/constellation/util.js diff --git a/services/pcp-tgu-ops-ui/package-lock.json b/services/pcp-tgu-ops-ui/package-lock.json index c0cd25c..01facb3 100644 --- a/services/pcp-tgu-ops-ui/package-lock.json +++ b/services/pcp-tgu-ops-ui/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "@vitejs/plugin-react": "^5.1.1", + "d3-geo": "^3.1.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "topojson-client": "^3.1.0", "typescript": "^5.9.3", "vite": "^7.2.4" }, @@ -1513,6 +1515,11 @@ "node": ">=18" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1561,6 +1568,28 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", @@ -1769,6 +1798,14 @@ "node": ">= 14" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2211,6 +2248,19 @@ "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==", "dev": true }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/services/pcp-tgu-ops-ui/package.json b/services/pcp-tgu-ops-ui/package.json index 8dde952..2f18c72 100644 --- a/services/pcp-tgu-ops-ui/package.json +++ b/services/pcp-tgu-ops-ui/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@vitejs/plugin-react": "^5.1.1", + "d3-geo": "^3.1.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "topojson-client": "^3.1.0", "typescript": "^5.9.3", "vite": "^7.2.4" }, diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx new file mode 100644 index 0000000..887010c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx @@ -0,0 +1,217 @@ +/* ============================================================ + ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM) + Default export: — монтируется как вкладка. + ============================================================ */ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import MapView from "./MapView.jsx"; +import Timeline from "./Timeline.jsx"; +import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +import * as SIM from "./sim.js"; +import { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; +import "./dashboard.css"; + +function buildEnv() { + const epochMs = Date.now(); + const sats = SATELLITES, stations = STATIONS, rois = ROIS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +// Без пропсов — встроенная симуляция (buildEnv). С пропсами env/domain/sched — +// реальные данные из API (см. ConstellationTab + realData.ts, INTEGRATION.md §5). +// Обёртка монтирует компонент только когда данные готовы, поэтому cfg стабилен +// от первого рендера и mount-once замыкания RAF/apiRef остаются валидными. +function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) { + const cfg = useMemo( + () => (extEnv ? { env: extEnv, domain: extDomain, sched: extSched } : buildEnv()), + [extEnv, extDomain, extSched] + ); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +export default App; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx new file mode 100644 index 0000000..8e26e85 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx @@ -0,0 +1,75 @@ +/* ============================================================ + ConstellationTab.tsx — обёртка вкладки «Группировка» с реальными данными. + Грузит состав/геометрию/расписание из API (realData.ts) и монтирует + прототипный ConstellationDashboard уже с готовыми env/domain/sched — поэтому + его mount-once замыкания (RAF/canvas) видят стабильные данные с первого рендера. + ============================================================ */ +import { useEffect, useState, type ComponentType } from "react"; +import ConstellationDashboardImpl from "./ConstellationDashboard.jsx"; +import { loadConstellationEnv, type ConstellationConfig } from "./realData"; +import "./dashboard.css"; + +// ConstellationDashboard — JS-модуль прототипа; типизируем точку монтирования. +const ConstellationDashboard = ConstellationDashboardImpl as ComponentType; + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; cfg: ConstellationConfig }; + +export function ConstellationTab() { + const [state, setState] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ kind: "loading" }); + loadConstellationEnv() + .then((cfg) => { + if (!cancelled) setState({ kind: "ready", cfg }); + }) + .catch((error: unknown) => { + if (!cancelled) { + setState({ kind: "error", message: error instanceof Error ? error.message : "Не удалось загрузить данные группировки." }); + } + }); + return () => { + cancelled = true; + }; + }, []); + + if (state.kind === "loading") { + return ; + } + if (state.kind === "error") { + return ; + } + if (state.cfg.env.sats.length === 0) { + return ; + } + return ; +} + +function ConstellationNotice({ text, tone }: { text: string; tone?: "error" }) { + return ( +
+ {text} +
+ ); +} + +export default ConstellationTab; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx new file mode 100644 index 0000000..ec8512d --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx @@ -0,0 +1,340 @@ +/* ============================================================ + MapView.jsx — canvas world map (ESM, default export) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +import React, { useRef, useEffect } from "react"; +import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo"; +import { feature } from "topojson-client"; +import * as SIM from "./sim.js"; +import { hexA } from "./util.js"; +// Карта мира раздаётся со своего origin (Vite ?url), без рантайм-зависимости от CDN +// (см. INTEGRATION.md §3). Файл: assets/land-110m.json (world-atlas@2). +import landUrl from "./assets/land-110m.json?url"; + +// keep the original call-sites (d3.* / topojson.*) working unchanged +const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle }; +const topojson = { feature }; + +// Подспутниковая точка: реальный сэмплер env.subSat (flight-line) или встроенная +// симуляция SIM.subSat. См. INTEGRATION.md §5.2. +function subSatOf(env, sat, tMs) { + return env.subSat ? env.subSat(sat, tMs) : SIM.subSat(sat, tMs, env.epochMs); +} + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch(landUrl) + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = subSatOf(env, sat, tMs + dm * 60000); + if (p) pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = subSatOf(env, sat, tMs + dm * 60000); + if (q) seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = subSatOf(env, sat, apiRef.current.simTime()); + const pp = p && s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +export default MapView; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx new file mode 100644 index 0000000..b1e86ea --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx @@ -0,0 +1,182 @@ +/* ============================================================ + Panels.jsx — header + left panel (ESM) + Exports: Header, SidePanel, STATUS_META, fmtDur. + ============================================================ */ +import React from "react"; +import { COL } from "./data.js"; + +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +// Время в проекте — локальное настенное (см. память project_tgu_ui_utc): часы, планы +// и flight-line идут в зоне браузера без UTC-семантики. Поэтому local-геттеры, не UTC. +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`, + date: `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + местн. +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +export { Header, SidePanel, STATUS_META, fmtDur }; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx new file mode 100644 index 0000000..c83e318 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + Timeline.jsx — bottom event timeline (ESM, default export) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react"; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getHours()).padStart(2, "0"); + const isDay = d.getHours() === 0; + return ( + + + + {isDay ? `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +export default Timeline; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json b/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json new file mode 100644 index 0000000..fbd897c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json @@ -0,0 +1 @@ +{"type":"Topology","objects":{"land":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0]],[[1]],[[2]],[[3]],[[4]],[[5]],[[6]],[[7]],[[8]],[[9]],[[10]],[[11]],[[12]],[[13]],[[14]],[[15]],[[16,18]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[48]],[[49]],[[50]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[58]],[[59]],[[60]],[[61]],[[62]],[[63]],[[64]],[[65]],[[66]],[[67]],[[68]],[[69]],[[70]],[[71]],[[72]],[[73]],[[74]],[[75]],[[76]],[[77]],[[78]],[[79]],[[80]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]],[[90]],[[91]],[[92,115],[114]],[[94]],[[95]],[[96]],[[97]],[[98]],[[99]],[[100]],[[101]],[[102]],[[103]],[[104]],[[105]],[[106]],[[107]],[[108]],[[109]],[[110]],[[111]],[[112]],[[113]],[[116]],[[117]],[[118]],[[119]],[[120]],[[121]],[[122]],[[123]],[[124]],[[125]],[[126]],[[127]],[[128]],[[129]]]}]}},"arcs":[[[33452,3290],[-82,-301],[-81,-266],[-582,81],[-621,-35],[-348,197],[0,23],[-152,174],[625,-23],[599,-58],[207,243],[147,208],[288,-243]],[[5775,3611],[-533,-81],[-364,208],[-163,209],[-11,35],[-180,162],[169,220],[517,-93],[277,-185],[212,-209],[76,-266]],[[37457,4468],[342,-255],[120,-359],[33,-254],[11,-301],[-430,-186],[-452,-150],[-522,-139],[-582,-116],[-658,35],[-365,197],[49,243],[593,162],[239,197],[174,254],[126,220],[168,209],[180,243],[141,0],[414,127],[419,-127]],[[16330,7154],[359,-93],[332,104],[-158,-208],[-261,-151],[-386,47],[-278,208],[60,197],[332,-104]],[[15122,7165],[425,-231],[-164,23],[-359,58],[-381,162],[202,127],[277,-139]],[[22505,8080],[305,-81],[304,69],[163,-335],[-217,46],[-337,-23],[-343,23],[-376,-35],[-283,116],[-146,243],[174,104],[353,-81],[403,-46]],[[30985,8657],[33,-266],[-49,-231],[-76,-220],[-326,-81],[-311,-116],[-364,11],[136,232],[-327,-81],[-310,-81],[-212,174],[-16,243],[305,231],[190,70],[321,-23],[82,301],[16,219],[-6,475],[158,278],[256,93],[147,-220],[65,-220],[120,-267],[92,-254],[76,-267]],[[0,529],[16,-5],[245,344],[501,-185],[32,21],[78,49],[94,61],[81,52],[41,26],[41,-1],[29,-10],[402,-246],[352,246],[63,34],[816,104],[265,-138],[130,-71],[419,-196],[789,-151],[625,-185],[1072,-139],[800,162],[1181,-116],[669,-185],[734,174],[773,162],[60,278],[-1094,23],[-898,139],[-234,231],[-745,128],[49,266],[103,243],[104,220],[-55,243],[-462,162],[-212,209],[-430,185],[675,-35],[642,93],[402,-197],[495,173],[457,220],[223,197],[-98,243],[-359,162],[-408,174],[-571,35],[-500,81],[-539,58],[-180,220],[-359,185],[-217,208],[-87,672],[136,-58],[250,-185],[457,58],[441,81],[228,-255],[441,58],[370,127],[348,162],[315,197],[419,58],[-11,220],[-97,220],[81,208],[359,104],[163,-196],[425,115],[321,151],[397,12],[375,57],[376,139],[299,128],[337,127],[218,-35],[190,-46],[414,81],[370,-104],[381,11],[364,81],[375,-57],[414,-58],[386,23],[403,-12],[413,-11],[381,23],[283,174],[337,92],[349,-127],[331,104],[300,208],[179,-185],[98,-208],[180,-197],[288,174],[332,-220],[375,-70],[321,-162],[392,35],[354,104],[418,-23],[376,-81],[381,-104],[147,254],[-180,197],[-136,209],[-359,46],[-158,220],[-60,220],[-98,440],[213,-81],[364,-35],[359,35],[327,-93],[283,-174],[119,-208],[376,-35],[359,81],[381,116],[342,70],[283,-139],[370,46],[239,451],[224,-266],[321,-104],[348,58],[228,-232],[365,-23],[337,-69],[332,-128],[218,220],[108,209],[278,-232],[381,58],[283,-127],[190,-197],[370,58],[288,127],[283,151],[337,81],[392,69],[354,81],[272,127],[163,186],[65,254],[-32,244],[-87,231],[-98,232],[-87,231],[-71,209],[-16,231],[27,232],[130,220],[109,243],[44,231],[-55,255],[-32,232],[136,266],[152,173],[180,220],[190,186],[223,173],[109,255],[152,162],[174,151],[267,34],[174,186],[196,115],[228,70],[202,150],[157,186],[218,69],[163,-151],[-103,-196],[-283,-174],[-120,-127],[-206,92],[-229,-58],[-190,-139],[-202,-150],[-136,-174],[-38,-231],[17,-220],[130,-197],[-190,-139],[-261,-46],[-153,-197],[-163,-185],[-174,-255],[-44,-220],[98,-243],[147,-185],[229,-139],[212,-185],[114,-232],[60,-220],[82,-232],[130,-196],[82,-220],[38,-544],[81,-220],[22,-232],[87,-231],[-38,-313],[-152,-243],[-163,-197],[-370,-81],[-125,-208],[-169,-197],[-419,-220],[-370,-93],[-348,-127],[-376,-128],[-223,-243],[-446,-23],[-489,23],[-441,-46],[-468,0],[87,-232],[424,-104],[311,-162],[174,-208],[-310,-185],[-479,58],[-397,-151],[-17,-243],[-11,-232],[327,-196],[60,-220],[353,-220],[588,-93],[500,-162],[398,-185],[506,-186],[690,-92],[681,-162],[473,-174],[517,-197],[272,-278],[136,-220],[337,209],[457,173],[484,186],[577,150],[495,162],[691,12],[680,-81],[560,-139],[180,255],[386,173],[702,12],[550,127],[522,128],[577,81],[614,104],[430,150],[-196,209],[-119,208],[0,220],[-539,-23],[-571,-93],[-544,0],[-77,220],[39,440],[125,128],[397,138],[468,139],[337,174],[337,174],[251,231],[380,104],[376,81],[190,47],[430,23],[408,81],[343,116],[337,139],[305,139],[386,185],[245,197],[261,173],[82,232],[-294,139],[98,243],[185,185],[288,116],[305,139],[283,185],[217,232],[136,277],[202,163],[331,-35],[136,-197],[332,-23],[11,220],[142,231],[299,-58],[71,-220],[331,-34],[360,104],[348,69],[315,-34],[120,-243],[305,196],[283,105],[315,81],[310,81],[283,139],[310,92],[240,128],[168,208],[207,-151],[288,81],[202,-277],[157,-209],[316,116],[125,232],[283,162],[365,-35],[108,-220],[229,220],[299,69],[326,23],[294,-11],[310,-70],[300,-34],[130,-197],[180,-174],[304,104],[327,24],[315,0],[310,11],[278,81],[294,70],[245,162],[261,104],[283,58],[212,162],[152,324],[158,197],[288,-93],[109,-208],[239,-139],[289,46],[196,-208],[206,-151],[283,139],[98,255],[250,104],[289,197],[272,81],[326,116],[218,127],[228,139],[218,127],[261,-69],[250,208],[180,162],[261,-11],[229,139],[54,208],[234,162],[228,116],[278,93],[256,46],[244,-35],[262,-58],[223,-162],[27,-254],[245,-197],[168,-162],[332,-70],[185,-162],[229,-162],[266,-35],[223,116],[240,243],[261,-127],[272,-70],[261,-69],[272,-46],[277,0],[229,-614],[-11,-150],[-33,-267],[-266,-150],[-218,-220],[38,-232],[310,12],[-38,-232],[-141,-220],[-131,-243],[212,-185],[321,-58],[321,104],[153,232],[92,220],[153,185],[174,174],[70,208],[147,289],[174,58],[316,24],[277,69],[283,93],[136,231],[82,220],[190,220],[272,151],[234,115],[153,197],[157,104],[202,93],[277,-58],[250,58],[272,69],[305,-34],[201,162],[142,393],[103,-162],[131,-278],[234,-115],[266,-47],[267,70],[283,-46],[261,-12],[174,58],[234,-35],[212,-127],[250,81],[300,0],[255,81],[289,-81],[185,197],[141,196],[191,163],[348,439],[179,-81],[212,-162],[185,-208],[354,-359],[272,-12],[256,0],[299,70],[299,81],[229,162],[190,174],[310,23],[207,127],[218,-116],[141,-185],[196,-185],[305,23],[190,-150],[332,-151],[348,-58],[288,47],[218,185],[185,185],[250,46],[251,-81],[288,-58],[261,93],[250,0],[245,-58],[256,-58],[250,104],[299,93],[283,23],[316,0],[255,58],[251,46],[76,290],[11,243],[174,-162],[49,-266],[92,-244],[115,-196],[234,-105],[315,35],[365,12],[250,35],[364,0],[262,11],[364,-23],[310,-46],[196,-186],[-54,-220],[179,-173],[299,-139],[310,-151],[360,-104],[375,-92],[283,-93],[315,-12],[180,197],[245,-162],[212,-185],[245,-139],[337,-58],[321,-69],[136,-232],[316,-139],[212,-208],[310,-93],[321,12],[299,-35],[332,12],[332,-47],[310,-81],[288,-139],[289,-116],[195,-173],[-32,-232],[-147,-208],[-125,-266],[-98,-209],[-131,-243],[-364,-93],[-163,-208],[-360,-127],[-125,-232],[-190,-220],[-201,-185],[-115,-243],[-70,-220],[-28,-266],[6,-220],[158,-232],[60,-220],[130,-208],[517,-81],[109,-255],[-501,-93],[-424,-127],[-528,-23],[-234,-336],[-49,-278],[-119,-220],[-147,-220],[370,-196],[141,-244],[239,-219],[338,-197],[386,-186],[419,-185],[636,-185],[142,-289],[800,-128],[53,-45],[208,-175],[767,151],[636,-186],[-99520,-142]],[[31180,18764],[361,-355],[389,-147],[-125,-296],[-264,-29],[-141,208],[-92,-239],[-238,-183],[-301,67],[-202,177],[-291,86],[-350,330],[-283,317],[-383,662],[229,-124],[390,-395],[369,-212],[143,271],[90,405],[256,244],[198,-70],[106,-274],[139,-443]],[[33736,20389],[222,-266],[-83,-207],[-375,-177],[-125,207],[-236,-266],[-139,266],[333,354],[236,-148],[167,237]],[[69522,21210],[-427,-38],[-7,314],[41,244],[19,121],[179,-186],[263,-74],[9,-112],[-77,-269]],[[90387,26479],[269,-204],[151,81],[217,113],[166,-39],[20,-702],[-95,-203],[-29,-476],[-97,162],[-193,-412],[-57,32],[-171,19],[-171,505],[-38,390],[-160,515],[7,271],[181,-52]],[[98060,26404],[63,-244],[198,239],[80,-249],[0,-249],[-103,-274],[-182,-435],[-142,-238],[103,-284],[-214,-7],[-238,-223],[-75,-387],[-157,-597],[-219,-264],[-138,-169],[-256,13],[-180,194],[-302,42],[-46,217],[149,438],[349,583],[179,111],[200,225],[238,310],[167,306],[123,441],[106,149],[41,330],[195,273],[61,-251]],[[98502,29218],[202,-622],[5,403],[126,-161],[41,-447],[224,-192],[188,-48],[158,226],[141,-69],[-67,-524],[-85,-345],[-212,12],[-74,-179],[26,-254],[-41,-110],[-105,-319],[-138,-404],[-214,-236],[-48,155],[-116,85],[160,486],[-91,326],[-299,236],[8,214],[201,206],[47,455],[-13,382],[-113,396],[8,104],[-133,244],[-218,523],[-117,418],[104,46],[151,-328],[216,-153],[78,-526]],[[96421,37487],[-105,-142],[-153,160],[-199,266],[-179,313],[-184,416],[-38,201],[119,-9],[156,-201],[122,-200],[89,-166],[228,-366],[144,-272]],[[99547,40335],[96,-171],[-46,-308],[-172,-81],[-153,73],[-27,260],[107,203],[126,-74],[69,98]],[[0,40798],[99822,-145],[-177,-124],[-36,220],[139,121],[88,33],[-99836,184]],[[0,41087],[0,-289]],[[0,41087],[57,27],[-34,-284],[-23,-32]],[[96623,40851],[-92,-78],[-93,259],[10,158],[175,-339]],[[96418,41756],[45,-476],[-75,74],[-58,-32],[-39,163],[-6,453],[133,-182]],[[63904,42571],[45,-711],[72,-276],[-28,-284],[-49,-174],[-94,347],[-53,-175],[53,-438],[-24,-250],[-77,-137],[-18,-500],[-109,-689],[-137,-814],[-172,-1120],[-106,-821],[-125,-685],[-226,-140],[-243,-250],[-160,151],[-220,211],[-77,312],[-18,524],[-98,471],[-26,425],[50,426],[128,102],[1,197],[133,447],[25,377],[-65,280],[-52,372],[-23,544],[97,331],[38,375],[138,22],[155,121],[103,107],[122,7],[158,337],[229,364],[83,297],[-38,253],[118,-71],[153,410],[6,356],[92,264],[96,-254],[74,-251],[69,-390]],[[89877,42448],[100,-464],[179,223],[92,-250],[133,-231],[-29,-262],[60,-506],[42,-295],[70,-72],[75,-505],[-27,-307],[90,-400],[301,-309],[197,-281],[186,-257],[-37,-143],[159,-371],[108,-639],[111,130],[113,-256],[68,91],[48,-626],[197,-363],[129,-226],[217,-478],[78,-475],[7,-337],[-19,-365],[132,-502],[-16,-523],[-48,-274],[-75,-527],[6,-339],[-55,-423],[-123,-538],[-205,-290],[-102,-458],[-93,-292],[-82,-510],[-107,-294],[-70,-442],[-36,-407],[14,-187],[-159,-205],[-311,-22],[-257,-242],[-127,-229],[-168,-254],[-230,262],[-170,104],[43,308],[-152,-112],[-243,-428],[-240,160],[-158,94],[-159,42],[-269,171],[-179,364],[-52,449],[-64,298],[-137,240],[-267,71],[91,287],[-67,438],[-136,-408],[-247,-109],[146,327],[42,341],[107,289],[-22,438],[-226,-504],[-174,-202],[-106,-470],[-217,243],[9,313],[-174,429],[-147,221],[52,137],[-356,358],[-195,17],[-267,287],[-498,-56],[-359,-211],[-317,-197],[-265,39],[-294,-303],[-241,-137],[-53,-309],[-103,-240],[-236,-15],[-174,-52],[-246,107],[-199,-64],[-191,-27],[-165,-315],[-81,26],[-140,-167],[-133,-187],[-203,23],[-186,0],[-295,377],[-149,113],[6,338],[138,81],[47,134],[-10,212],[34,411],[-31,350],[-147,598],[-45,337],[12,336],[-111,385],[-7,174],[-123,235],[-35,463],[-158,467],[-39,252],[122,-255],[-93,548],[137,-171],[83,-229],[-5,303],[-138,465],[-26,186],[-65,177],[31,341],[56,146],[38,295],[-29,346],[114,425],[21,-450],[118,406],[225,198],[136,252],[212,217],[126,46],[77,-73],[219,220],[168,66],[42,129],[74,54],[153,-14],[292,173],[151,262],[71,316],[163,300],[13,236],[7,321],[194,502],[117,-510],[119,118],[-99,279],[87,287],[122,-128],[34,449],[152,291],[67,233],[140,101],[4,165],[122,-69],[5,148],[122,85],[134,80],[205,-271],[155,-350],[173,-4],[177,-56],[-59,325],[133,473],[126,155],[-44,147],[121,338],[168,208],[142,-70],[234,111],[-5,302],[-204,195],[148,86],[184,-147],[148,-242],[234,-151],[79,60],[172,-182],[162,169],[105,-51],[65,113],[127,-292],[-74,-316],[-105,-239],[-96,-20],[32,-236],[-81,-295],[-99,-291],[20,-166],[221,-327],[214,-189],[143,-204],[201,-350],[78,1],[145,-151],[43,-183],[265,-200],[183,202],[55,317],[56,262],[34,324],[85,470],[-39,286],[20,171],[-32,339],[37,445],[53,120],[-43,197],[67,313],[52,325],[7,168],[104,222],[78,-289],[19,-371],[70,-71],[11,-249],[101,-300],[21,-335],[-10,-214]],[[95032,44386],[78,-203],[-194,4],[-106,363],[166,-142],[56,-22]],[[83531,44530],[-117,-11],[-368,414],[259,116],[146,-180],[97,-180],[-17,-159]],[[94680,44747],[-108,-14],[-170,60],[-58,91],[17,235],[183,-93],[91,-124],[45,-155]],[[94910,44908],[-42,-109],[-206,512],[-57,353],[94,0],[100,-473],[111,-283]],[[84565,44589],[-238,-130],[-33,71],[25,201],[119,360],[275,235],[32,139],[239,133],[194,20],[87,74],[105,-74],[-102,-160],[-289,-258],[-233,-170],[-181,-441]],[[82749,45797],[100,-158],[172,48],[69,-251],[-321,-119],[-193,-79],[-149,5],[95,340],[153,5],[74,209]],[[84139,45797],[-41,-328],[-417,-168],[-370,73],[0,216],[220,123],[174,-177],[185,45],[249,216]],[[94409,45654],[12,-119],[-218,251],[-152,212],[-104,197],[41,60],[128,-142],[228,-272],[65,-187]],[[93760,46238],[-56,-33],[-121,134],[-114,243],[14,99],[166,-250],[111,-193]],[[80172,46575],[533,-59],[61,244],[515,-284],[101,-383],[417,-108],[341,-351],[-317,-225],[-306,238],[-251,-16],[-288,44],[-260,106],[-322,225],[-204,59],[-116,-74],[-506,243],[-48,254],[-255,44],[191,564],[337,-35],[224,-231],[115,-45],[38,-210]],[[87423,46908],[-143,-402],[-27,445],[49,212],[58,200],[63,-173],[0,-282]],[[93299,46550],[-78,-59],[-120,227],[-122,375],[-59,450],[38,57],[30,-175],[84,-134],[135,-375],[131,-200],[-39,-166]],[[92217,47343],[-146,-48],[-44,-166],[-152,-144],[-142,-138],[-148,1],[-228,171],[-158,165],[23,183],[249,-86],[152,46],[42,283],[40,15],[27,-314],[158,45],[78,202],[155,211],[-30,348],[166,11],[56,-97],[-5,-327],[-93,-361]],[[85346,48536],[-104,-196],[-192,108],[-54,254],[281,29],[69,-195]],[[86241,48752],[101,-452],[-234,244],[-232,49],[-157,-39],[-192,21],[65,325],[344,24],[305,-172]],[[92538,47921],[-87,-157],[-52,348],[-65,229],[-126,193],[-158,252],[-200,174],[77,143],[150,-166],[94,-130],[117,-142],[111,-248],[106,-189],[33,-307]],[[87261,49899],[78,-955],[287,-354],[232,627],[319,356],[247,1],[238,-206],[206,-212],[298,-113],[482,-407],[513,-338],[192,-302],[154,-297],[43,-349],[462,-365],[68,-313],[-256,-64],[62,-393],[248,-388],[180,-627],[159,20],[-11,-262],[215,-100],[-84,-111],[295,-249],[-30,-171],[-184,-41],[-69,153],[-238,66],[-281,89],[-216,377],[-158,325],[-144,517],[-362,259],[-235,-169],[-170,-195],[35,-436],[-218,-203],[-155,99],[-288,25],[-247,485],[-282,118],[-69,-168],[-352,-18],[118,481],[175,164],[-72,642],[-134,496],[-538,500],[-229,50],[-417,546],[-82,-287],[-107,-52],[-63,216],[-1,257],[-212,290],[299,213],[198,-11],[-23,156],[-407,1],[-110,352],[-248,109],[-117,293],[374,143],[142,192],[446,-242],[44,-220]],[[84788,51419],[-223,-587],[-209,-113],[-267,115],[-463,-29],[-243,-85],[-39,-447],[248,-526],[150,268],[518,201],[-22,-272],[-121,86],[-121,-347],[-245,-229],[263,-757],[-50,-203],[249,-682],[-2,-388],[-148,-173],[-109,207],[134,484],[-273,-229],[-69,164],[36,228],[-200,346],[21,576],[-186,-179],[24,-689],[11,-846],[-176,-85],[-119,173],[79,544],[-43,570],[-117,4],[-86,405],[115,387],[40,469],[139,891],[58,243],[237,439],[217,-174],[350,-82],[319,25],[275,429],[48,-132]],[[85746,51249],[-15,-517],[-143,58],[-42,-359],[114,-312],[-78,-71],[-112,374],[-82,755],[56,472],[92,215],[20,-322],[164,-52],[26,-241]],[[79393,47122],[-308,-12],[-234,494],[-356,482],[-119,358],[-210,481],[-138,443],[-212,827],[-244,493],[-81,508],[-103,461],[-250,372],[-145,506],[-209,330],[-290,652],[-24,300],[178,-24],[430,-114],[246,-577],[215,-401],[153,-246],[263,-635],[283,-9],[233,-405],[161,-495],[211,-270],[-111,-482],[159,-205],[100,-15],[47,-412],[97,-330],[204,-52],[135,-374],[-70,-735],[-11,-914]],[[82742,51659],[312,-546],[-329,-70],[-93,-403],[12,-535],[-267,-404],[-7,-589],[-107,-903],[-41,210],[-316,-266],[-110,361],[-198,34],[-139,189],[-330,-212],[-101,285],[-182,-32],[-229,68],[-43,793],[-138,164],[-134,505],[-38,517],[32,548],[165,392],[204,-202],[214,110],[56,500],[119,112],[333,128],[199,467],[137,374],[110,221],[236,323],[214,411],[140,462],[112,2],[143,-299],[13,-257],[183,-165],[231,-177],[-20,-232],[-186,-29],[50,-289],[-205,-201],[-158,-533],[204,-560],[-48,-272]],[[85104,55551],[28,-392],[16,-332],[-94,-540],[-102,602],[-130,-300],[89,-435],[-79,-277],[-327,343],[-78,428],[84,280],[-176,280],[-87,-245],[-131,23],[-205,-330],[-46,173],[109,498],[175,166],[151,223],[98,-268],[212,162],[45,264],[196,15],[-16,457],[225,-280],[23,-297],[20,-218]],[[72560,54241],[-242,-135],[-132,470],[-49,849],[126,959],[192,-328],[129,-416],[134,-616],[-42,-615],[-116,-168]],[[33073,56553],[-232,-65],[-50,53],[81,163],[-6,233],[160,77],[58,-21],[-11,-440]],[[84439,56653],[-100,-195],[-87,-373],[-87,-175],[-171,409],[57,158],[70,165],[30,367],[153,35],[-44,-398],[205,570],[-26,-563]],[[82917,56084],[-369,-561],[136,414],[200,364],[167,409],[146,587],[49,-482],[-183,-325],[-146,-406]],[[83856,57606],[166,-183],[177,1],[-5,-247],[-129,-251],[-176,-178],[-10,275],[20,301],[-43,282]],[[84861,57766],[78,-660],[-214,157],[5,-199],[68,-364],[-132,-133],[-11,416],[-84,31],[-43,357],[163,-47],[-4,224],[-169,451],[266,-13],[77,-220]],[[83757,58301],[-74,-510],[-119,295],[-142,450],[238,-22],[97,-213]],[[83700,61512],[171,-168],[85,153],[26,-150],[-46,-245],[95,-423],[-73,-491],[-164,-196],[-43,-476],[62,-471],[147,-65],[123,70],[347,-328],[-27,-321],[91,-142],[-29,-272],[-216,290],[-103,310],[-71,-217],[-177,354],[-253,-87],[-138,130],[14,244],[87,151],[-83,136],[-36,-213],[-137,340],[-41,257],[-11,566],[112,-195],[29,925],[90,535],[169,-1]],[[31780,61349],[-71,-149],[-209,4],[-163,-21],[-16,253],[40,86],[227,-3],[142,-52],[50,-118]],[[28638,61137],[-84,-99],[-156,95],[-159,215],[34,135],[116,41],[64,-20],[187,-53],[147,-142],[46,-161],[-195,-11]],[[29839,62320],[241,-93],[34,101],[217,-3],[165,-152],[73,15],[50,-209],[152,11],[-9,-176],[124,-21],[136,-217],[-103,-240],[-132,128],[-127,-25],[-92,28],[-50,-107],[-106,-37],[-43,144],[-92,-85],[-111,-405],[-71,94],[-14,170],[-185,100],[-131,-41],[-169,43],[-130,-110],[-149,184],[24,190],[256,-82],[210,-47],[100,131],[-127,256],[2,226],[-175,92],[62,163],[170,-26]],[[80649,61615],[-240,-284],[-228,183],[-8,509],[137,267],[304,166],[159,-14],[62,-226],[-122,-260],[-64,-341]],[[6794,61855],[-41,-99],[-69,84],[8,165],[-46,216],[14,65],[48,97],[-19,116],[16,55],[21,-11],[107,-100],[49,-51],[45,-79],[71,-207],[-7,-33],[-108,-126],[-89,-92]],[[6645,62777],[-94,-43],[-47,125],[-32,48],[-3,37],[27,50],[99,-56],[73,-90],[-23,-71]],[[6456,63091],[-9,-63],[-149,17],[21,72],[137,-26]],[[6207,63177],[-15,-34],[-19,8],[-97,21],[-35,133],[-11,24],[74,82],[23,-38],[80,-196]],[[5737,63567],[-33,-58],[-93,107],[14,43],[43,58],[64,-12],[5,-138]],[[27867,64030],[110,-216],[260,66],[98,-138],[235,-366],[173,-267],[92,8],[165,-120],[-20,-167],[205,-24],[210,-242],[-33,-138],[-185,-75],[-187,-29],[-191,46],[-398,-57],[186,329],[-113,154],[-179,39],[-96,171],[-66,336],[-157,-23],[-259,159],[-83,124],[-362,91],[-97,115],[104,148],[-273,30],[-199,-307],[-115,-8],[-40,-144],[-138,-65],[-118,56],[146,183],[60,213],[126,131],[142,116],[210,56],[67,65],[240,-42],[219,-7],[261,-201]],[[28462,64617],[-68,-29],[-70,340],[-104,171],[60,375],[84,-23],[97,-491],[1,-343]],[[83659,64045],[-119,-485],[-146,499],[-32,438],[163,581],[223,447],[127,-176],[-49,-357],[-167,-947]],[[28383,66284],[-303,-95],[-19,219],[130,47],[184,-18],[8,-153]],[[28611,66290],[-48,-420],[-51,75],[4,309],[-124,234],[-1,67],[220,-265]],[[87399,70756],[35,-203],[-156,-357],[-114,189],[-143,-137],[-73,-346],[-181,168],[2,281],[154,352],[158,-68],[114,248],[204,-127]],[[59604,71655],[-188,-251],[21,-111],[8,-48],[-285,-240],[-136,77],[-64,237],[132,22],[19,3],[40,143],[200,-8],[253,176]],[[56583,71675],[152,-199],[216,34],[207,-42],[-7,-103],[151,71],[-35,-175],[-400,-50],[3,98],[-339,115],[52,251]],[[54311,73167],[-100,-465],[41,-183],[-58,-303],[-213,222],[-141,64],[-387,300],[38,304],[325,-54],[284,64],[211,51]],[[52558,74927],[166,-419],[-39,-782],[-126,38],[-113,-197],[-105,156],[-11,713],[-64,338],[153,-30],[139,183]],[[89159,72524],[-104,-472],[48,-296],[-145,-416],[-355,-278],[-488,-36],[-396,-675],[-186,227],[-12,442],[-483,-130],[-329,-279],[-325,-11],[282,-435],[-186,-1004],[-179,-248],[-135,229],[69,533],[-176,172],[-113,405],[263,182],[145,371],[280,306],[203,403],[553,177],[297,-121],[291,1050],[185,-282],[408,591],[158,229],[174,723],[-47,664],[117,374],[295,108],[152,-819],[-9,-479],[-256,-595],[4,-610]],[[52655,75484],[-92,-456],[-126,120],[-64,398],[56,219],[179,226],[47,-507]],[[89974,76679],[195,-126],[197,250],[62,-663],[-412,-162],[-244,-587],[-436,404],[-152,-646],[-308,-9],[-39,587],[138,455],[296,33],[81,817],[83,460],[326,-615],[213,-198]],[[32315,78082],[202,-79],[257,16],[-137,-242],[-102,-38],[-353,250],[-69,198],[105,183],[97,-288]],[[32831,79592],[-135,-11],[-360,186],[-258,279],[96,49],[365,-148],[284,-247],[8,-108]],[[15692,79240],[-140,-82],[-456,269],[-84,209],[-248,207],[-50,168],[-286,107],[-107,321],[24,137],[291,-129],[171,-89],[261,-63],[94,-204],[138,-280],[277,-244],[115,-327]],[[34407,80527],[-184,-517],[181,199],[187,-126],[-98,-206],[247,-162],[128,144],[277,-182],[-86,-433],[194,101],[36,-313],[86,-367],[-117,-520],[-125,-22],[-183,111],[60,484],[-77,75],[-322,-513],[-166,21],[196,277],[-267,144],[-298,-35],[-539,18],[-43,175],[173,208],[-121,160],[234,356],[287,941],[172,336],[241,204],[129,-26],[-54,-160],[-148,-372]],[[13136,82508],[267,47],[-84,-671],[242,-475],[-111,1],[-167,270],[-103,272],[-140,184],[-51,260],[16,188],[131,-76]],[[89901,80562],[280,-1046],[-411,195],[-171,-854],[271,-605],[-8,-413],[-211,356],[-182,-457],[-51,496],[31,575],[-32,638],[64,446],[13,790],[-163,581],[24,808],[257,271],[-110,274],[123,83],[73,-391],[96,-569],[-7,-581],[114,-597]],[[48114,81456],[-493,-349],[-393,89],[225,617],[-145,601],[378,463],[210,276],[233,24],[298,-365],[-149,-406],[46,-422],[-210,-528]],[[53524,83435],[-166,-478],[-291,333],[-39,246],[408,195],[88,-296]],[[7498,84325],[-277,-225],[-142,152],[-43,277],[252,210],[148,90],[185,-40],[117,-183],[-240,-281]],[[49165,85222],[-297,-639],[283,81],[304,-3],[-72,-481],[-250,-530],[287,-38],[270,-759],[190,-95],[171,-673],[79,-233],[337,-113],[-34,-378],[-142,-173],[111,-305],[-250,-310],[-371,6],[-473,-163],[-130,116],[-183,-276],[-257,67],[-195,-226],[-148,118],[407,621],[249,127],[-436,99],[-79,235],[291,183],[-152,319],[52,387],[414,-54],[40,343],[-190,372],[-337,104],[-66,160],[101,264],[-92,163],[-149,-279],[-17,569],[-140,301],[101,611],[216,480],[222,-47],[335,49]],[[4006,85976],[-171,-92],[-182,110],[-168,161],[274,101],[220,-54],[27,-226]],[[27981,87304],[-108,-310],[-123,50],[-73,176],[13,41],[107,177],[114,-13],[70,-121]],[[27250,87631],[-325,-326],[-196,13],[-61,160],[207,273],[381,-6],[-6,-114]],[[2297,88264],[171,-113],[173,61],[225,-156],[276,-79],[-23,-64],[-211,-125],[-211,128],[-106,107],[-245,-34],[-66,52],[17,223]],[[26344,89371],[51,-259],[143,91],[161,-155],[304,-203],[318,-184],[25,-281],[204,46],[199,-196],[-247,-186],[-432,142],[-156,266],[-275,-314],[-396,-306],[-95,346],[-377,-57],[242,292],[35,465],[95,542],[201,-49]],[[45969,89843],[-64,-382],[314,-403],[-361,-451],[-801,-405],[-240,-107],[-365,87],[-775,187],[273,261],[-605,289],[492,114],[-12,174],[-583,137],[188,385],[421,87],[433,-400],[422,321],[349,-167],[453,315],[461,-42]],[[28926,90253],[-312,-30],[-69,289],[118,331],[255,82],[217,-163],[3,-253],[-32,-82],[-180,-174]],[[0,91325],[681,-451],[728,-588],[-24,-367],[187,-147],[-64,429],[754,-88],[544,-553],[-276,-257],[-455,-61],[-7,-578],[-111,-122],[-260,17],[-212,206],[-369,172],[-62,257],[-283,96],[-315,-76],[-151,207],[60,219],[-333,-140],[126,-278],[-158,-251]],[[0,88971],[0,2354]],[[23431,91410],[-173,-207],[-374,179],[-226,-65],[-380,266],[245,183],[194,256],[295,-168],[166,-106],[84,-112],[169,-226]],[[0,92833],[99999,-404],[-305,-30],[-49,187],[-99645,247]],[[0,92833],[36,24],[235,-1],[402,-169],[-24,-81],[-286,-141],[-363,-36],[0,404]],[[24848,91640],[-1,-604],[371,463],[332,-380],[-83,-438],[269,-399],[290,427],[202,510],[16,648],[394,-45],[411,-87],[373,-293],[17,-293],[-207,-315],[196,-316],[-36,-288],[-544,-413],[-386,-91],[-287,178],[-83,-297],[-268,-498],[-81,-258],[-322,-400],[-397,-39],[-220,-250],[-18,-384],[-323,-74],[-340,-479],[-301,-665],[-108,-466],[-15,-686],[408,-99],[125,-553],[130,-448],[388,117],[517,-256],[277,-225],[199,-279],[348,-162],[294,-249],[459,-34],[302,-58],[-45,-511],[86,-594],[201,-661],[414,-561],[214,192],[150,607],[-145,934],[-196,311],[445,276],[314,415],[154,411],[-22,395],[-189,502],[-338,445],[328,619],[-121,535],[-93,922],[194,137],[476,-161],[286,-57],[230,155],[258,-200],[342,-343],[85,-229],[495,-45],[-8,-496],[92,-747],[254,-92],[201,-348],[402,328],[266,652],[184,274],[216,-527],[362,-754],[307,-709],[-112,-371],[370,-333],[250,-338],[442,-152],[179,-189],[110,-500],[216,-78],[112,-223],[20,-664],[-202,-222],[-199,-207],[-458,-210],[-349,-486],[-470,-96],[-594,125],[-417,4],[-287,-41],[-233,-424],[-354,-262],[-401,-782],[-320,-545],[236,97],[446,776],[583,493],[416,59],[245,-290],[-262,-397],[88,-637],[91,-446],[361,-295],[459,86],[278,664],[19,-429],[180,-214],[-344,-387],[-615,-351],[-276,-239],[-310,-426],[-211,44],[-11,500],[483,488],[-445,-19],[-309,-72],[48,-194],[-296,-286],[-286,-204],[-293,-175],[-159,-386],[-35,-98],[-3,-313],[92,-313],[115,-15],[-29,216],[83,-131],[-22,-169],[-188,-96],[-133,12],[-205,-104],[-121,-29],[-162,-29],[-231,-171],[408,111],[82,-112],[-389,-177],[-177,-1],[8,72],[-84,-164],[82,-27],[-60,-424],[-203,-455],[-20,152],[-61,31],[-91,147],[57,-318],[66,-106],[8,-222],[-89,-230],[-157,-472],[-25,24],[86,402],[-142,226],[-33,490],[-53,-255],[59,-375],[-175,88],[183,-186],[12,-562],[79,-41],[29,-204],[39,-591],[-176,-439],[-288,-175],[-182,-346],[-139,-38],[-141,-217],[-39,-199],[-305,-383],[-157,-281],[-131,-351],[-43,-419],[50,-411],[92,-505],[124,-418],[1,-256],[132,-685],[-9,-398],[-12,-230],[-69,-361],[-83,-74],[-137,71],[-44,259],[-106,136],[-147,508],[-129,452],[-42,231],[57,393],[-77,325],[-217,494],[-108,91],[-281,-269],[-49,30],[-135,276],[-174,146],[-314,-75],[-247,66],[-212,-41],[-118,-83],[54,-166],[-5,-240],[59,-117],[-53,-77],[-103,87],[-104,-112],[-202,18],[-207,313],[-242,-74],[-202,137],[-173,-42],[-234,-138],[-253,-438],[-276,-255],[-152,-282],[-63,-266],[-3,-407],[14,-284],[52,-201],[1,-1],[-1,-1],[-107,-516],[-49,-426],[-20,-791],[-27,-289],[48,-322],[86,-288],[56,-458],[184,-440],[65,-337],[109,-291],[295,-157],[114,-247],[244,165],[212,60],[208,106],[175,101],[176,241],[67,345],[22,496],[48,173],[188,155],[294,137],[246,-21],[169,50],[66,-125],[-9,-285],[-149,-351],[-66,-360],[51,-103],[-42,-255],[-69,-461],[-71,152],[-58,-10],[1,-87],[53,-3],[-5,-160],[-45,-256],[24,-91],[-29,-212],[18,-56],[-32,-299],[-55,-156],[-50,-19],[-55,-205],[90,-107],[24,88],[82,-75],[29,-23],[61,104],[79,8],[26,-48],[43,29],[129,-53],[128,16],[90,65],[32,66],[89,-31],[66,-40],[73,14],[55,51],[127,-82],[44,-13],[85,-110],[80,-132],[101,-91],[73,-162],[-24,-57],[-14,-132],[29,-216],[-64,-202],[-30,-237],[-9,-261],[15,-152],[7,-266],[-43,-58],[-26,-253],[19,-156],[-56,-151],[12,-159],[43,-97],[70,-321],[108,-238],[130,-252],[100,-212],[-6,-125],[111,-27],[26,48],[77,-145],[136,42],[119,150],[168,119],[95,176],[153,-34],[-10,-58],[155,-21],[124,-102],[90,-177],[105,-164],[143,-18],[209,412],[114,63],[3,195],[51,500],[159,274],[175,11],[22,123],[218,-49],[218,298],[109,132],[134,285],[98,-36],[73,-156],[-54,-199],[-8,-139],[-163,-69],[91,-268],[-3,-309],[-123,-343],[105,-469],[120,38],[62,427],[-86,208],[-14,447],[346,241],[-38,278],[97,186],[100,-415],[195,-9],[180,-330],[11,-195],[249,-6],[297,61],[159,-264],[213,-73],[155,184],[4,149],[344,35],[333,9],[-236,-175],[95,-279],[222,-44],[210,-291],[45,-473],[144,13],[109,-139],[183,-217],[171,-385],[8,-304],[105,-14],[149,-289],[109,-205],[333,-119],[30,107],[225,43],[298,-159],[95,-65],[204,-140],[294,-499],[46,-242],[95,28],[69,-327],[155,-1033],[149,-97],[7,-408],[-208,-487],[86,-178],[491,-92],[10,-593],[211,388],[349,-212],[462,-361],[135,-346],[-45,-327],[323,182],[540,-313],[415,23],[411,-489],[355,-662],[214,-170],[237,-24],[101,-186],[94,-752],[46,-358],[-110,-977],[-142,-385],[-391,-822],[-177,-668],[-206,-513],[-69,-11],[-78,-435],[20,-1107],[-77,-910],[-30,-390],[-88,-233],[-49,-790],[-282,-771],[-47,-610],[-225,-256],[-65,-355],[-302,2],[-437,-227],[-195,-263],[-311,-173],[-327,-470],[-235,-586],[-41,-441],[46,-326],[-51,-597],[-63,-289],[-195,-325],[-308,-1040],[-244,-468],[-189,-277],[-127,-562],[-183,-337],[-121,-372],[-313,-328],[-205,118],[-151,-63],[-256,253],[-189,-19],[-169,327],[-19,-308],[353,-506],[-38,-408],[173,-257],[-14,-289],[-267,-757],[-412,-317],[-557,-123],[-305,59],[59,-352],[-57,-442],[51,-298],[-167,-208],[-284,-82],[-267,216],[-108,-155],[39,-587],[188,-178],[152,186],[82,-307],[-255,-183],[-223,-367],[-41,-595],[-66,-316],[-262,-2],[-218,-302],[-80,-443],[274,-433],[265,-119],[-96,-531],[-328,-333],[-180,-692],[-254,-234],[-113,-276],[89,-614],[185,-342],[-117,30],[-247,4],[-134,-145],[-250,-213],[-45,-552],[-118,-14],[-313,192],[-318,412],[-346,338],[-87,374],[79,346],[-140,393],[-36,1007],[119,568],[293,457],[-422,172],[265,522],[94,982],[309,-208],[145,1224],[-186,157],[-87,-738],[-175,83],[87,845],[95,1095],[127,404],[-79,576],[-23,666],[117,19],[170,954],[192,945],[118,881],[-64,885],[83,487],[-34,730],[163,721],[50,1143],[89,1227],[87,1321],[-20,967],[-58,832],[-279,340],[-24,242],[-551,593],[-498,646],[-214,365],[-115,488],[46,170],[-236,775],[-274,1090],[-262,1177],[-114,269],[-87,435],[-216,386],[-198,239],[90,264],[-134,563],[86,414],[221,373],[148,442],[-60,258],[-106,-275],[-166,259],[56,167],[-47,536],[97,89],[52,368],[105,381],[-20,241],[153,126],[190,236],[-37,183],[103,44],[-12,296],[65,214],[138,40],[117,371],[106,310],[-102,141],[52,343],[-62,540],[59,155],[-44,500],[-112,315],[-93,170],[-59,319],[68,158],[-70,40],[-52,195],[-138,165],[-122,-38],[-56,-205],[-112,-149],[-61,-20],[-27,-123],[132,-321],[-75,-76],[-40,-87],[-130,-30],[-48,353],[-36,-101],[-92,35],[-56,238],[-114,39],[-72,69],[-119,-1],[-8,-128],[-32,89],[-151,131],[-56,124],[32,103],[-11,130],[-77,142],[-109,116],[-95,76],[-19,173],[-73,105],[18,-172],[-55,-141],[-64,164],[-89,58],[-38,120],[2,179],[36,187],[-78,83],[64,114],[-96,186],[-130,238],[-61,200],[-117,185],[-140,267],[31,92],[46,-89],[21,41],[-48,185],[-84,52],[-31,-140],[-161,9],[-100,57],[-115,117],[-154,37],[-79,127],[-142,103],[-174,11],[-127,117],[-149,244],[-314,636],[-144,192],[-226,154],[-156,-43],[-223,-223],[-140,-58],[-196,156],[-208,112],[-260,271],[-208,83],[-314,275],[-233,282],[-70,158],[-155,35],[-284,187],[-116,270],[-299,335],[-139,373],[-66,288],[93,57],[-29,169],[64,153],[1,204],[-93,266],[-25,235],[-94,298],[-244,587],[-280,462],[-135,368],[-238,241],[-51,145],[42,365],[-142,137],[-164,288],[-69,412],[-149,48],[-162,311],[-130,288],[-12,184],[-149,446],[-99,452],[5,227],[-201,235],[-93,-26],[-159,163],[-44,-240],[46,-284],[27,-444],[95,-243],[206,-407],[46,-139],[42,-42],[37,-203],[49,8],[56,-381],[85,-150],[59,-210],[174,-300],[92,-550],[83,-259],[77,-277],[15,-311],[134,-20],[112,-268],[100,-264],[-6,-106],[-117,-217],[-49,3],[-74,359],[-182,337],[-200,286],[-142,150],[9,432],[-42,320],[-132,183],[-191,264],[-37,-76],[-70,154],[-171,143],[-164,343],[20,44],[115,-33],[103,221],[10,266],[-214,422],[-163,163],[-102,369],[-103,388],[-129,472],[-113,531],[-46,302],[-180,340],[-130,71],[-30,169],[-156,30],[-100,159],[-258,59],[-70,95],[-34,324],[-270,594],[-231,821],[10,137],[-123,195],[-215,495],[-38,482],[-148,323],[61,489],[-10,507],[-89,453],[109,557],[67,1072],[-50,792],[-88,506],[-80,274],[33,115],[402,-200],[148,-558],[68,156],[-44,485],[-94,484],[-38,1],[-537,581],[-199,255],[-503,245],[-155,523],[40,362],[-356,252],[-48,476],[-336,429],[-6,304],[-153,223],[-245,188],[-78,515],[-358,478],[-150,558],[-267,38],[-441,15],[-326,170],[-574,613],[-266,112],[-486,211],[-385,-50],[-546,271],[-330,252],[-309,-125],[58,-411],[-154,-38],[-321,-123],[-245,-199],[-307,-126],[-40,348],[125,580],[295,182],[-76,148],[-354,-329],[-190,-394],[-400,-420],[203,-287],[-262,-424],[-299,-247],[-278,-181],[-69,-261],[-434,-305],[-87,-278],[-325,-252],[-191,45],[-259,-165],[-282,-201],[-231,-197],[-477,-169],[-43,99],[304,276],[271,182],[296,324],[345,66],[137,243],[385,353],[62,119],[205,208],[48,448],[141,349],[-320,-179],[-90,102],[-150,-215],[-181,300],[-75,-212],[-104,294],[-278,-236],[-170,0],[-24,352],[50,217],[-179,210],[-361,-113],[-235,277],[-190,142],[-1,334],[-214,252],[108,340],[226,330],[99,303],[225,43],[191,-94],[224,285],[201,-51],[212,183],[-52,270],[-155,106],[205,228],[-170,-7],[-295,-128],[-85,-131],[-219,131],[-392,-67],[-407,142],[-117,238],[-351,343],[390,247],[620,289],[228,0],[-38,-295],[586,22],[-225,366],[-342,226],[-197,295],[-267,252],[-381,187],[155,309],[493,19],[350,270],[66,287],[284,281],[271,68],[526,262],[256,-40],[427,315],[421,-124],[201,-266],[123,114],[469,-35],[-16,-136],[425,-101],[283,59],[585,-186],[534,-56],[214,-77],[370,96],[421,-177],[302,-83],[518,-142],[438,-284],[289,-55],[244,247],[336,184],[413,-72],[416,259],[455,148],[191,-245],[207,138],[62,278],[192,-63],[470,-530],[369,401],[38,-448],[341,96],[105,173],[337,-34],[424,-248],[650,-217],[383,-100],[272,38],[375,-300],[-391,-293],[502,-127],[750,70],[236,103],[296,-354],[302,299],[-283,251],[179,202],[338,27],[223,59],[224,-141],[279,-321],[310,47],[491,-266],[431,94],[405,-14],[-32,367],[247,103],[431,-200],[-2,-559],[177,471],[223,-16],[126,594],[-298,364],[-324,239],[22,653],[329,429],[366,-95],[281,-261],[378,-666],[-247,-290],[517,-120]],[[18287,93781],[-139,-277],[618,179],[386,-298],[314,302],[254,-194],[227,-580],[140,244],[-197,606],[244,86],[276,-94],[311,-239],[175,-575],[86,-417],[466,-293],[502,-279],[-31,-260],[-456,-48],[178,-227],[-94,-217],[-503,93],[-478,160],[-322,-36],[-522,-201],[-824,-103],[-374,-41],[-151,279],[-379,161],[-246,-66],[-343,468],[185,62],[429,101],[392,-26],[362,103],[-537,138],[-594,-47],[-394,12],[-146,217],[644,237],[-428,-9],[-485,156],[233,443],[193,235],[744,359],[284,-114]],[[20972,93958],[-244,-390],[-434,413],[95,83],[372,24],[211,-130]],[[28794,93770],[25,-163],[-296,17],[-299,13],[-304,-80],[-80,36],[-306,313],[12,213],[133,39],[636,-63],[479,-325]],[[25955,93803],[219,-369],[256,477],[704,242],[477,-611],[-42,-387],[550,172],[263,235],[616,-299],[383,-282],[36,-258],[515,134],[290,-376],[670,-234],[242,-238],[263,-553],[-510,-275],[654,-386],[441,-130],[400,-543],[437,-39],[-87,-414],[-487,-687],[-342,253],[-437,568],[-359,-74],[-35,-338],[292,-344],[377,-272],[114,-157],[181,-584],[-96,-425],[-350,160],[-697,473],[393,-509],[289,-357],[45,-206],[-753,236],[-596,343],[-337,287],[97,167],[-414,304],[-405,286],[5,-171],[-803,-94],[-235,203],[183,435],[522,10],[571,76],[-92,211],[96,294],[360,576],[-77,261],[-107,203],[-425,286],[-563,201],[178,150],[-294,367],[-245,34],[-219,201],[-149,-175],[-503,-76],[-1011,132],[-588,174],[-450,89],[-231,207],[290,270],[-394,2],[-88,599],[213,528],[286,241],[717,158],[-204,-382]],[[22123,94208],[331,-124],[496,75],[72,-172],[-259,-283],[420,-254],[-50,-532],[-455,-229],[-268,50],[-192,225],[-690,456],[5,189],[567,-73],[-306,386],[329,286]],[[89889,93835],[-421,-4],[-569,66],[-49,31],[263,234],[348,54],[394,-226],[34,-155]],[[24112,93575],[-298,-442],[-317,22],[-173,519],[4,294],[145,251],[276,161],[579,-20],[530,-144],[-415,-526],[-331,-115]],[[16539,92764],[-731,-294],[-147,259],[-641,312],[93,193],[218,489],[241,388],[-272,362],[939,93],[397,-123],[709,-33],[270,-171],[298,-249],[-349,-149],[-681,-415],[-344,-414],[0,-248]],[[91869,94941],[-321,-234],[-444,53],[-516,233],[66,192],[518,-89],[697,-155]],[[23996,94879],[-151,-229],[-403,44],[-337,155],[148,266],[399,159],[243,-208],[101,-187]],[[90301,95224],[-219,-439],[-1023,16],[-461,-139],[-550,384],[149,406],[366,111],[734,-26],[1004,-313]],[[22639,95907],[212,-273],[9,-303],[-127,-440],[-458,-60],[-298,94],[5,345],[-455,-46],[-18,457],[299,-18],[419,201],[390,-34],[22,77]],[[19941,95601],[109,-210],[247,99],[291,-26],[49,-289],[-169,-281],[-940,-91],[-701,-256],[-423,-14],[-35,193],[577,261],[-1255,-70],[-389,106],[379,577],[262,165],[782,-199],[493,-350],[485,-45],[-397,565],[255,215],[286,-68],[94,-282]],[[65981,92363],[-164,-52],[-907,77],[-74,262],[-503,158],[-40,320],[284,126],[-10,323],[551,503],[-255,73],[665,518],[-75,268],[621,312],[917,380],[925,110],[475,220],[541,76],[193,-233],[-187,-184],[-984,-293],[-848,-282],[-863,-562],[-414,-577],[-435,-568],[56,-491],[531,-484]],[[23699,96131],[308,-190],[547,1],[240,-194],[-64,-222],[319,-134],[177,-140],[374,-26],[406,-50],[441,128],[566,51],[451,-42],[298,-223],[62,-244],[-174,-157],[-414,-127],[-355,72],[-797,-91],[-570,-11],[-449,73],[-738,190],[-96,325],[-34,293],[-279,258],[-574,72],[-322,183],[104,242],[573,-37]],[[17722,96454],[-38,-454],[-214,-205],[-259,-29],[-517,-252],[-444,-91],[-377,128],[472,442],[570,383],[426,-9],[381,87]],[[63641,74970],[141,-419],[130,-28],[85,-159],[-228,-47],[-49,-459],[-47,-207],[-102,-138],[7,-293],[88,-436],[263,-123],[193,-296],[395,-102],[434,156],[27,139],[-52,417],[40,618],[-216,200],[71,405],[-184,34],[61,498],[262,-145],[244,189],[-202,355],[-80,338],[-224,-151],[-28,-433],[-87,383],[-15,144],[68,246],[-53,206],[-322,202],[-125,530],[-154,150],[-9,192],[270,-56],[11,432],[236,96],[243,-88],[50,576],[-50,365],[-278,-28],[-236,144],[-321,-260],[-259,-124],[-127,-350],[-269,-97],[-276,-610],[252,-561],[-27,-398],[303,-696],[146,-311]],[[0,88971],[99997,-3],[-357,-260],[-360,44],[250,-315],[166,-487],[128,-159],[32,-244],[-71,-157],[-518,129],[-777,-445],[-247,-69],[-425,-415],[-403,-362],[-102,-269],[-397,409],[-724,-464],[-126,220],[-268,-254],[-371,81],[-90,-388],[-333,-572],[10,-239],[316,-132],[-37,-860],[-258,-22],[-119,-494],[116,-255],[-486,-301],[-96,-675],[-415,-144],[-83,-600],[-400,-551],[-103,407],[-119,862],[-155,1313],[134,819],[234,353],[15,276],[431,132],[496,744],[479,608],[499,471],[223,833],[-337,-50],[-167,-487],[-705,-648],[-227,726],[-717,-201],[-696,-990],[230,-362],[-620,-154],[-430,-61],[20,427],[-431,90],[-344,-291],[-850,102],[-913,-175],[-900,-1153],[-1065,-1394],[438,-74],[136,-370],[270,-132],[178,296],[305,-39],[401,-650],[9,-502],[-217,-591],[-23,-705],[-126,-945],[-418,-855],[-94,-409],[-377,-688],[-374,-682],[-179,-349],[-370,-346],[-175,-8],[-175,287],[-373,-432],[-43,-197],[-106,36],[-120,-201],[-83,-201],[10,-424],[-143,-130],[-50,-105],[-104,-174],[-185,-97],[-121,-159],[-9,-256],[-32,-65],[111,-96],[157,-259],[240,-697],[68,-383],[3,-681],[-105,-325],[-252,-113],[-222,-245],[-250,-51],[-31,322],[52,443],[-123,615],[206,99],[-190,506],[-135,113],[-34,-112],[-81,-49],[-10,112],[-72,54],[-75,94],[77,260],[65,69],[-25,108],[71,319],[-18,97],[-163,64],[-131,158],[-388,-171],[-204,-277],[-300,-161],[148,274],[-58,230],[220,397],[-147,310],[-242,-209],[-314,-411],[-171,-381],[-272,-29],[-142,-275],[147,-400],[227,-97],[9,-265],[220,-172],[311,421],[247,-230],[179,-15],[46,-310],[-394,-165],[-130,-319],[-270,-296],[-142,-414],[299,-324],[109,-582],[169,-541],[189,-454],[-5,-439],[-174,-161],[66,-315],[164,-184],[-43,-481],[-71,-468],[-155,-53],[-203,-640],[-225,-775],[-258,-705],[-382,-545],[-386,-498],[-313,-68],[-170,-262],[-96,192],[-157,-294],[-388,-296],[-294,-90],[-95,-624],[-154,-35],[-73,429],[66,228],[-373,190],[-131,-97],[-371,-505],[-231,-558],[-61,-410],[212,-623],[260,-772],[252,-365],[169,-475],[127,-1093],[-37,-1039],[-232,-389],[-318,-381],[-227,-492],[-346,-550],[-101,378],[78,401],[-206,335],[-233,87],[-112,307],[-141,611],[-249,271],[-238,-11],[41,464],[-245,-3],[-22,-650],[-150,-863],[-90,-522],[19,-428],[181,-18],[113,-539],[50,-512],[155,-338],[168,-69],[144,-306],[64,-56],[164,-356],[116,-396],[16,-398],[-29,-269],[27,-203],[20,-349],[98,-163],[109,-523],[-5,-199],[-197,-40],[-263,438],[-329,469],[-32,301],[-161,395],[-38,489],[-100,322],[30,431],[-61,250],[-110,227],[-47,292],[-148,334],[-135,280],[-45,-347],[-53,328],[30,369],[82,566],[-27,439],[86,452],[-94,350],[23,644],[-113,306],[-90,707],[-50,746],[-121,490],[-183,-297],[-315,-421],[-156,53],[-172,138],[96,732],[-58,554],[-218,681],[34,213],[-163,76],[-197,481],[-79,309],[-16,301],[-53,284],[-116,344],[-256,23],[25,-243],[-87,-329],[-118,120],[-41,-108],[-78,65],[-108,53],[-39,-216],[-189,7],[-343,-122],[16,-445],[-148,-349],[-400,-398],[-311,-695],[-209,-373],[-276,-386],[-1,-272],[-138,-146],[-250,-212],[-130,-31],[-84,-450],[58,-769],[15,-490],[-118,-561],[-1,-1004],[-144,-29],[-126,-450],[84,-195],[-253,-167],[-93,-402],[-112,-170],[-263,552],[-128,827],[-107,596],[-97,279],[-148,568],[-69,739],[-48,369],[-253,811],[-115,1145],[-83,756],[1,716],[-54,553],[-404,-353],[-196,70],[-362,716],[133,214],[-82,232],[-326,501],[-203,150],[-83,425],[-215,449],[-512,-111],[-451,-11],[-391,-83],[-523,179],[-302,136],[-314,76],[-118,725],[-133,105],[-214,-106],[-280,-286],[-339,196],[-281,454],[-267,168],[-186,561],[-205,788],[-149,-96],[-177,196],[-103,-231],[-165,29],[58,-261],[-25,-135],[89,-445],[109,-510],[137,-135],[47,-207],[190,-248],[16,-244],[-27,-197],[35,-199],[80,-165],[37,-194],[41,-145],[-18,430],[75,310],[76,64],[84,-186],[5,-345],[-61,-348],[53,-226],[49,29],[11,-162],[217,93],[230,-15],[168,-18],[190,400],[207,379],[176,364],[80,201],[35,-51],[-26,-244],[-37,-108],[38,-466],[125,-404],[155,-214],[204,-78],[164,-107],[125,-339],[75,-196],[100,-75],[-1,-132],[-101,-352],[-44,-166],[-117,-189],[-104,-404],[-126,31],[-58,-141],[-44,-300],[34,-395],[-26,-72],[-128,2],[-174,-221],[-27,-288],[-63,-125],[-173,5],[-109,-149],[1,-239],[-134,-164],[-153,56],[-186,-199],[-128,-33],[-201,-159],[-54,-263],[-6,-201],[-277,-249],[-444,-276],[-249,-417],[-122,-32],[-83,34],[-163,-245],[-177,-113],[-233,-31],[-70,-34],[-61,-156],[-73,-43],[-42,-150],[-138,13],[-89,-80],[-192,30],[-72,345],[8,323],[-46,174],[-54,437],[-80,243],[56,29],[-29,270],[34,114],[-12,257],[-36,253],[-84,177],[-22,236],[-143,212],[-148,495],[-79,482],[-192,406],[-124,97],[-184,563],[-32,411],[12,350],[-159,655],[-130,231],[-150,122],[-92,339],[15,133],[-77,307],[-81,131],[-108,440],[-170,476],[-141,406],[-139,-3],[44,325],[12,206],[34,236],[-9,86],[-78,-238],[-60,-446],[-75,-308],[-65,-103],[-93,191],[-125,263],[-198,847],[-29,-53],[115,-624],[171,-594],[210,-920],[102,-321],[90,-334],[249,-654],[-55,-103],[9,-384],[323,-530],[49,-121],[90,-580],[-61,-107],[40,-608],[102,-706],[106,-145],[152,-219],[161,-683],[77,-543],[152,-288],[379,-558],[154,-336],[151,-341],[87,-203],[136,-178],[66,-183],[-9,-245],[-158,-142],[119,-161],[91,-109],[54,-244],[125,-248],[138,-2],[262,151],[302,70],[245,184],[138,39],[99,108],[158,20],[89,12],[128,88],[147,59],[132,202],[105,2],[6,-163],[-25,-344],[1,-310],[-59,-214],[-78,-639],[-134,-659],[-172,-755],[-238,-866],[-237,-661],[-327,-806],[-278,-479],[-415,-586],[-259,-450],[-304,-715],[-64,-312],[-63,-140],[-195,-236],[-68,-246],[-104,-44],[-40,-416],[-89,-238],[-54,-393],[-112,-195],[-128,-728],[16,-335],[178,-216],[8,-153],[-76,-357],[16,-180],[-18,-282],[97,-370],[115,-583],[101,-129],[45,-265],[-11,-588],[34,-519],[11,-923],[49,-290],[-83,-422],[-108,-410],[-177,-366],[-254,-225],[-313,-287],[-313,-634],[-107,-108],[-194,-420],[-115,-136],[-23,-421],[132,-448],[54,-346],[4,-177],[49,29],[-8,-579],[-45,-274],[65,-102],[-41,-246],[-116,-210],[-229,-199],[-334,-320],[-122,-219],[24,-248],[71,-40],[-24,-311],[-70,-430],[-32,-491],[-72,-267],[-190,-298],[-54,-86],[-118,-300],[-77,-303],[-158,-424],[-314,-609],[-196,-355],[-209,-269],[-291,-229],[-141,-31],[-36,-164],[-169,88],[-138,-113],[-301,114],[-168,-72],[-115,31],[-286,-233],[-238,-94],[-171,-223],[-127,-14],[-117,210],[-94,11],[-120,264],[-13,-82],[-37,159],[2,346],[-90,396],[89,108],[-7,453],[-182,553],[-139,501],[-1,1],[-199,768],[-207,446],[-108,432],[-62,575],[-68,428],[-93,910],[-7,707],[-35,322],[-108,243],[-144,489],[-146,708],[-60,371],[-226,577],[-17,453],[-26,372],[38,519],[96,541],[15,254],[90,532],[66,243],[159,386],[90,263],[29,438],[-15,335],[-83,211],[-74,358],[-68,355],[15,122],[85,235],[-84,570],[-57,396],[-139,374],[26,115],[-39,183],[-74,444],[-228,626],[-285,596],[-184,488],[-169,610],[9,196],[61,189],[67,430],[56,438],[-52,90],[96,663],[40,467],[-108,390],[-127,100],[-56,265],[-71,85],[3,163],[-289,-213],[-105,32],[-107,-133],[-222,13],[-149,370],[-91,427],[-197,390],[-209,-8],[-245,1],[-229,-69],[-224,-126],[-436,-346],[-154,-203],[-250,-171],[-248,168],[-126,-7],[-194,116],[-178,-7],[-329,-103],[-193,-170],[-275,-217],[-54,15],[-73,-5],[-286,282],[-252,450],[-237,323],[-187,381],[-75,44],[-200,238],[-144,316],[-49,216],[-34,437],[-122,349],[-108,232],[-71,76],[-69,118],[-32,261],[-41,130],[-80,97],[-149,247],[-117,39],[-63,166],[1,90],[-84,125],[-18,127],[-46,453],[36,262],[-115,460],[-138,210],[122,112],[134,415],[66,304],[-24,318],[78,291],[34,557],[-30,583],[-34,294],[28,295],[-72,281],[-146,255],[12,249],[13,274],[106,161],[91,308],[-18,200],[96,417],[155,376],[93,95],[74,344],[6,315],[100,365],[185,216],[177,603],[144,235],[259,66],[219,403],[139,158],[232,493],[-70,735],[106,508],[37,312],[179,399],[278,270],[206,244],[186,612],[87,362],[205,-2],[167,-251],[264,41],[288,-131],[121,-6],[267,323],[300,102],[175,244],[268,180],[471,105],[459,48],[140,-87],[262,232],[297,5],[113,-137],[190,35],[302,239],[195,-71],[-9,-299],[236,217],[20,-113],[-139,-289],[-2,-274],[96,-147],[-36,-511],[-183,-297],[53,-322],[143,-10],[70,-281],[106,-92],[326,-204],[117,51],[232,-98],[368,-264],[130,-526],[250,-114],[391,-248],[296,-293],[136,153],[133,272],[-65,452],[87,288],[200,277],[192,80],[375,-121],[95,-264],[104,-2],[88,-101],[276,-69],[68,-196],[369,10],[268,-156],[275,-175],[129,-92],[214,188],[114,169],[245,49],[198,-75],[75,-293],[65,193],[222,-140],[217,-33],[137,149],[80,194],[-19,34],[74,276],[56,446],[40,149],[8,6],[99,482],[138,416],[5,21],[-26,452],[68,243],[-102,268],[105,222],[-169,-51],[-233,136],[-191,-340],[-421,-66],[-225,317],[-300,20],[-64,-245],[-192,-71],[-268,315],[-303,-10],[-165,587],[-203,328],[135,459],[-176,283],[308,565],[428,23],[117,449],[529,-78],[334,383],[324,167],[459,13],[485,-416],[399,-229],[323,91],[239,-53],[328,309],[42,252],[-70,403],[-160,218],[-154,68],[-102,181],[-354,499],[-317,223],[-240,347],[202,95],[231,494],[-156,234],[410,241],[-8,129],[-249,-95],[-222,-48],[-185,-191],[-260,-31],[-239,-220],[16,-368],[136,-142],[284,35],[-55,-210],[-304,-103],[-377,-342],[-154,121],[61,277],[-304,173],[50,113],[265,197],[-80,135],[-432,149],[-19,221],[-257,-73],[-103,-325],[-215,-437],[6,-152],[-135,-128],[-84,56],[-78,-713],[-144,-245],[-101,-422],[89,-337],[33,-228],[243,-190],[-51,-145],[-330,-33],[-118,-182],[-232,-319],[-87,275],[3,122],[-169,17],[-145,56],[-336,-154],[192,-332],[-141,-96],[-154,0],[-147,304],[-52,-130],[62,-353],[139,-277],[-105,-130],[155,-272],[137,-171],[4,-334],[-257,157],[82,-302],[-176,-62],[105,-521],[-184,-7],[-228,257],[-104,472],[-49,393],[-108,272],[-143,337],[-18,168],[-48,41],[-5,130],[-154,199],[-24,281],[23,403],[38,184],[-46,93],[-59,46],[-78,192],[-120,118],[-261,218],[-161,213],[-254,176],[-233,435],[56,44],[-127,248],[-5,200],[-179,93],[-85,-255],[-82,198],[6,205],[10,9],[62,54],[-221,86],[-226,-210],[15,-293],[-34,-168],[91,-301],[261,-298],[140,-488],[309,-476],[217,3],[68,-130],[-78,-118],[249,-213],[204,-179],[238,-308],[29,-111],[-52,-211],[-154,276],[-242,97],[-116,-382],[200,-219],[-33,-309],[-116,-35],[-148,-506],[-116,-46],[1,181],[57,317],[60,126],[-108,342],[-85,298],[-115,74],[-82,255],[-179,107],[-120,238],[-206,38],[-217,267],[-254,384],[-189,341],[-86,584],[-138,68],[-226,195],[-128,-80],[-161,-274],[-115,-43],[-252,-334],[-548,160],[-404,-192],[-32,-355],[15,-344],[-263,-393],[-356,-125],[-25,-199],[-171,-327],[-107,-481],[108,-338],[-160,-263],[-60,-384],[-210,-118],[-197,-455],[-352,-8],[-265,11],[-174,-209],[-106,-223],[-136,49],[-103,199],[-79,340],[-259,92],[-112,-153],[-146,83],[-143,-65],[42,462],[-26,363],[-124,55],[-67,224],[22,386],[111,215],[20,239],[58,355],[-6,250],[-56,212],[-12,200],[14,420],[-114,257],[393,426],[340,-107],[373,4],[296,-101],[230,31],[449,-19],[144,354],[53,1177],[-287,620],[-205,299],[-424,228],[-28,430],[360,129],[466,-152],[-88,669],[263,-254],[646,461],[84,484],[243,119],[222,117],[143,162],[244,870],[380,247],[231,-17],[54,125],[232,32],[52,-130],[188,291],[-63,222],[-13,335],[-113,328],[-8,604],[46,159],[80,178],[244,36],[98,163],[223,167],[-9,-304],[-82,-192],[33,-166],[151,-89],[-68,-223],[-83,64],[-200,-425],[76,-288],[4,-228],[281,-138],[-3,-210],[283,111],[156,162],[313,-233],[132,-189],[189,174],[434,273],[350,200],[277,-100],[21,-144],[268,-7],[63,260],[383,191],[-59,497],[10,445],[136,371],[262,202],[221,-442],[223,12],[53,453],[32,349],[-102,-75],[-176,210],[-24,340],[351,164],[350,86],[301,-97],[287,17],[316,327],[-291,280],[-504,-47],[-489,-216],[-452,-125],[-161,322],[-269,195],[62,581],[-135,534],[133,344],[252,371],[635,640],[185,124],[-28,250],[-387,279],[-478,-167],[-269,-413],[43,-361],[-441,-475],[-537,-509],[-202,-832],[198,-416],[265,-328],[-255,-666],[-289,-138],[-106,-992],[-157,-554],[-337,57],[-158,-468],[-321,-27],[-89,558],[-232,671],[-211,835],[-187,363],[-548,-684],[-370,-138],[-385,301],[-99,635],[-88,1364],[256,380],[733,496],[549,609],[508,824],[668,1141],[465,444],[763,741],[610,259],[457,-31],[423,489],[506,-26],[499,118],[869,-433],[-358,-158],[305,-371],[286,206],[456,-358],[761,-140],[1050,-668],[213,-281],[18,-393],[-308,-311],[-454,-157],[-1240,449],[-204,-75],[453,-433],[36,-878],[358,-180],[217,-153],[36,286],[-174,263],[183,215],[672,-368],[234,144],[-187,433],[647,578],[256,-34],[260,-206],[161,406],[-231,352],[136,353],[-204,367],[777,-190],[158,-331],[-351,-73],[2,-328],[218,-203],[429,128],[68,377],[581,282],[969,507],[209,-29],[-273,-359],[344,-61],[199,202],[521,16],[412,245],[317,-356],[315,391],[-291,343],[145,195],[820,-179],[385,-185],[1006,-675],[186,309],[-282,313],[-8,125],[-335,58],[92,280],[-149,461],[-8,189],[512,535],[182,537],[207,116],[735,-156],[58,-328],[-263,-479],[173,-189],[89,-413],[-63,-809],[307,-362],[-120,-395],[-544,-839],[318,-87],[110,213],[306,151],[74,293],[240,281],[-162,336],[130,390],[-304,49],[-67,328],[222,594],[-361,481],[497,398],[-64,421],[139,13],[145,-328],[-109,-570],[297,-108],[-127,426],[465,233],[577,31],[513,-337],[-247,492],[-28,630],[484,119],[668,-26],[602,77],[-226,309],[321,388],[319,16],[540,293],[734,79],[93,162],[729,55],[227,-133],[624,314],[510,-10],[77,255],[265,252],[656,242],[476,-191],[-378,-146],[629,-90],[75,-292],[254,143],[812,-7],[626,-289],[223,-221],[-69,-307],[-307,-175],[-730,-328],[-209,-175],[345,-83],[410,-149],[250,112],[142,-379],[122,153],[444,93],[892,-97],[67,-276],[1162,-88],[15,451],[590,-103],[443,3],[449,-312],[128,-378],[-165,-247],[349,-465],[437,-240],[268,620],[446,-266],[473,159],[538,-182],[204,166],[455,-83],[-201,549],[367,256],[2509,-384],[236,-351],[727,-451],[1122,112],[553,-98],[231,-244],[-33,-432],[342,-168],[372,121],[492,15],[525,-116],[526,66],[484,-526],[344,189],[-224,378],[123,263],[886,-166],[578,36],[799,-282],[-99610,-258]],[[23933,96380],[-126,-17],[-521,38],[-74,165],[559,-9],[195,-109],[-33,-68]],[[19392,96485],[-518,-170],[-411,191],[224,188],[406,60],[392,-92],[-93,-177]],[[56867,96577],[-620,-241],[-490,137],[191,152],[-167,189],[575,119],[110,-222],[401,-134]],[[19538,97019],[-339,-115],[-461,1],[5,84],[285,177],[149,-27],[361,-120]],[[23380,96697],[-411,-122],[-226,138],[-119,221],[-22,245],[360,-24],[162,-39],[332,-205],[-76,-214]],[[22205,96856],[108,-247],[-453,66],[-457,192],[-619,21],[268,176],[-335,142],[-21,227],[546,-81],[751,-215],[212,-281]],[[79187,96845],[-1566,-228],[507,776],[229,66],[208,-38],[704,-336],[-82,-240]],[[55069,97669],[915,-440],[-699,-233],[-155,-435],[-243,-111],[-132,-490],[-335,-23],[-598,361],[252,210],[-416,170],[-541,499],[-216,463],[757,212],[152,-207],[396,8],[105,202],[408,20],[350,-206]],[[57068,98086],[545,-207],[-412,-318],[-806,-70],[-819,98],[-50,163],[-398,11],[-304,271],[858,165],[403,-142],[281,177],[702,-148]],[[64204,98169],[-373,-78],[-250,-45],[-39,-97],[-324,-98],[-301,140],[158,185],[-618,18],[542,107],[422,8],[57,-160],[159,142],[262,97],[412,-129],[-107,-90]],[[77760,97184],[-606,-73],[-773,170],[-462,226],[-213,423],[-379,117],[722,404],[600,133],[540,-297],[640,-572],[-69,-531]],[[25828,97644],[334,-190],[-381,-176],[-513,-445],[-492,-42],[-575,76],[-299,240],[4,215],[220,157],[-508,-4],[-306,196],[-176,268],[193,262],[192,180],[285,42],[-122,135],[646,30],[355,-315],[468,-127],[455,-112],[220,-390]],[[30972,99681],[742,-47],[597,-75],[508,-161],[-12,-157],[-678,-257],[-672,-119],[-251,-133],[605,3],[-656,-358],[-452,-167],[-476,-483],[-573,-98],[-177,-120],[-841,-64],[383,-74],[-192,-105],[230,-292],[-264,-202],[-429,-167],[-132,-232],[-388,-176],[39,-134],[475,23],[6,-144],[-742,-355],[-726,163],[-816,-91],[-414,71],[-525,31],[-35,284],[514,133],[-137,427],[170,41],[742,-255],[-379,379],[-450,113],[225,229],[492,141],[79,206],[-392,231],[-118,304],[759,-26],[220,-64],[433,216],[-625,68],[-972,-38],[-491,201],[-232,239],[-324,173],[-61,202],[413,112],[324,19],[545,96],[409,220],[344,-30],[300,-166],[211,319],[367,95],[498,65],[849,24],[148,-63],[802,100],[601,-38],[602,-37]],[[42472,99925],[1737,-469],[-513,-227],[-1062,-26],[-1496,-58],[140,-105],[984,65],[836,-204],[540,181],[231,-212],[-305,-344],[707,220],[1348,229],[833,-114],[156,-253],[-1132,-420],[-157,-136],[-888,-102],[643,-28],[-324,-431],[-224,-383],[9,-658],[333,-386],[-434,-24],[-457,-187],[513,-313],[65,-502],[-297,-55],[360,-508],[-617,-42],[322,-241],[-91,-208],[-391,-91],[-388,-2],[348,-400],[4,-263],[-549,244],[-143,-158],[375,-148],[364,-361],[105,-476],[-495,-114],[-214,228],[-344,340],[95,-401],[-322,-311],[732,-25],[383,-32],[-745,-515],[-755,-466],[-813,-204],[-306,-2],[-288,-228],[-386,-624],[-597,-414],[-192,-24],[-370,-145],[-399,-138],[-238,-365],[-4,-415],[-141,-388],[-453,-472],[112,-462],[-125,-488],[-142,-577],[-391,-36],[-410,482],[-556,3],[-269,324],[-186,577],[-481,735],[-141,385],[-38,530],[-384,546],[100,435],[-186,208],[275,691],[418,220],[110,247],[58,461],[-318,-209],[-151,-88],[-249,-84],[-341,193],[-19,401],[109,314],[258,9],[567,-157],[-478,375],[-249,202],[-276,-83],[-232,147],[310,550],[-169,220],[-220,409],[-335,626],[-353,230],[3,247],[-745,346],[-590,43],[-743,-24],[-677,-44],[-323,188],[-482,372],[729,186],[559,31],[-1188,154],[-627,241],[39,229],[1051,285],[1018,284],[107,214],[-750,213],[243,235],[961,413],[404,63],[-115,265],[658,156],[854,93],[853,5],[303,-184],[737,325],[663,-221],[390,-46],[577,-192],[-660,318],[38,253],[932,353],[975,-27],[354,218],[982,57],[2219,-74]]],"bbox":[-180,-85.60903777459777,180,83.64513000000002],"transform":{"scale":[0.0036000360003600037,0.0016925586033320111],"translate":[-180,-85.60903777459777]}} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css b/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css new file mode 100644 index 0000000..f8d2a01 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css @@ -0,0 +1,184 @@ +/* Fonts — remove this @import if your app already loads IBM Plex */ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +/* Переменные и резеты дашборда оскоплены на .cdash, чтобы не течь в остальное + приложение (см. INTEGRATION.md §6). В частности, --line здесь свой и не должен + переопределять --line из styles/theme.css на уровне всего приложения. */ +.cdash { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; + color:var(--hi); font-family:'IBM Plex Sans',system-ui,sans-serif; font-size:13px; + -webkit-font-smoothing:antialiased; +} +.cdash *{box-sizing:border-box;} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +.cdash button{font-family:inherit; cursor:pointer; color:inherit;} +.cdash ::-webkit-scrollbar{width:8px;height:8px;} +.cdash ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +.cdash ::-webkit-scrollbar-track{background:transparent;} + +/* Встроено вкладкой в shell с собственной шапкой: высота от родителя, не 100vh + (см. INTEGRATION.md §4). display:contents у обёртки вкладки делает .app прямым + элементом grid-строки shell (minmax(0,1fr)). */ +.app{height:100%; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/data.js b/services/pcp-tgu-ops-ui/src/features/constellation/data.js new file mode 100644 index 0000000..d66b900 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (ESM) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + См. INTEGRATION.md — эти массивы предполагается заменить данными из API. + ============================================================ */ +export const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + +export { sats as SATELLITES, stations as STATIONS, rois as ROIS }; + diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/index.js b/services/pcp-tgu-ops-ui/src/features/constellation/index.js new file mode 100644 index 0000000..ab53214 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/index.js @@ -0,0 +1,10 @@ +/* index.js — public entry for the dashboard module */ +export { default } from "./ConstellationDashboard.jsx"; +export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx"; + +// granular exports (in case you want to recompose the layout) +export { default as MapView } from "./MapView.jsx"; +export { default as Timeline } from "./Timeline.jsx"; +export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +export * as SIM from "./sim.js"; +export { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts new file mode 100644 index 0000000..fde7a1b --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts @@ -0,0 +1,270 @@ +/* ============================================================ + realData.ts — реальный источник данных для дашборда «Группировка». + Заменяет встроенную симуляцию (data.js/sim.js) данными из API по контрактам + INTEGRATION.md §5: + • состав — платформы НСИ (tgu-planning), станции, заявки (ROI); + • положение — реальная трасса flight-line (сэмплер позиции по времени); + • сброс — зоны радиовидимости (ЗРВ) из баллистики; + • съёмка — пролёт подспутниковой точки над полигоном заявки; + • тень — астрономическая модель из sim.js. + Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа, + так что ConstellationDashboard рендерит реальные данные без изменений разметки. + ============================================================ */ +import { fetchPlatforms, fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchSatelliteRva } from "../../api/ballisticsApi"; +import type { TguPlatform } from "../../model/tguTypes"; +import type { RequestMapItem } from "../../model/requestTypes"; +import { isOperationalPlatform } from "../tgu-planning/tguTimelineMapper"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import { parseLocal } from "../../utils/localTime"; +// sim.js/data.js — JS-модули прототипа (allowJs); типы any, нам достаточно. +import { buildSchedule, wrapLon } from "./sim.js"; +import { COL } from "./data.js"; + +type Payload = "optic" | "radar"; + +export interface ConstellationSat { + id: string; + name: string; + norad: number; + payload: Payload; + domain: "ОПТИКА" | "ЛОКАЦИЯ"; + group: string; + color: string; + mem0: number; +} + +export interface ConstellationStation { + id: string; + name: string; + lon: number; + lat: number; +} + +export interface ConstellationRoi { + id: string; + name: string; + payload: Payload; + poly: [number, number][]; +} + +export interface Interval { + start: number; + end: number; + label?: string; +} + +export interface ConstellationEnv { + epochMs: number; + sats: ConstellationSat[]; + stations: ConstellationStation[]; + rois: ConstellationRoi[]; + prohibits: Record; + /** Подспутниковая точка по реальной трассе; null — нет данных на это время. */ + subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null; +} + +export interface ConstellationConfig { + env: ConstellationEnv; + domain: { start: number; end: number }; + sched: Record; +} + +// --- классификация полезной нагрузки по имени КА (из НСИ) ------------------- +// Поля payload у платформы нет — определяем по имени/миссии (радиолокация vs оптика). +const RADAR_KEYS = [ + "кондор", "kondor", "обзор", "obzor", "sar", "рса", "radar", "локац", + "аркон", "arkon", "стрела", "strela", "пион", "neitron", "нейтрон" +]; + +function classifyPayload(name: string, mission: string | null | undefined): Payload { + const s = `${name} ${mission ?? ""}`.toLowerCase(); + return RADAR_KEYS.some((k) => s.includes(k)) ? "radar" : "optic"; +} + +// Человеческие названия известных миссий НСИ → подгруппа в списке/таймлайне. +const MISSION_RU: Record = { + RESURS_P: "Ресурс-П", + KANOPUS_V: "Канопус-В", + KONDOR_FKA: "Кондор-ФКА", + OBZOR_R: "Обзор-Р", + TERRA_AQUA: "Terra/Aqua" +}; + +function prettifyMission(mission: string): string { + return mission + .toLowerCase() + .split("_") + .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) + .join(" "); +} + +function deriveGroup(name: string, mission: string | null | undefined): string { + if (mission) return MISSION_RU[mission] ?? prettifyMission(mission); + // нет миссии — отрезаем хвост «No.3» / «№3» / завершающий номер + const base = name.replace(/\s*(No\.?|№)\s*\d+.*$/i, "").trim(); + return base || name; +} + +// --- сэмплер позиции по трассе flight-line --------------------------------- +type Sample = { t: number; lon: number; lat: number }; + +function buildSampler(points: FlightLinePoint[]): (t: number) => { lon: number; lat: number } | null { + const arr: Sample[] = points + .map((p) => ({ t: parseLocal(p.time), lon: wrapLon(p.long), lat: p.lat })) + .filter((s) => Number.isFinite(s.t) && Number.isFinite(s.lon) && Number.isFinite(s.lat)) + .sort((a, b) => a.t - b.t); + + if (arr.length === 0) return () => null; + const first = arr[0]; + const last = arr[arr.length - 1]; + + return (t: number) => { + if (t <= first.t) return { lon: first.lon, lat: first.lat }; + if (t >= last.t) return { lon: last.lon, lat: last.lat }; + let lo = 0; + let hi = arr.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (arr[mid].t <= t) lo = mid; + else hi = mid; + } + const a = arr[lo]; + const b = arr[hi]; + const f = (t - a.t) / (b.t - a.t || 1); + const lat = a.lat + (b.lat - a.lat) * f; + // долготу интерполируем по кратчайшей дельте, чтобы не прыгать через антимеридиан + let dlon = b.lon - a.lon; + if (dlon > 180) dlon -= 360; + else if (dlon < -180) dlon += 360; + return { lon: wrapLon(a.lon + dlon * f), lat }; + }; +} + +// --- ROI из заявок ---------------------------------------------------------- +const DEAD_REQUEST = new Set(["DELETED", "EXPIRED"]); + +function roiFromRequest(item: RequestMapItem): ConstellationRoi | null { + if (DEAD_REQUEST.has(item.status)) return null; + const rings = parseWktPolygon(item.geometry); + const ring = rings[0]; + if (!ring || ring.length < 3) return null; + const poly: [number, number][] = ring.map((pt) => [wrapLon(pt.lon), pt.lat]); + const payload: Payload = item.surveyType === "RSA" ? "radar" : "optic"; + return { id: item.id, name: item.name || item.id, payload, poly }; +} + +// --- порядок КА: домен (ОПТИКА → ЛОКАЦИЯ) → группа → имя -------------------- +// Список и таймлайн группируют по соседству, поэтому порядок важен. +function compareSats(a: ConstellationSat, b: ConstellationSat): number { + if (a.domain !== b.domain) return a.domain === "ОПТИКА" ? -1 : 1; + if (a.group !== b.group) return a.group.localeCompare(b.group, "ru"); + return a.name.localeCompare(b.name, "ru"); +} + +/** + * Грузит реальный состав и геометрию группировки и собирает конфиг дашборда. + * Горизонт по умолчанию now-6ч … now+18ч (как в прототипе). КА без трассы + * (например, не эксплуатируемые) на карту не попадают. + */ +export async function loadConstellationEnv(): Promise { + const now = Date.now(); + const domain = { start: now - 6 * 3600e3, end: now + 18 * 3600e3 }; + + const [platforms, stationDtos, requestItems] = await Promise.all([ + fetchPlatforms(), + fetchStations(), + fetchRequestsForMap({}) + .then((r) => r.items) + .catch(() => [] as RequestMapItem[]) + ]); + + const stations: ConstellationStation[] = (stationDtos as StationDto[]).map((s) => ({ + id: s.id ?? String(s.number), + name: s.name, + lon: s.position.long, + lat: s.position.lat + })); + const stationNameByNumber = new Map( + (stationDtos as StationDto[]).map((s) => [s.number, s.name]) + ); + + const rois: ConstellationRoi[] = requestItems + .map(roiFromRequest) + .filter((r): r is ConstellationRoi => r !== null); + + // Эксплуатируемые платформы с NORAD — кандидаты в группировку. + const candidates = (platforms as TguPlatform[]).filter( + (p) => isOperationalPlatform(p) && p.noradId != null + ); + + // Трассу и ЗРВ тянем по каждому КА параллельно; сбой по одному непрозрачен. + const perSat = await Promise.all( + candidates.map(async (p) => { + const norad = Number(p.noradId); + const [line, rva] = await Promise.all([ + fetchFlightLine(String(norad), domain.start, domain.end).catch(() => [] as FlightLinePoint[]), + fetchSatelliteRva(norad, domain.start, domain.end).catch(() => []) + ]); + return { p, norad, line, rva }; + }) + ); + + const sats: ConstellationSat[] = []; + const samplers: Record { lon: number; lat: number } | null> = {}; + const rvaBySat: Record = {}; + + for (const { p, norad, line, rva } of perSat) { + if (line.length === 0) continue; // нет трассы — на карте не разместить + const id = String(norad); + const name = p.name ?? id; + const payload = classifyPayload(name, p.mission); + sats.push({ + id, + name, + norad, + payload, + domain: payload === "radar" ? "ЛОКАЦИЯ" : "ОПТИКА", + group: deriveGroup(name, p.mission), + color: COL[payload], + mem0: (norad % 60) + 15 // памяти борта нет в телеметрии — модельная стартовая + }); + samplers[id] = buildSampler(line); + rvaBySat[id] = rva; + } + + sats.sort(compareSats); + + const prohibits: Record = Object.fromEntries(sats.map((s) => [s.id, []])); + + const env: ConstellationEnv = { + epochMs: now, + sats, + stations, + rois, + prohibits, + subSat: (sat, t) => (samplers[sat.id] ? samplers[sat.id](t) : null) + }; + + // Съёмка (по пролёту над ROI), тень и геометрический сброс — из реальных позиций. + // Шаг 0.5 мин, чтобы не проскочить мелкие полигоны заявок. + const sched = buildSchedule(sats, env, domain.start, domain.end, 0.5) as ConstellationConfig["sched"]; + + // Сброс заменяем авторитетными зонами радиовидимости, где они есть. + for (const s of sats) { + const rva = rvaBySat[s.id]; + if (!rva || rva.length === 0) continue; + sched[s.id].downlink = rva + .map((z) => ({ + start: parseLocal(z.onStart.time), + end: parseLocal(z.onStop.time), + label: stationNameByNumber.get(z.stationId) ?? `ст. ${z.stationId}` + })) + .filter((iv) => Number.isFinite(iv.start) && Number.isFinite(iv.end) && iv.end > iv.start) + .sort((a, b) => a.start - b.start); + } + + return { env, domain, sched }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js new file mode 100644 index 0000000..f35a7d5 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js @@ -0,0 +1,164 @@ +/* ============================================================ + sim.js — geometry + schedule (ESM) + Чистая геометрия подспутниковой точки, тень, расписание событий. + См. INTEGRATION.md — subSat()/statusAt() можно заменить на реальную эфемериду/API. + ============================================================ */ +const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // sub-satellite point: реальный сэмплер из env.subSat (эфемерида/flight-line), + // иначе встроенная синусоидальная симуляция. См. INTEGRATION.md §5.2. + function subSatOf(sat, tMs, env) { + return env.subSat ? env.subSat(sat, tMs) : subSat(sat, tMs, env.epochMs); + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSatOf(sat, tMs, env); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSatOf(s, t, env); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + +export { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, +}; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/util.js b/services/pcp-tgu-ops-ui/src/features/constellation/util.js new file mode 100644 index 0000000..0f91d08 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/util.js @@ -0,0 +1,9 @@ +/* util.js — small shared helpers (ESM) */ + +// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged +export function hexA(hex, a) { + if (typeof hex !== "string" || hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index 4230d3f..cb66811 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -10,6 +10,7 @@ import { import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab"; +import { ConstellationTab } from "../constellation/ConstellationTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; @@ -586,6 +587,12 @@ export function TguPlanningPage() {
)} + {visitedTabs.has("constellation") && ( +
+ +
+ )} + {visitedTabs.has("editor") && (
Заявки +
@@ -176,6 +315,13 @@ function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { return polygons; } +/** Подпись прогона в выпадающем списке истории: интервал, статус и счётчик режимов. */ +function formatRunOption(run: ComplexPlanRunSummary): string { + const interval = `${formatLocalDateTime(parseLocal(run.intervalStart))} — ${formatLocalDateTime(parseLocal(run.intervalEnd))}`; + const status = run.status === "COMPLETED" ? "" : ` · ${run.status}`; + return `#${run.runId} · ${interval} · режимов ${run.calculatedModesCount}${status}`; +} + function formatPercent(value: number | null | undefined): string { if (value == null || !Number.isFinite(value)) return "—"; return `${value.toFixed(1)}%`; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index fd19361..de9a1b2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -143,6 +143,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) — // блокирует повторный запуск и кнопки правок, пока не перечитаем включения. const [planCalcRunning, setPlanCalcRunning] = useState(false); + // Открыт диалог выбора режима очистки плана (всё / оставить ручные съёмки). + const [clearDialogOpen, setClearDialogOpen] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -877,27 +879,28 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; - // Полная очистка плана: сносит ВСЕ включения (съёмки, включая ручные, и сбросы) в БД черновика - // и в локальном представлении. Единственный способ убрать ручные маршруты массово (помимо - // ручного удаления по одному). Подтверждение — действие необратимо. - const clearPlan = async () => { + // Очистка плана. Оператор выбирает в диалоге, что снести: всё (включая ручные маршруты) или + // только рассчитанное (ручные съёмки source=MANUAL остаются, как при пересчёте плана). Сбросы + // удаляются в любом случае. Меняем БД черновика и локальное представление. Необратимо. + const clearPlan = async (keepManual: boolean) => { if (!selectedPlan || planCalcRunning) return; - // window здесь затенён состоянием окна таймлайна — берём confirm из globalThis. - if (!globalThis.confirm("Очистить план полностью? Будут удалены все включения, включая построенные вручную. Действие необратимо.")) { - return; - } + setClearDialogOpen(false); setPlanCalcRunning(true); setApplyError(undefined); setNotice(undefined); try { const draftId = await ensureDraft(); - await clearPlanModes(draftId); + await clearPlanModes(draftId, keepManual); await refreshDraftModes(draftId); changeDraft((current) => ({ ...current, works: [] })); setSelectedWorkId(undefined); setEditingWorkId(undefined); setRightMode("none"); - setNotice("План полностью очищен."); + setNotice( + keepManual + ? "Рассчитанные включения удалены. Ручные маршруты съёмки сохранены." + : "План полностью очищен." + ); } catch (error) { setApplyError(error instanceof Error ? error.message : "Не удалось очистить план."); } finally { @@ -1160,7 +1163,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }} onCheck={runCheck} onCalculatePlan={() => { void runPlanCalculation(); }} - onClearPlan={() => { void clearPlan(); }} + onClearPlan={() => setClearDialogOpen(true)} onZoom={zoom} /> @@ -1408,6 +1411,48 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, )}
+ + {clearDialogOpen && ( +
setClearDialogOpen(false)} + > +
e.stopPropagation()}> +
+

Очистить план

+
+

+ Что удалить из плана? Сбросы удаляются в любом случае. Действие необратимо. +

+
+ + +
+
+ +
+
+
+ )}
); } diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index bf9e6de..7f86e9f 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -1188,3 +1188,36 @@ border: 0; padding: 0; } + +/* Диалог выбора режима очистки плана (всё / оставить ручные съёмки). */ +.tgu-editor-clear-dialog { + width: min(26rem, calc(100vw - 2rem)); +} + +.tgu-editor-clear-dialog__text { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted, #9fb0b3); +} + +.tgu-editor-clear-dialog__choices { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.tgu-editor-clear-dialog__choices > .tgu-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + width: 100%; + padding: 0.6rem 0.75rem; + text-align: left; +} + +.tgu-editor-clear-dialog__choices > .tgu-button > small { + font-weight: 400; + font-size: 0.72rem; + opacity: 0.85; +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 4eb9fb3..cd56a02 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -102,6 +102,12 @@ color: #eafffd; } +.tgu-button.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + .tgu-button--accept { border-color: rgba(47, 158, 68, 0.65); background: rgba(47, 158, 68, 0.16); From 45fd6e8da9bfa9ca6344544917c71e1f7ae7520d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 6 Jun 2026 00:04:36 +0300 Subject: [PATCH 70/79] =?UTF-8?q?docs:=20=D0=B1=D1=8D=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=20=D0=B8=20=D0=B1=D1=80=D0=B8=D1=84=20pcp-tgu-ops-ui,=20?= =?UTF-8?q?ui-=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D1=82=D0=B8=D0=BF=D1=8B=20tgu?= =?UTF-8?q?-ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены docs/бэклог-pcp-tgu-ops-ui.md, бриф дашборда состояния группировки и прототипы docs/ui-prototypes/tgu-ops/; удалён устаревший docs/задачи для pcp-tgu-ops-ui.txt. --- .../INTEGRATION.md | 179 ++++++++++ .../README.md | 156 +++++++++ .../reference/js/app.jsx | 203 +++++++++++ .../reference/js/data.js | 73 ++++ .../reference/js/map.jsx | 331 ++++++++++++++++++ .../reference/js/panels.jsx | 180 ++++++++++ .../reference/js/sim.js | 158 +++++++++ .../reference/js/timeline.jsx | 160 +++++++++ .../reference/preview.html | 34 ++ .../reference/styles.css | 180 ++++++++++ .../src/ConstellationDashboard.jsx | 210 +++++++++++ .../src/MapView.jsx | 331 ++++++++++++++++++ .../src/Panels.jsx | 180 ++++++++++ .../src/Timeline.jsx | 160 +++++++++ .../src/dashboard.css | 183 ++++++++++ .../src/data.js | 73 ++++ .../src/index.js | 10 + .../src/sim.js | 158 +++++++++ .../src/util.js | 9 + .../бриф-дашборд-состояние-группировки.md | 115 ++++++ docs/бэклог-pcp-tgu-ops-ui.md | 42 +++ docs/задачи для pcp-tgu-ops-ui.txt | 21 -- 22 files changed, 3125 insertions(+), 21 deletions(-) create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js create mode 100644 docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js create mode 100644 docs/ui-prototypes/бриф-дашборд-состояние-группировки.md create mode 100644 docs/бэклог-pcp-tgu-ops-ui.md delete mode 100644 docs/задачи для pcp-tgu-ops-ui.txt diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md new file mode 100644 index 0000000..dbd43cf --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md @@ -0,0 +1,179 @@ +# INTEGRATION — встраивание вкладки «Состояние группировки» + +Цель: добавить дашборд новой вкладкой в существующий шелл (рядом с +`Планы / Карта / Комплексный план / Редактор / Заявки`), переключив встроенную +симуляцию на реальные данные. + +--- + +## 1. Зависимости + +```bash +npm i d3-geo topojson-client +# react / react-dom — уже есть в проекте +``` + +Версии, на которых собран прототип: `d3-geo@3`, `topojson-client@3`, `react@18`. + +--- + +## 2. Положить модуль + +Скопируйте папку `src/` в проект, например в `src/features/constellation/`. +Все импорты внутри — относительные, так что путь произвольный. + +`dashboard.css` импортируется автоматически из `ConstellationDashboard.jsx` +(`import "./dashboard.css"`). Если используете CSS-модули/Tailwind — см. п.6. + +--- + +## 3. Карта мира (TopoJSON) + +`MapView.jsx` сейчас тянет карту с CDN: + +```js +fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") +``` + +Для прода **положите файл локально** и раздавайте со своего origin: + +```js +// import landUrl from "../assets/land-110m.json?url"; // Vite +fetch(landUrl).then(r => r.json()).then(/* ... */) +``` + +Либо, если у вас уже есть карта (Leaflet/MapLibre/OpenLayers), рассмотрите +вариант рендерить слои группировки поверх неё — см. п.7. + +--- + +## 4. Монтирование как вкладки + +`src/index.js` отдаёт `default` = компонент `ConstellationDashboard` (без пропсов — +сейчас он сам строит данные из `data.js`/`sim.js`). + +Пример с массивом табов (адаптируйте под ваш роутер): + +```jsx +import ConstellationDashboard from "@/features/constellation"; + +const TABS = [ + { id: "plans", label: "Планы", element: }, + { id: "map", label: "Карта", element: }, + // ... + { id: "constel", label: "Группировка", element: }, // ← новая +]; +``` + +или с react-router: + +```jsx +} /> +``` + +### Важно про высоту +Корневой `.app` использует `height:100vh`, а `reference/styles.css` ставит +`body{overflow:hidden}`. Внутри вкладки с собственной шапкой это надо снять: + +- замените в `dashboard.css` `.app{height:100vh}` → `.app{height:100%}` и дайте + контейнеру вкладки реальную высоту (`height:100%` по цепочке родителей), **или** +- оставьте `100vh`, если вкладка занимает весь вьюпорт. +- правила для `body{…}` из `dashboard.css` лучше удалить/перенести, чтобы не влиять + на остальное приложение (или сразу оскопить — см. п.6). + +--- + +## 5. Контракты данных (главное) + +Сейчас данные синтетические. Замена — в двух файлах: `data.js` (что у нас есть) +и `sim.js` (где это находится во времени/пространстве). + +### 5.1 `data.js` — статический состав группировки + +```ts +type Payload = "optic" | "radar"; + +interface Satellite { + id: string; // уникальный ключ + name: string; // "Ресурс-П №3" + norad: number; + payload: Payload; // влияет на цвет и какие ROI снимает + domain: "ОПТИКА" | "ЛОКАЦИЯ"; // верхняя группировка в списке/таймлайне + group: string; // подгруппа ("Ресурс-П", "Кондор-ФКА", …) + color: string; // = COL[payload], проставляется автоматически + mem0: number; // стартовая загрузка памяти, % + orbit: { latAmp:number; period:number; phase:number; lon0:number }; // только для встроенной симуляции +} + +interface Station { id:string; name:string; lon:number; lat:number; } + +interface ROI { // заявка на съёмку (полигон) + id:string; name:string; payload:Payload; + poly:[number,number][]; // [[lon,lat], …] (без замыкающей точки) +} +``` + +Экспорты: `SATELLITES`, `STATIONS`, `ROIS`, `COL`. +Чтобы расширить до 20–30 КА и новых групп — просто добавляйте элементы; список, +сводка и таймлайн подхватывают автоматически (группировка идёт по `domain`→`group`). +Поле `orbit` нужно только встроенной симуляции (п.5.3). + +### 5.2 `sim.js` — геометрия и расписание + +Чистые функции, легко заменить на реальные источники: + +| Функция | Что возвращает | Чем заменить в проде | +|---|---|---| +| `subSat(sat, tMs, epochMs)` | `{lon,lat}` подспутниковой точки | реальная эфемерида/SGP4 по TLE на время `t` | +| `subSolar(tMs)` | `{lon,lat}` подсолнечной точки | можно оставить (астрономия точная) | +| `inShadow(lon,lat,tMs)` | в тени ли точка | по вашей модели освещённости | +| `statusAt(sat,tMs,env)` | `{state, p, roi?, station?, prohibit?, eclipse}` | **главный хук**: верните статус из вашего плана | +| `buildSchedule(...)` | интервалы `capture/downlink/prohibit/shadow` по КА на домене | отдайте готовые интервалы из API планирования | +| `buildProhibits(...)` | окна запрета по КА | из вашего сервиса ограничений (ВНЗ, питание и т.п.) | + +`state ∈ "operational" | "capture" | "downlink" | "prohibit"` (плюс флаг `eclipse`). +Приоритет в `statusAt`: запрет → съёмка → сброс → operational. + +### 5.3 Минимальная интеграция с API (рекомендуемый путь) +1. **Состав** (`SATELLITES/STATIONS/ROIS`) — из ваших справочников/`pcp-request-service`. +2. **Положение** — замените тело `subSat()` на расчёт по TLE (напр. `satellite.js`/SGP4), + `epochMs`/`period`/`latAmp`/`lon0` тогда не нужны. +3. **Расписание** — вместо `buildSchedule()` отдайте интервалы прямо из плана: + объект `{ [satId]: { capture:[], downlink:[], prohibit:[], shadow:[] } }`, + где интервал = `{ start:ms, end:ms, label?:string }`. Таймлайн и лента это рисуют как есть. +4. **Запреты** — `prohibit`-интервалы из сервиса ограничений (поле `reason` → подпись). +5. **Память борта** — если телеметрия известна, замените модельную динамику в RAF-цикле + `ConstellationDashboard.jsx` (блок `memory dynamics`) на реальные значения по `satId`. + +Домен времени задаётся в `buildEnv()` (`now-6h … now+18h`) — подгоните под горизонт плана. + +--- + +## 6. Изоляция стилей (если нужно) +`dashboard.css` — глобальные классы (`.app`, `.hdr`, `.side`, …) и `:root`-переменные. +Если боитесь конфликтов: +- оберните корень во `
` и добавьте префикс к селекторам + (или прогоните через CSS-модули / `@scope`), **или** +- оставьте как есть, если имена классов не пересекаются с вашими. +Переменные в `:root` безобидны (с префиксом `--bg0` и т.п.), но при желании +перенесите их на `.cdash`. + +--- + +## 7. (Опционально) рендер на вашей карте +Если в приложении уже есть карта (на скриншотах — тёмная equirectangular): +- слои **заявок (ROI)**, **станций**, **треков**, **полос обзора**, **линий сброса** + можно отрисовать вашими средствами (GeoJSON-слой / canvas-overlay), переиспользуя + геометрию из `sim.js` (`subSat`, footprint = окружность радиусом ~7–9° вокруг точки). +- тогда `MapView.jsx` нужен лишь как референс по слоям/цветам/порядку отрисовки. +- остальные части (`Header`, `SidePanel`, `Timeline`, RAF-часы/память) переиспользуются как есть. + +--- + +## 8. Проверка после встраивания +- [ ] Вкладка открывается, карта рисует сушу/сетку, КА движутся при Play. +- [ ] Скорости ×1…×600 меняют ход времени; «Сейчас» включает LIVE. +- [ ] Скраб по таймлайну двигает плейхед и пересчитывает статусы/карту. +- [ ] Клик по КА (список/карта/таймлайн) ставит фокус и затемняет остальных. +- [ ] Полосы памяти меняются на съёмке/сбросе. +- [ ] Высота/скролл вкладки корректны (см. п.4 «про высоту»). diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md new file mode 100644 index 0000000..6f8e9c1 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md @@ -0,0 +1,156 @@ +# Handoff: Дашборд состояния группировки (Mission Ops «Состояние группировки») + +## Overview +Полноэкранная информационная панель (operations wall) для мониторинга орбитальной +группировки КА в реальном времени. Показывает на карте мира движущиеся аппараты, +их маршруты съёмки, сбросы данных на наземные станции, интервалы запрета работы и +тень Земли. Снизу — таймлайн событий по каждому КА. Воспроизведение управляется +кнопкой Play со скоростями ×1/×10/×60/×600, есть скраб по времени и кнопка «Сейчас». + +Назначение: дежурная смена видит «что сейчас делает группировка» одним взглядом — +кто снимает, кто сбрасывает, кто в запрете/в тени, загрузку памяти бортов и +ближайшие события. + +## About the Design Files +Файлы в этом пакете — **дизайн-референс, реализованный на HTML/React-прототипе**. +Это эталон внешнего вида и поведения, **не обязательно финальный продакшн-код**. +Задача — **воссоздать этот интерфейс в вашем кодовом окружении** (судя по скриншотам — +React + Vite, карта в стиле тёмной equirectangular-проекции), используя ваши паттерны, +ваш роутинг табов и, по возможности, **вашу существующую карту и ваши данные из API** +вместо встроенной симуляции. + +Два слоя в пакете: +- `reference/` — рабочий автономный прототип (открывается в браузере, грузит React/d3 с CDN). + Нужен только чтобы «пощупать» поведение и свериться по пикселям. +- `src/` — те же экраны, переписанные в **чистые ESM-модули** (`import/export`, без CDN + и без `window.*`). Это то, что встраивается в приложение. См. `INTEGRATION.md`. + +## Fidelity +**High-fidelity.** Цвета, типографика, отступы, состояния и анимации — финальные. +Воссоздавайте 1:1, подменяя только источник данных (см. ниже) и, при желании, +рендер карты на вашу картографическую библиотеку. + +## Screens / Views +Один экран, 4 функциональные зоны в CSS-grid/flex. + +### 1. Topbar (шапка) — `Header` +- **Layout:** горизонтальный флекс, высота `54px`, нижняя граница `1px var(--line)`, + фон `linear-gradient(var(--bg1),var(--bg0))`. Три блока: бренд (слева, `min-width:248px`), + транспорт (центр, `flex:1`, по центру), часы (справа, `min-width:248px`, выровнено вправо). +- **Бренд:** кружок-маркер `20px` с бирюзовым свечением + «СОСТОЯНИЕ ГРУППИРОВКИ» + (600, 13px, `nowrap`) и «MISSION OPS · ТГУ» (mono, 9px, `--dim`, letter-spacing .12em). +- **Транспорт:** кнопки `−1ч`, Play/Pause (бирюзовая, 42px), `+1ч`; разделитель; + сегмент скоростей `×1 ×10 ×60 ×600` (активная — бирюзовый фон); кнопка `СЕЙЧАС` + (в live-режиме — зелёная). +- **Часы:** бейдж `LIVE`/`СИМ` (LIVE — зелёная пульсирующая точка), крупное mono-время + `HH:MM:SS` (22px) + `UTC`, ниже `ДЕНЬ · ДД.ММ.ГГГГ` (mono, 10px). + +### 2. Left panel (левая панель) — `SidePanel` +Ширина `328px`, фон `--bg1`, правая граница, вертикальный флекс, скролл внутри. +- **СВОДКА ГРУППИРОВКИ:** 4 плитки (grid 4×1): СНИМАЮТ / СБРОС / ЗАПРЕТ / В ТЕНИ — + число (mono, 21px, цвет статуса) + подпись (8px, `--dim`). Под ними строка + «● ОПТИКА n ● ЛОКАЦИЯ n · N КА». +- **Поиск:** input (mono, 11.5px) — фильтрует список по name / NORAD / группе. +- **КОСМИЧЕСКИЕ АППАРАТЫ:** список, сгруппированный `ОПТИКА`→группы, `ЛОКАЦИЯ`→группы. + Каждая строка КА: цветная риска слева (cyan=оптика / violet=локация), имя (500, 12.5px), + статус справа (mono, 9px, цвет статуса, иконка ☾ если в тени), снизу `NORAD XXXXX`, + полоса памяти (4px) и `NN%`. Цвет памяти: бирюза → амбер >78% → красный >92%. + Клик по строке = фокус КА (подсветка только его событий). +- **АКТИВНЫЕ СОБЫТИЯ:** лента текущих событий (СЪЁМКА/СБРОС/ЗАПРЕТ) с целью/станцией + и обратным отсчётом «ещё M:SS». Клик = фокус соответствующего КА. + +### 3. Map (карта) — `MapView` +Занимает всё центральное пространство (`flex:1`). Canvas 2D, equirectangular-проекция +(d3-geo), повёрнута на `rotate([-10,0])`. Слои: +- База (кэш offscreen): океан `#0a0e0f`, сетка градусов (шаг 30°), суша `#171c1e` + (контур `#252d30`), прямоугольники заявок (ROI) пунктиром амбер, подписи станций (mono 9px). +- Тень Земли: ночное полушарие (d3.geoCircle от антисолнечной точки, r=90°) заливкой + `rgba(20,28,46,0.55)` с кромкой-терминатором. +- По каждому КА: синусоидальный трек (±52 мин, цвет по типу полезной нагрузки), + «полоса обзора» (footprint, geoCircle), глиф-ромб с гало и кольцом-статусом, подпись. +- Съёмка: амбер-утолщение трека + подсветка полигона заявки. +- Сброс: анимированная пунктирная синяя линия КА→станция + свечение станции. +- Станции: белые треугольники. +- Оверлеи (абсолютно): подсказка/карточка фокуса (сверху-слева), легенда (снизу-слева), + координаты курсора (снизу-справа, mono), тултип имени КА при наведении. + +### 4. Timeline (таймлайн) — `Timeline` +Высота `248px`, фон `--bg1`, верхняя граница. Шапка: «ТАЙМЛАЙН СОБЫТИЙ» + легенда +(СЪЁМКА/СБРОС/ЗАПРЕТ РАБОТЫ(штриховка)/ТЕНЬ ЗЕМЛИ) + статус режима. Тело — SVG: +- Левая колонка (`188px`): группы и имена КА с цветной точкой. +- Ось времени сверху (часовые тики, на полночь — дата). +- Дорожка каждого КА: полосы событий — тень (низ, слейт), сброс (синий), съёмка (амбер), + запрет (красная штриховка, во всю высоту). +- Вертикальный плейхед (бирюза в live, амбер в симуляции) + пунктирная линия «СЕЙЧАС» + (реальное время). Зажатие+перетаскивание = скраб времени. Клик по строке = фокус КА. + +## Interactions & Behavior +- **Play/Pause:** запуск/останов модельного времени. +- **Скорость ×1/×10/×60/×600:** множитель к ходу модельного времени (×1 = реальное). +- **СЕЙЧАС:** включает live-режим (время = системные часы, ×1, бейдж LIVE, зелёная пульсация). +- **−1ч / +1ч:** сдвиг модельного времени на час (выходит из live). +- **Скраб по таймлайну:** установка времени перетаскиванием (выходит из live). +- **Фокус КА:** клик в списке / на карте / в таймлайне / в ленте — подсвечивает один КА, + затемняет остальные; повторный клик снимает фокус. +- **Наведение на карту:** тултип имени ближайшего КА + координаты курсора (lat/lon). +- **Анимации:** бегущие пунктиры линий сброса, пульсация live-точки (1.4s), плавные + переходы полос памяти. Без бесконечных декоративных лупов на контенте. + +## State Management +Всё состояние — в корневом компоненте (`ConstellationDashboard.jsx`): +- `simRef` (модельное время, ms) — в `ref`, чтобы canvas рисовался на 60fps без ре-рендеров. +- `playingRef`, `speedRef`, `liveRef` — режим воспроизведения. +- `focusId` (state + ref) — выбранный КА. +- `memRef` — карта «загрузка памяти %» по КА (растёт при съёмке, падает при сбросе). +- `query` — строка поиска. `cursor`, `hover` — UI карты. +- RAF-цикл двигает время и память, рисует карту императивно (`mapApi.draw`), а панели/таймлайн + ре-рендерит троттлингом (~12 fps) через `setTick`. Производные (статусы, счётчики, + активные события) вычисляются в `render` из `simRef` + `memRef`. + +> Контракты данных и точки замены симуляции на реальное API — в `INTEGRATION.md`. + +## Design Tokens +Из `src/dashboard.css` (`:root`): +- **Фоны:** `--bg0 #0a0c0b` (канвас), `--bg1 #0e1211` (панели), `--panel #111514`, + `--ins #0a0e0d` (вставки/карта). +- **Линии:** `--line rgba(255,255,255,.06)`, `--line2 rgba(255,255,255,.11)`. +- **Текст:** `--hi #e7ece9`, `--mid #95a19d`, `--dim #5b6662`, `--faint #3c4644`. +- **Акценты:** `--teal #5fb3a3` (управление/активное), `--amber #d99a4e` (съёмка/заявки), + `--blue #5b9bd5` (сброс), `--red #d9685f` (запрет), `--green #5cae7e` (operational), + `--violet #a78bdb` (локация/РСА). +- **Тип нагрузки:** ОПТИКА `#56c2c8` (cyan), ЛОКАЦИЯ `#a78bdb` (violet) — `COL` в `data.js`. +- **Карта:** океан `#0a0e0f`, суша `#171c1e`/контур `#252d30`, ночь `rgba(20,28,46,.55)`. +- **Типографика:** IBM Plex Sans (UI), IBM Plex Mono (числа/координаты/ID/метки). + Заголовки секций — капс, letter-spacing .14–.16em. +- **Скругления:** 5–8px. **Высоты:** topbar 54, timeline 248, строка таймлайна 22. + +## Assets +- Карта мира: TopoJSON `world-atlas@2` → `land-110m.json` (грузится в `MapView`). + В проде положите файл локально или используйте свою карту (см. `INTEGRATION.md`). +- Иконок-картинок нет: станции/КА рисуются на canvas, бренд-маркер — CSS. +- Шрифты: IBM Plex Sans/Mono (Google Fonts; `@import` в `dashboard.css` можно убрать, + если шрифты уже есть в приложении). + +## Files +``` +design_handoff_constellation_dashboard/ +├── README.md ← этот файл (дизайн-референс) +├── INTEGRATION.md ← как встроить вкладку + контракты данных +├── reference/ ← автономный прототип «как должно выглядеть» +│ ├── preview.html ← открыть в браузере +│ ├── styles.css +│ └── js/ (data, sim, map, timeline, panels, app — версия с CDN/window.*) +└── src/ ← ESM-модули для встраивания + ├── index.js ← точка входа (default = ConstellationDashboard) + ├── ConstellationDashboard.jsx← корневой компонент вкладки + ├── MapView.jsx ← canvas-карта + ├── Timeline.jsx ← таймлайн событий + ├── Panels.jsx ← Header + SidePanel + ├── sim.js ← геометрия/расписание (заменяемо на API) + ├── data.js ← модель группировки (заменяемо на API) + ├── util.js ← хелперы + └── dashboard.css ← все стили +``` + +См. **`INTEGRATION.md`** — пошагово: зависимости, монтирование как вкладки, +контракты данных и где подключить реальные эфемериды/расписание. diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx new file mode 100644 index 0000000..8d3c4a9 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx @@ -0,0 +1,203 @@ +/* ============================================================ + app.jsx — wiring, RAF clock, memory sim, derived state + ============================================================ */ +const { useState, useEffect, useRef, useMemo } = React; + +function buildEnv() { + const epochMs = Date.now(); + const { sats, stations, rois } = window.OPS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +function App() { + const cfg = useMemo(buildEnv, []); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: window.fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js new file mode 100644 index 0000000..7ff8b83 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (plain JS, attaches window.OPS) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + ============================================================ */ +(function () { + const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + + window.OPS = { sats, stations, rois, COL }; +})(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx new file mode 100644 index 0000000..3d9f07a --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx @@ -0,0 +1,331 @@ +/* ============================================================ + map.jsx — canvas world map (exports window.MapView) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +const { useRef, useEffect } = React; + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs); + const pp = s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +function hexA(hex, a) { + if (hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} + +window.MapView = MapView; +window.hexA = hexA; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx new file mode 100644 index 0000000..555036d --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx @@ -0,0 +1,180 @@ +/* ============================================================ + panels.jsx — header + left panel (exports window.Header, + window.SidePanel). Status meta, summary, roster, activity feed. + ============================================================ */ +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`, + date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + UTC +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +window.Header = Header; +window.SidePanel = SidePanel; +window.STATUS_META = STATUS_META; +window.fmtDur = fmtDur; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js new file mode 100644 index 0000000..afb4ac8 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js @@ -0,0 +1,158 @@ +/* ============================================================ + sim.js — geometry + schedule (plain JS, attaches window.SIM) + ============================================================ */ +(function () { + const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSat(sat, tMs, env.epochMs); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSat(s, t, env.epochMs); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + + window.SIM = { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, + }; +})(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx new file mode 100644 index 0000000..af1f142 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + timeline.jsx — bottom event timeline (exports window.Timeline) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +const { useRef: useRefTL, useEffect: useEffectTL, useState: useStateTL } = React; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const isDay = d.getUTCHours() === 0; + return ( + + + + {isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +window.Timeline = Timeline; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html new file mode 100644 index 0000000..af71843 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html @@ -0,0 +1,34 @@ + + + + + +Состояние группировки — Mission Ops + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css new file mode 100644 index 0000000..12d5124 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css @@ -0,0 +1,180 @@ +:root { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; +} +*{box-sizing:border-box;} +html,body{height:100%;margin:0;} +body{ + background:var(--bg0); color:var(--hi); + font-family:'IBM Plex Sans',system-ui,sans-serif; + font-size:13px; -webkit-font-smoothing:antialiased; + overflow:hidden; +} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +button{font-family:inherit; cursor:pointer; color:inherit;} +::-webkit-scrollbar{width:8px;height:8px;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +::-webkit-scrollbar-track{background:transparent;} + +.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx new file mode 100644 index 0000000..1cabe32 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx @@ -0,0 +1,210 @@ +/* ============================================================ + ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM) + Default export: — монтируется как вкладка. + ============================================================ */ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import MapView from "./MapView.jsx"; +import Timeline from "./Timeline.jsx"; +import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +import * as SIM from "./sim.js"; +import { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; +import "./dashboard.css"; + +function buildEnv() { + const epochMs = Date.now(); + const sats = SATELLITES, stations = STATIONS, rois = ROIS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +function App() { + const cfg = useMemo(buildEnv, []); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +export default App; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx new file mode 100644 index 0000000..1180930 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx @@ -0,0 +1,331 @@ +/* ============================================================ + MapView.jsx — canvas world map (ESM, default export) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +import React, { useRef, useEffect } from "react"; +import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo"; +import { feature } from "topojson-client"; +import * as SIM from "./sim.js"; +import { hexA } from "./util.js"; + +// keep the original call-sites (d3.* / topojson.*) working unchanged +const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle }; +const topojson = { feature }; + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs); + const pp = s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +export default MapView; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx new file mode 100644 index 0000000..a934870 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx @@ -0,0 +1,180 @@ +/* ============================================================ + Panels.jsx — header + left panel (ESM) + Exports: Header, SidePanel, STATUS_META, fmtDur. + ============================================================ */ +import React from "react"; +import { COL } from "./data.js"; + +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`, + date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + UTC +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +export { Header, SidePanel, STATUS_META, fmtDur }; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx new file mode 100644 index 0000000..9351480 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + Timeline.jsx — bottom event timeline (ESM, default export) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react"; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const isDay = d.getUTCHours() === 0; + return ( + + + + {isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +export default Timeline; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css new file mode 100644 index 0000000..31f9ab6 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css @@ -0,0 +1,183 @@ +/* Fonts — remove this @import if your app already loads IBM Plex */ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +:root { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; +} +*{box-sizing:border-box;} +html,body{height:100%;margin:0;} +body{ + background:var(--bg0); color:var(--hi); + font-family:'IBM Plex Sans',system-ui,sans-serif; + font-size:13px; -webkit-font-smoothing:antialiased; + overflow:hidden; +} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +button{font-family:inherit; cursor:pointer; color:inherit;} +::-webkit-scrollbar{width:8px;height:8px;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +::-webkit-scrollbar-track{background:transparent;} + +.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js new file mode 100644 index 0000000..d66b900 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (ESM) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + См. INTEGRATION.md — эти массивы предполагается заменить данными из API. + ============================================================ */ +export const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + +export { sats as SATELLITES, stations as STATIONS, rois as ROIS }; + diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js new file mode 100644 index 0000000..ab53214 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js @@ -0,0 +1,10 @@ +/* index.js — public entry for the dashboard module */ +export { default } from "./ConstellationDashboard.jsx"; +export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx"; + +// granular exports (in case you want to recompose the layout) +export { default as MapView } from "./MapView.jsx"; +export { default as Timeline } from "./Timeline.jsx"; +export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +export * as SIM from "./sim.js"; +export { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js new file mode 100644 index 0000000..2494881 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js @@ -0,0 +1,158 @@ +/* ============================================================ + sim.js — geometry + schedule (ESM) + Чистая геометрия подспутниковой точки, тень, расписание событий. + См. INTEGRATION.md — subSat()/statusAt() можно заменить на реальную эфемериду/API. + ============================================================ */ +const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSat(sat, tMs, env.epochMs); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSat(s, t, env.epochMs); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + +export { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, +}; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js new file mode 100644 index 0000000..0f91d08 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js @@ -0,0 +1,9 @@ +/* util.js — small shared helpers (ESM) */ + +// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged +export function hexA(hex, a) { + if (typeof hex !== "string" || hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} diff --git a/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md b/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md new file mode 100644 index 0000000..af9dd3f --- /dev/null +++ b/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md @@ -0,0 +1,115 @@ +# Хэндофф для Claude Design — дашборд «Состояние группировки» + +Этот файл — то, что прикладываем в чат Claude Design вместе со скриншотами текущего UI. +Цель: спроектировать **новый экран «Состояние группировки»** в едином стиле с уже сделанными +прототипами (`pcp-tgu-mission-ops`, `pcp-service-map`, `pcp-tgu-editor`), но на **реальных +токенах продукта**. + +Эталон стиля — **реальный код** pcp-tgu-ops-ui (`src/styles/theme.css` + `editor-tokens.css`), +а не oklch/IBM Plex из прежних прототипов. Ниже — те же имена токенов, что в прототипах, +но с боевыми значениями. Используй их as-is. + +--- + +## 1. Токены (вставить в `:root` нового прототипа) + +```css +:root { + /* Фоны (тёмная пультовая тема) */ + --bg-0: #111315; /* база */ + --bg-1: #171a1c; /* панель */ + --bg-2: #1f2325; /* панель 2 */ + --bg-3: #262b2e; /* приподнятый элемент */ + --line: #30373a; + --line-soft: #252b2d; + --grid: #252b2d; + + /* Текст */ + --ink: #e7ecec; + --ink-1: #e7ecec; + --ink-2: #a4b0b1; + --ink-3: #748183; + + /* Акцент / статусы */ + --accent: #68c3bd; /* бирюза — основной акцент */ + --accent-strong: #82ded8; + --danger: #d64545; + --warning: #f0ad2e; + --success: #2f9e44; + --now: #f0ad2e; /* линия «сейчас» */ + --warn: #f0ad2e; + + /* Типы аппаратуры (ДЗЗ): оптика / радар / комбо */ + --t-optical: #68c3bd; + --t-optical-dim: rgba(104, 195, 189, 0.18); + --t-radar: #f0ad2e; + --t-radar-dim: rgba(240, 173, 46, 0.16); + --t-combo: #d64545; + --t-combo-dim: rgba(214, 69, 69, 0.16); + + /* Шрифты */ + --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + + --radius: 7px; + --shadow: 0 16px 42px rgba(0, 0, 0, 0.28); +} +``` + +**Правила стиля, обязательные к соблюдению:** +- Все **числа, идентификаторы, времена** — моноширинным (`--mono`). Это узнаваемая черта UI. +- Подписи/заголовки — `--sans` (Inter). +- Никаких oklch и IBM Plex — только значения выше. + +--- + +## 2. Что изменилось в продукте с момента прежних прототипов + +Если будешь заодно подравнивать существующие морды — учти эти расхождения с тем, что было +сгенерировано раньше: + +- **Редактор:** левая панель информации шире (≈17rem вместо 15.5rem) — числа больше не липнут к краю. +- **Слоты:** статусы слотов проставляются и красятся **только после закладки плана на КА** + (статус `LAID_IN`), а не по «принятию». До закладки — нейтральны. +- **Закладка:** кнопка «Подтвердить закладку» заблокирована, пока начало закладки в будущем. +- **Комплексный план (вкладка):** появились история прогонов (выпадающий список прошлых расчётов), + тумблер «Показать заявки» (оверлей заявок на карте) и ограничение интервала расчёта — **не более 10 суток**. +- **Редактор / правка включения:** инспектор включения read-only; правка идёт через + «Редактировать» → предзаполненное окно построения, которое заменяет маршрут. +- **Таймлайн:** стрелки цепочки версий плана между выдачами. +- **Статусы плана:** `LAID_IN` и `SUPERSEDED` теперь реальны; `ACCEPTED` не возникает. + +--- + +## 3. ТЗ на новый экран «Состояние группировки» + +Оперативный дашборд состояния орбитальной группировки ДЗЗ. Главное — «живая» карта с +проигрыванием и подсветкой того, что снимается прямо сейчас. + +**Раскладка (предложение, можно улучшить):** +- Крупная карта мира (2D, как в `pcp-service-map`) — основная область. +- Снизу — компактная **полоса проигрывания**: кнопка ▶/⏸, скорость (1×/10×/60×/300×), + ползунок времени, текущее модельное время моноширинным. +- Слева/справа — узкая панель: список КА группировки со статусом (на связи / съёмка / сброс / + вне зоны), фильтр по типу аппаратуры (оптика/радар/комбо), счётчики. +- Верхняя строка — сводка: всего КА, сейчас снимают N, активных маршрутов M, ближайший сеанс. + +**Поведение «плей»:** +- По ▶ модельное время идёт вперёд; **метки КА двигаются по трассам** в реальном (масштабируемом) времени. +- Маршруты/полосы, которые **снимаются в текущий момент времени**, подсвечиваются (акцентом/типом + аппаратуры), у КА в режиме съёмки — выделенное состояние. +- При паузе — всё замирает, можно кликнуть КА/маршрут и увидеть детали (как инспектор в редакторе). +- Линия «сейчас» (`--now`) отделяет факт от прогноза, если будет мини-таймлайн снизу. + +**Данные (моки в `data.jsx`, сид-генерация как в прежних прототипах):** +- `spacecraft[]`: { id, name, group, type: optical|radar|combo, ... } + упрощённая трасса + (последовательность {t, lat, lon}) для анимации позиции по времени. +- `captures[]` (включения): { id, scId, tStart, tEnd, footprint (полигон/полоса), requestId? } — + по ним подсвечиваем «снимается сейчас», когда `tStart ≤ now ≤ tEnd`. +- `NOW` зафиксировать под дату проекта, диапазон — несколько суток. + +**Формат сдачи:** новая папка `docs/ui-prototypes/pcp-grouping-dashboard/` в том же устройстве, +что прежние прототипы: `app.jsx` (корень) + `data.jsx` (моки) + модули (`mapview.jsx`, +`timeline.jsx`/`playbar.jsx`, `sidebar.jsx`, `details.jsx`, `util.jsx`) + HTML-обёртка +(React/Babel из CDN, блок `:root` из раздела 1). Переиспользуй `basemap.js`/`world.js`/`orbit.jsx` +из `pcp-service-map`, если подходят. diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md new file mode 100644 index 0000000..b406ffa --- /dev/null +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -0,0 +1,42 @@ +# Бэклог pcp-tgu-ops-ui + +Список задач по сервису **pcp-tgu-ops-ui** (и связанным бэкендам). +Ведётся вручную и с помощью Claude: по просьбе — отмечаем выполненное и добавляем новые задачи. + +**Обозначения статуса:** +- `[ ]` — не начато +- `[~]` — в работе +- `[x]` — выполнено (переносится в раздел «Выполнено» с пометкой) + +У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер. + +_Обновлено: 2026-06-05_ + +--- + +## Активные задачи + +### Карта и отображение +- [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца. +- [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. +- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». На вкладке «Редактор» они должны быть другого цвета и выделяться. + +### Редактор +- [ ] **#4** — Показывать на вкладке «Редактор» только те заявки, которые может удовлетворить аппарат (например, для оптики не показывать заявку, для которой доступна только локация). +- [ ] **#5** — Дать возможность превратить план в статусе `PLANNED` (построенный цепочкой от последнего `LAID_IN`) в черновик и вручную наполнить его включениями — до того, как он будет автоматически выдан по таймеру. + +### Комплексный план +- [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки. + +### Бэкенд / инфраструктура +- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). + +### Баги +- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». + +--- + +## Выполнено +- [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._ +- [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._ +- [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._ diff --git a/docs/задачи для pcp-tgu-ops-ui.txt b/docs/задачи для pcp-tgu-ops-ui.txt deleted file mode 100644 index f26a955..0000000 --- a/docs/задачи для pcp-tgu-ops-ui.txt +++ /dev/null @@ -1,21 +0,0 @@ -TODO: -- Отображение трасс полос исходя их возможности съемки КА. Для оптики нужно отображать только части, которые находятся в допустимом угле солнца. -- Отображение заявок на вкладке редактор только тех, которые может удовлетворить аппарат (не показывать только локацию для оптики например) -- Чуть расширить вправо левую панель с информацией на вкладке Редактор ибо цифры на границе смотрятся плохо -- Проставлять статусы слотов только после того, как план заложен на КА -- Когда мы кликаем по полосам обзора, если там пересекаются несколько, то их надо выводить списком на панели справа. И при нажатии на полосу обзора на панели выделять объект на карте. -- Ошибка при попытке принять план во вкладке планы -- Перевод сервиса станций на ordinis по примеру получения аппаратов в сервисе tgu -- Отображение предыдущих планов и следующих. Предлагаю не просто сделать отображение как рамку, я делать еле заметные все другие планы, а эти обводить яркими рамками, чтобы было видно какой план прошлый, а какой будущий. -Прошлый: #94A3B8 slate-400 -Текущий: #2563EB blue-600 -Следующий: #F59E0B amber-500 -Предлагаю использовать эти цвета -- Нужна кнопка создания нового плана от этого, аля как черновик, но не черновик -- При создании черновика, базовый план у черновика должен быть как у исходного плана, а не исходный план. -- Деактивировать отмененный план, чтобы он не портил статистику -- Если план заложен, то все отмененные планы до него (время начала которых меньше времени начала этого) не должны влиять на цветовую индикацию плана на вкладке Планы - - - - From a06dfcac4eb3d3b1c82baaf8539d828afc300024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 6 Jun 2026 00:12:27 +0300 Subject: [PATCH 71/79] =?UTF-8?q?docs:=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20?= =?UTF-8?q?kafka/http/camunda=20=D0=B2=D0=B7=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D0=B9=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architect/PCP_SERVICE_INTERACTIONS.md | 224 +++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/architect/PCP_SERVICE_INTERACTIONS.md diff --git a/docs/architect/PCP_SERVICE_INTERACTIONS.md b/docs/architect/PCP_SERVICE_INTERACTIONS.md new file mode 100644 index 0000000..9e1dd06 --- /dev/null +++ b/docs/architect/PCP_SERVICE_INTERACTIONS.md @@ -0,0 +1,224 @@ +# Карта взаимодействий сервисов PCP + +> Составлено по исходникам (`services/*/src/main`) и конфигам (`config-repo/`). +> Три канала интеграции: **Kafka** (события), **HTTP/REST** (синхронные запросы), **Camunda 8 / Zeebe** (оркестрация процессов). +> Имена топиков и адреса сервисов берутся из Spring Cloud Config (`config-repo/*.yaml`, +> общая карта адресов — `application-dev.yaml` / `application-local.yaml`, ключи `pcp.services.*`). + +## Содержание +1. [Перечень сервисов и порты](#1-перечень-сервисов-и-порты) +2. [Kafka — карта топиков и событий](#2-kafka--карта-топиков-и-событий) +3. [HTTP/REST — карта вызовов между сервисами](#3-httprest--карта-вызовов-между-сервисами) +4. [Camunda — процессы и воркеры](#4-camunda--процессы-и-воркеры) + +--- + +## 1. Перечень сервисов и порты + +Локальные порты — из `config-repo/application-local.yaml` (`pcp.ports.*`). В dev/docker все слушают `8080`. + +| Сервис | Локальный порт | Роль | +|---|---|---| +| `spring-cloud-config-server` | 8888 | раздача конфигов | +| `pcp-srpring-boot-admin-server` | (admin) | мониторинг Spring Boot Admin | +| `tle-monitoring-service` | 7001 | загрузка/раздача TLE (CelesTrak) | +| `pcp-complex-mission-service` | 7002 | комплексные планы/миссии, режимы съёмки КА | +| `pcp-ballistics-service` | 7003 | баллистика, расчёт НУ/витков/трасс (он же `ic-service`) | +| `pcp-request-service` | 7005 | заявки, маршруты-привязки, outbox | +| `slots-service` | 7006 | слоты, бронирования, расчёт покрытия | +| `pcp-ui-service` | 7008 | BFF/агрегатор для старого UI | +| `pcp-stations-service` | 7009 | каталог наземных станций | +| `pcp-mission-planing-service` | 7010 | планирование миссий + **Camunda job-воркеры** | +| `pcp-tgu-service` | 7011 | ТГУ: выдача планов, **запуск Camunda-процессов** | +| `pcp-coverage-scheme-service` | 7012 | расчёт схем покрытия | +| `pcp-satellite-catalog-service` | 7013 | каталог КА | +| `pcp-dynamic-plan-service` | 7014 | динамические/комплексные планы (calcPlan) | +| `pcp-route-processing-service` | — | обработка маршрутных паспортов | + +`pcp-tgu-ops-ui` — фронтенд (Vite), ходит в микросервисы напрямую через прокси, в этой карте не участвует как backend. + +--- + +## 2. Kafka — карта топиков и событий + +Событие — JSON-конверт `KafkaMessage` с заголовком `type` (значения из `PcpKafkaEvent`). +Консьюмеры фильтруют по этому заголовку. + +### Топики + +| Топик | Продюсер(ы) | Консьюмер(ы) | Событие / назначение | +|---|---|---|---| +| `pcp.tle` *(default-topic)* | `tle-monitoring-service` (TLE); `slots-service` (`ICRVPlacedEvent`) | `pcp-ballistics-service` (фильтры `placedIcFilter`, `updatedIcFilter`, `placedIcRvFilter`) | общий «шина TLE / НУ-РВ»: новые TLE и заложенные начальные условия | +| `pcp.satellites` | `pcp-satellite-catalog-service` (`SatelliteDeletedEvent`) | `tle-monitoring`, `slots-service`, `pcp-complex-mission-service`, `pcp-dynamic-plan-service`, `pcp-ballistics-service`, `pcp-mission-planing-service` | удаление КА (fan-out для очистки производных данных) | +| `pcp.slots.status.v1` | `pcp-mission-planing-service` (`BookedSlotsStatusChangedEvent`) | `slots-service` (`BookedSlotsKafkaListener`) | смена статуса забронированных слотов (lifecycle Kafka-first) | +| `pcp.route.in.v1` | `pcp-route-processing-service` (`processed-route`) | `pcp-mission-planing-service` (`ModeStatusChangedKafkaListener`, `ModeStatusChangedEvent`) | обработанный маршрут → смена статуса режима съёмки | +| `pcp.route.georeference.v1` | `pcp-route-processing-service` (`route-georeference`) | `pcp-request-service` (`RequestKafkaListener.consumeRoute`) | геопривязка маршрута к заявке | +| `pcp.request.survey-georeference.v1` | (инжект `RouteProcessingTestController` / внешний источник) | `pcp-route-processing-service` (`RoutePassportMessageListener`, вход) | входной маршрутный паспорт на обработку | +| `pcp.request.survey-georeference-dlq.v1` | `pcp-route-processing-service` (DLQ) | — | dead-letter для входных паспортов | +| `pcp.request.completed.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | *(в репозитории нет консьюмера)* | заявка завершена (REQUEST_COMPLETED) | +| `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) | + +### Поток данных (упрощённо) + +``` +CelesTrak ──HTTP──> tle-monitoring ──pcp.tle(TLE)──────────────> ballistics +slots-service ─────pcp.tle(ICRVPlacedEvent)───────────────────> ballistics + +satellite-catalog ──pcp.satellites(SatelliteDeletedEvent)──┬──> tle-monitoring + ├──> slots-service + ├──> complex-mission + ├──> dynamic-plan + ├──> ballistics + └──> mission-planing + +route-processing ──pcp.route.in.v1──────> mission-planing (ModeStatusChanged) +route-processing ──pcp.route.georeference.v1──> request-service + (вход) pcp.request.survey-georeference.v1 ──> route-processing + +mission-planing ──pcp.slots.status.v1(BookedSlots)──────> slots-service +request-service ──pcp.request.completed.v1 (outbox)─────> (нет консьюмера в репо) +``` + +### ТГУ-топики (`pcp.tgu.*`) + +`pcp-tgu-service` объявляет консьюмеры на свои топики (`config-repo/pcp-tgu-service.yaml`, блок `topics:`). +Продюсеры этих событий — внешняя система СПО УОГ / тестовые эндпоинты; внутри репозитория стабильных продюсеров нет. + +| Топик | Консьюмер в tgu | Событие | +|---|---|---| +| `pcp.tgu.visibility-windows-changed.v1` | `VisibilityWindowsChangedConsumer` | изменение окон видимости | +| `pcp.tgu.satellite-plan-decision.v1` | `PlanDecisionConsumer` | решение по плану (принят/отклонён) | +| `pcp.tgu.layin-confirmed.v1` | `LayinConfirmedConsumer` | подтверждение закладки | +| `pcp.tgu.layin-failed.v1` | `LayinFailedConsumer` | ошибка закладки | +| `pcp.tgu.plan-not-accepted.v1` | `PlanNotAcceptedConsumer` | план не принят | +| `pcp.tgu.station-params-changed.v1` | `StationParamsChangedConsumer` | смена параметров станции | + +--- + +## 3. HTTP/REST — карта вызовов между сервисами + +Адреса берутся из `settings.*` → `pcp.services.*` (Spring Cloud Config). Клиенты — `WebClient`. + +| Вызывающий сервис | Целевой сервис | Эндпоинты (примеры) | Назначение | +|---|---|---|---| +| `pcp-ui-service` (BFF) | `tle-monitoring` | `/v1/api/tle`, `/v1/api/tle/satellite/{id}` | TLE | +| | `pcp-satellite-catalog` | `/api/satellites`, `/api/satellites/{id}/observation-profile`, `/slot-profile`, `/api/satellite-groups` | каталог КА | +| | `pcp-complex-mission` | `/api/missions`, `/api/missions/{id}/drops/calculate`, `/api/com-plan/runs`, `/api/com-plan/process` | миссии и комплексные планы | +| | `pcp-dynamic-plan` | `/v1/main/calcPlan`, `/{runId}/result`, `/routes`, `/intervals` | динамические планы | +| | `pcp-request` (request / earth-grid) | `/v1/requests`, `/v1/requests/{id}/cells` | заявки и ячейки | +| | `pcp-stations` | `/api/stations`, `/api/stations/{id}` | станции | +| | `slots-service` | `/api/slots/...`, `/api/slots/booking[...]` | слоты и бронирования | +| | `pcp-ballistics` | `/api/satellites/{id}/asc-node`, `/flight-line`, `/orbit`, `/extract-time`, `/initial-conditions/current`, `/receive-rv`, `/api/tle/parse`, `/api/tle/point` | баллистика | +| | `pcp-coverage-scheme` | `/api/coverage-schemes/calculate` | схемы покрытия | +| | `pcp-tgu` | `/api/platforms`, `/api/plans[...]`, `/api/plans/{id}/dismiss`, `/restore`, `/test/tgu/plans/{id}/decision`, `/confirm-layin` | планы ТГУ | +| `pcp-tgu-service` | `pcp-mission-planing` (`mission-planing.base-url`) | `/api/missions/{id}/clone`, `/api/missions/blank`, `/api/missions/{id}` | управление миссиями плана | +| | `pcp-ballistics` (`ballistics.base-url`) | расчёт точек/баллистики (`ExternalPointsClient`) | внешние точки | +| | `ordinis.nstart.cloud` (внешний) | `/api/v1/spacecraft/records` (`platforms-url`) | классификатор платформ КА | +| `pcp-mission-planing` | `pcp-ballistics` | `/api/obj-view/mpl-square`, `/mpl-point`, `/cell-coverage/{cell}`, `/api/satellites/{id}/rva`, `/extract-time` | баллистика для планирования | +| | `pcp-complex-mission` | `/api/satellites/snapshots/{id}`, режимы КА | данные комплексной миссии | +| | `pcp-satellite-catalog` | `/api/satellites/{id}` | каталог КА | +| `pcp-complex-mission` | `pcp-ballistics` (`ic-service`) | расчёты НУ/баллистики | баллистика | +| | `pcp-satellite-catalog` | `/api/satellites`, `/api/satellites/batch` | каталог КА | +| | `slots-service` | покрытие/слоты (`CoverageBatchClient`, `SlotService`) | слоты | +| | `pcp-request` (`earth-grid`) | сетка Земли/ячейки (`EarthGridService`) | заявки/ячейки | +| | `pcp-coverage-scheme` | расчёт схемы покрытия | покрытие | +| `pcp-dynamic-plan` | `pcp-request` | `/v1/requests` (выборка заявок) | заявки | +| | `pcp-ballistics` | `/api/satellites/orbit/availability/{norad}`, `/extract-time`, `/api/obj-view/mpl-square` | баллистика | +| | `pcp-satellite-catalog` | `/api/satellites` | каталог КА | +| `pcp-coverage-scheme` | `pcp-ballistics` | `/api/obj-view/mpl-square`, `/mpl-point`, `/extract-time` | баллистика | +| | `pcp-complex-mission` | `/api/satellites/{id}` | данные КА/миссии | +| `pcp-ballistics` | `pcp-stations` | `/api/stations` | станции (для расчётов видимости) | +| `slots-service` | `pcp-satellite-catalog` | `/api/satellites`, `/batch`, `/slot-profile` | каталог КА | +| | `pcp-request` (`earth-grid`) | `/v1/requests`, `/cells` | заявки/ячейки | +| `tle-monitoring` | `pcp-satellite-catalog` | `/api/satellites` | каталог КА | + +Все сервисы дополнительно ходят в `spring-cloud-config-server` (8888) за конфигом и регистрируются в `spring-boot-admin-server`. + +### Граф зависимостей (кто кого зовёт по HTTP) + +``` +ui-service ──> почти все (BFF-агрегатор) +tgu ─────────> mission-planing, ballistics, (ordinis ext) +mission-planing ─> ballistics, complex-mission, satellite-catalog +complex-mission ─> ballistics, satellite-catalog, slots, request, coverage-scheme +dynamic-plan ───> request, ballistics, satellite-catalog +coverage-scheme ─> ballistics, complex-mission +ballistics ─────> stations +slots ──────────> satellite-catalog, request +tle-monitoring ─> satellite-catalog (+ CelesTrak внешний) +``` + +`pcp-ballistics` и `pcp-satellite-catalog` — наиболее востребованные «нижние» сервисы. + +--- + +## 4. Camunda — процессы и воркеры + +Используется **Camunda 8 (Zeebe)**, `io.camunda:camunda-spring-boot-4-starter:8.8.10`. +Брокер внешний: gRPC `camunda.k8s.265.nstart.cloud:30901` (dev) / `localhost:26500` (локально), +ключ `camunda.client.grpc-address`. + +- **`pcp-tgu-service`** — *оркестратор*: содержит BPMN-определения, разворачивает их и запускает экземпляры процессов (`CamundaClient.newCreateInstanceCommand()`). +- **`pcp-mission-planing-service`** — *исполнитель*: реализует все job-воркеры (`@JobWorker` в `FlowService`). + +### Процесс `createSatelliteMission` + +BPMN: [createSatelliteMission.bpmn](../../services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn). +Запуск: `BpmnPlanIssueProcessStarter` ← `IssueDuePlanWorker.issuePlanNow()` ← `PlanController` `POST /api/plans/{planId}/issue`. + +``` +startSatelliteMission + → [calculateSatelliteSurveyMissions] «Сформировать план съёмки» + → [calculateSatelliteDropMissions] «Сформировать план сбросов» + → [sendSatellitePlan] «Отправка плана в СПО УОГ» + → endSatelliteMission +``` + +### Процесс `updateAcceptedPlanSlots` + +BPMN: [updateAcceptedPlanSlots.bpmn](../../services/pcp-tgu-service/src/main/resources/BPMN/updateAcceptedPlanSlots.bpmn). +Запуск: `AcceptedPlanSlotsUpdateProcessStarter` ← `AcceptedPlanSlotsUpdateWorker`. + +``` +start → [updateSlotsStatus] «Обновление статуса слотов» → end +``` + +### Соответствие job-type → воркер + +Все воркеры — в `pcp-mission-planing-service`, [FlowService.kt](../../services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt), `autoComplete = false` (ручной `newCompleteCommand`). + +| Job type (BPMN) | Метод воркера | Процесс | +|---|---|---| +| `calculateSatelliteSurveyMissions` | `FlowService.surveyPlan` | createSatelliteMission | +| `calculateSatelliteDropMissions` | `FlowService.surveyDrop` | createSatelliteMission | +| `sendSatellitePlan` | `FlowService.sendPlan` | createSatelliteMission | +| `updateSlotsStatus` | `FlowService.confirmPlan` | updateAcceptedPlanSlots | + +### Схема + +``` + pcp-tgu-service pcp-mission-planing-service + (оркестратор) (job-воркеры, FlowService) + PlanController POST /api/plans/{id}/issue + │ + ▼ + BpmnPlanIssueProcessStarter + │ newCreateInstanceCommand("createSatelliteMission") + ▼ + ┌─────────────── Zeebe / Camunda 8 broker (gRPC) ───────────────┐ + │ calculateSatelliteSurveyMissions ─────────────────────────► surveyPlan + │ calculateSatelliteDropMissions ─────────────────────────► surveyDrop + │ sendSatellitePlan ─────────────────────────► sendPlan + └───────────────────────────────────────────────────────────────┘ + + AcceptedPlanSlotsUpdateProcessStarter + │ newCreateInstanceCommand("updateAcceptedPlanSlots") + ▼ + ┌─────────────── Zeebe broker ───────────────┐ + │ updateSlotsStatus ───────────────────────► confirmPlan + └─────────────────────────────────────────────┘ +``` + +> Связь между HTTP и Camunda: воркеры `pcp-mission-planing-service` в ходе выполнения задач +> сами ходят по HTTP в `pcp-ballistics`, `pcp-complex-mission`, `pcp-satellite-catalog` +> и публикуют Kafka-событие `pcp.slots.status.v1` (см. разделы 2–3). From 316e0ee552f2fed16537f533c6dca543020edff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 6 Jun 2026 18:33:47 +0300 Subject: [PATCH 72/79] =?UTF-8?q?pcp-tgu:=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=20/test/tgu/platforms/load-manual=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20JSON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тестовый эндпоинт загрузки платформ принимает root-array НСИ в теле запроса (@RequestBody List) вместо пути к файлу; маршрут load-from-file переименован в load-manual. --- .../controller/TestTguController.kt | 16 ++++++---------- .../pcp_tgu_service/service/PlatformService.kt | 5 ++++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt index 3d63e56..1999530 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import space.nstart.pcp_tgu_service.domain.PlanDecision +import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.dto.PlanResponse @@ -20,8 +21,6 @@ import space.nstart.pcp_tgu_service.service.PlanQueryService import space.nstart.pcp_tgu_service.service.PlatformService import space.nstart.pcp_tgu_service.service.TestPlanDecisionService import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService -import java.nio.file.Files -import java.nio.file.Path import java.time.LocalDateTime import java.util.UUID @@ -77,14 +76,12 @@ class TestTguController( ): PlanExecutionFeedbackMessage = testPlanDecisionService.generateLayinConfirmed(planId, reason) - /** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */ - @PostMapping("/platforms/load-from-file") - fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse { - val path = Path.of(filePath).toAbsolutePath().normalize() - val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path)) + /** Загружает cache аппаратов из переданного в теле root-array payload НСИ вместо запроса в НСИ. */ + @PostMapping("/platforms/load-manual") + fun loadPlatformsFromPayload(@RequestBody platforms: List): TestPlatformsLoadResponse { + val loaded = platformService.loadPlatformsFromNsiPayload(platforms) return TestPlatformsLoadResponse( - filePath = path.toString(), - loadedCount = platforms.size, + loadedCount = loaded.size, allowedCount = platformService.loadAllowedPlatforms().size ) } @@ -109,7 +106,6 @@ class TestTguController( } data class TestPlatformsLoadResponse( - val filePath: String, val loadedCount: Int, val allowedCount: Int ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt index 96fc185..f2a91dc 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt @@ -46,8 +46,11 @@ class PlatformService( /** Replaces the in-memory platform cache from a raw NSI response payload for test-only manual runs. */ fun loadPlatformsFromNsiPayload(payloadJson: String): List = + loadPlatformsFromNsiPayload(objectMapper.readValue(payloadJson, Array::class.java).toList()) + + /** Replaces the in-memory platform cache from an already-parsed NSI payload for test-only manual runs. */ + fun loadPlatformsFromNsiPayload(platforms: List): List = synchronized(cacheLock) { - val platforms = objectMapper.readValue(payloadJson, Array::class.java).toList() cachePlatforms(platforms, Instant.now(clock)) } From 2499aebf2d1f2f2342240f684f89248944c865b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 6 Jun 2026 18:34:29 +0300 Subject: [PATCH 73/79] =?UTF-8?q?routes:=20=D0=BE=D0=B1=D1=8A=D1=91=D0=BC?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B0=20(volumeMb?= =?UTF-8?q?)=20=E2=80=94=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82,=20=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20=D0=91?= =?UTF-8?q?=D0=94=20=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Объём режима = скорость × длительность, Мбайт: съёмка 120 Мбайт/с (12 ГБ / 100 с), сброс — канал 1200 Мбит/с ÷ 8. Единый источник — ModeDataRates в pcp-types-lib. Хранится колонкой volume_mb (Flyway V10 mission-planing, V9 complex-mission, бэкфилл из duration), заполняется на всех путях построения через @PrePersist/@PreUpdate. DTO отдают вычисляемое read-only поле (та же формула). Фронт pcp-tgu-ops-ui показывает объём в редакторе (инспектор, построенный маршрут) и в сводке комплексного плана. --- .../dto/satellite/mission/ModeDataRates.kt | 31 +++++++++++++++++++ .../dto/satellite/mission/ModeResponseDTO.kt | 15 +++++++-- .../mission/SatelliteModeResponseDTO.kt | 8 ++++- .../satellite/mission/SurveyRouteBuildDTOs.kt | 7 ++++- .../mission/ModeResponseDTOJacksonTest.kt | 23 ++++++++++++++ .../entity/SatelliteModeEntity.kt | 15 ++++++++- .../migration/V9__satellite_mode_volume.sql | 6 ++++ .../entity/DropModeEntity.kt | 15 ++++++++- .../entity/SurveyModeEntity.kt | 15 ++++++++- .../db/migration/V10__mode_volume.sql | 8 +++++ .../pcp-tgu-ops-ui/src/api/complexPlanApi.ts | 2 ++ .../src/api/missionPlanningApi.ts | 4 +++ .../tgu-complex-plan/TguComplexPlanTab.tsx | 5 ++- .../src/features/tgu-editor/EditorPanels.tsx | 6 ++-- .../src/features/tgu-editor/TguEditorTab.tsx | 8 +++-- .../src/features/tgu-editor/editorApi.ts | 4 +++ .../src/features/tgu-editor/editorDraft.ts | 10 +++++- .../features/tgu-editor/editorPresentation.ts | 3 ++ .../features/tgu-editor/model/editorTypes.ts | 2 ++ .../pcp-tgu-ops-ui/src/utils/dataVolume.ts | 16 ++++++++++ 20 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt create mode 100644 services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql create mode 100644 services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql create mode 100644 services/pcp-tgu-ops-ui/src/utils/dataVolume.ts diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt new file mode 100644 index 0000000..784ece3 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +/** + * Скорости накопления/сброса данных для оценки объёма режима. + * + * Объём (`volumeMb`) считается единообразно во всех сервисах в **мегабайтах (Мбайт)** как + * `скорость × длительность`, поэтому константы живут в общей библиотеке типов — один источник + * правды для текущего плана (pcp-ui-service), редактора и комплексного плана (pcp-tgu-ops-ui). + * + * Внимание на единицы: съёмка задана в Мбайт/с, а скорость канала сброса — в Мбит/с, + * поэтому при расчёте объёма сброса скорость приводится к Мбайт делением на 8. + */ +object ModeDataRates { + /** + * Накопление при съёмке — единое для всех КА, **Мбайт/с**. + * Из расчёта 100 с маршрута = 12 ГБ (1 ГБ = 1000 Мбайт): 12000 Мбайт / 100 с = 120 Мбайт/с. + */ + const val SURVEY_MBYTE_PER_SEC: Double = 120.0 + + /** Сброс — постоянная скорость канала, **Мбит/с**. */ + const val DROP_MBIT_PER_SEC: Double = 1200.0 + + /** Бит в байте — приведение скорости канала (Мбит/с) к объёму памяти (Мбайт). */ + private const val BITS_PER_BYTE: Double = 8.0 + + /** Объём маршрута съёмки, Мбайт, по его длительности (секунды). */ + fun surveyVolumeMb(durationSeconds: Double): Double = durationSeconds * SURVEY_MBYTE_PER_SEC + + /** Объём сброса, Мбайт, по длительности сеанса (секунды): скорость канала Мбит/с → Мбайт. */ + fun dropVolumeMb(durationSeconds: Double): Double = durationSeconds * DROP_MBIT_PER_SEC / BITS_PER_BYTE +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt index 9ca3e7f..87f749c 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt @@ -1,5 +1,6 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import io.swagger.v3.oas.annotations.media.Schema @@ -35,6 +36,10 @@ sealed interface ModeResponseDTO { @get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"]) val type: String + + @get:Schema(description = "Объём режима, Мбайт (накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с)") + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + val volumeMb: Double } @Schema(description = "Режим съемки") @@ -54,7 +59,10 @@ data class SurveyModeResponseDTO( val roll: Double = 0.0, @get:Schema(description = "Идентификаторы забронированных слотов") val bookedSlotIds: List = emptyList() -) : ModeResponseDTO +) : ModeResponseDTO { + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + override val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration) +} @Schema(description = "Режим сброса") data class DropModeResponseDTO( @@ -66,4 +74,7 @@ data class DropModeResponseDTO( val station: String? = "", val duration: Double = 0.0, val surveys: List = emptyList() -) : ModeResponseDTO +) : ModeResponseDTO { + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + override val volumeMb: Double get() = ModeDataRates.dropVolumeMb(duration) +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt index ef23741..d79d04d 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt @@ -1,5 +1,6 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission +import com.fasterxml.jackson.annotation.JsonProperty import java.time.LocalDateTime data class SatelliteModeResponseDTO( @@ -19,4 +20,9 @@ data class SatelliteModeResponseDTO( val cellNums: List = emptyList(), val bookedSlotIds: List = emptyList(), val snapshotId: Long? = null -) +) { + /** Объём режима, Мбайт: сброс — канал 1200 Мбит/с (÷8), иначе — накопление съёмки 120 Мбайт/с. */ + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + val volumeMb: Double + get() = if (type == "DROP") ModeDataRates.dropVolumeMb(duration) else ModeDataRates.surveyVolumeMb(duration) +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt index 29afdfd..cbde248 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt @@ -1,5 +1,6 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission +import com.fasterxml.jackson.annotation.JsonProperty import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.DecimalMin import java.time.LocalDateTime @@ -74,4 +75,8 @@ data class SurveyRouteBuildResponseDTO( val contourWkt: String, @field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)") val surveyModeId: Long? = null -) +) { + @get:Schema(description = "Объём маршрута, Мбайт (накопление съёмки 120 Мбайт/с × длительность)") + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration) +} diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt index 7fd1aad..7a0f836 100644 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.module.kotlin.readValue import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test +import kotlin.test.assertTrue class ModeResponseDTOJacksonTest { @@ -50,4 +51,26 @@ class ModeResponseDTOJacksonTest { assertEquals("SURVEY", result[0].type) assertEquals("DROP", result[1].type) } + + @Test + fun `serializes computed volume and tolerates it on read-back`() { + // Сервис-источник теперь сериализует volumeMb; потребитель (pcp-ui-service) + // десериализует тот же JSON обратно — read-only поле не должно ломать чтение. + val modes: List = listOf( + SurveyModeResponseDTO(id = 1, planId = 10, duration = 100.0), + DropModeResponseDTO(id = 2, planId = 10, duration = 5.0) + ) + + val json = mapper.writeValueAsString(modes) + val tree = mapper.readTree(json) + + // Съёмка: 100 с × 120 Мбайт/с = 12000 Мбайт. Сброс: 5 с × 1200 Мбит/с ÷ 8 = 750 Мбайт. + assertEquals(12000.0, tree[0]["volumeMb"].asDouble()) + assertEquals(750.0, tree[1]["volumeMb"].asDouble()) + + val roundTrip: List = mapper.readValue(json) + assertEquals(12000.0, roundTrip[0].volumeMb) + assertEquals(750.0, roundTrip[1].volumeMb) + assertTrue(roundTrip[0] is SurveyModeResponseDTO) + } } diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt index 1c519d5..a37b4d4 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt @@ -12,8 +12,11 @@ import jakarta.persistence.Id import jakarta.persistence.OneToMany import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate import jakarta.persistence.Table import jakarta.persistence.Index +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates import java.time.LocalDateTime @Entity @@ -92,4 +95,14 @@ class SatelliteModeEntity( fetch = FetchType.LAZY ) var cells: MutableList = mutableListOf() -) +) { + /** Объём маршрута, Мбайт. Денормализация: считается из duration при построении (satellite_modes — только SURVEY). */ + @Column(name = "volume_mb", nullable = false) + var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration) + + @PrePersist + @PreUpdate + fun computeVolumeMb() { + volumeMb = ModeDataRates.surveyVolumeMb(duration) + } +} diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql new file mode 100644 index 0000000..54516ec --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql @@ -0,0 +1,6 @@ +-- Объём маршрута съёмки как атрибут, Мбайт. Считается при построении из длительности +-- (см. ModeDataRates в pcp-types-lib): накопление 120 Мбайт/с. satellite_modes — только SURVEY. +ALTER TABLE satellite_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- Бэкфилл существующих маршрутов из длительности. +UPDATE satellite_modes SET volume_mb = duration * 120; diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt index 0b065db..0cc04b3 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt @@ -3,7 +3,10 @@ package space.nstart.pcp.pcp_mission_planing_service.entity import jakarta.persistence.Column import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate import jakarta.persistence.Table +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates import java.time.LocalDateTime @Entity @@ -20,4 +23,14 @@ class DropModeEntity( @Column(name = "duration", nullable = false) var duration: Double = 0.0 -) : ModeEntity(id, plan, timeStart, revolution) +) : ModeEntity(id, plan, timeStart, revolution) { + /** Объём сброса, Мбайт. Денормализация: считается из duration (канал 1200 Мбит/с ÷ 8) при сохранении. */ + @Column(name = "volume_mb", nullable = false) + var volumeMb: Double = ModeDataRates.dropVolumeMb(duration) + + @PrePersist + @PreUpdate + fun computeVolumeMb() { + volumeMb = ModeDataRates.dropVolumeMb(duration) + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt index cdeaacc..ce6d8f6 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt @@ -5,8 +5,11 @@ import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates import java.time.LocalDateTime @Entity @@ -46,4 +49,14 @@ class SurveyModeEntity( @Enumerated(EnumType.STRING) @Column(name = "source", nullable = false, length = 20) var source: SurveyModeSource = SurveyModeSource.SLOT -) : ModeEntity(id, plan, timeStart, revolution) +) : ModeEntity(id, plan, timeStart, revolution) { + /** Объём маршрута съёмки, Мбайт. Денормализация: считается из duration при построении/сохранении. */ + @Column(name = "volume_mb", nullable = false) + var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration) + + @PrePersist + @PreUpdate + fun computeVolumeMb() { + volumeMb = ModeDataRates.surveyVolumeMb(duration) + } +} diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql new file mode 100644 index 0000000..a78fede --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql @@ -0,0 +1,8 @@ +-- Объём данных как атрибут маршрута, Мбайт. Считается при построении из длительности +-- (см. ModeDataRates в pcp-types-lib): съёмка 120 Мбайт/с; сброс — канал 1200 Мбит/с ÷ 8 = 150 Мбайт/с. +ALTER TABLE survey_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE drop_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- Бэкфилл существующих маршрутов из длительности. +UPDATE survey_modes SET volume_mb = duration * 120; +UPDATE drop_modes SET volume_mb = duration * 1200.0 / 8; diff --git a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts index b7dc1a9..2f379b6 100644 --- a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts @@ -99,6 +99,8 @@ export type ComplexPlanMode = { cellNum: number; bookedSlotIds: number[]; snapshotId: number | null; + /** Объём режима, МБ (скорость накопления/сброса × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; }; /** diff --git a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts index acd72bc..368632c 100644 --- a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts @@ -46,6 +46,8 @@ export type SurveyRoute = { longitude: number; /** Контур полосы съёмки, WKT POLYGON (lon lat, ...). */ contourWkt: string; + /** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; /** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */ surveyModeId?: number; }; @@ -96,6 +98,8 @@ export type DropMode = { station: string | null; duration: number; surveys: number[]; + /** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; }; /** diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx index 095d1a0..0183bba 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -18,6 +18,7 @@ import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; +import { formatVolume } from "../../utils/dataVolume"; type TguComplexPlanTabProps = { range: TimelineRange; @@ -203,6 +204,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) const displayRun = selectedRun; const contoursCount = modePolygons.length; + // Суммарный объём режимов run’а (МБ) — съёмка по скорости накопления, сброс по скорости канала. + const totalVolumeMb = modes.reduce((sum, mode) => sum + (mode.volumeMb ?? 0), 0); return (
@@ -253,7 +256,7 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
{displayRun && (
- run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} + run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров {showRequests ? ` · заявок ${requestItems.length}` : ""}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index abf4ed0..e0794dd 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; +import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, formatVolume, kindColor, kindLabel } from "./editorPresentation"; import { statusStyle } from "../tgu-planning/tguStatus"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; import { Time24Field } from "../../components/timeFields"; @@ -172,6 +172,8 @@ export function WorkInspector({
{formatDateTimeSeconds(work.endMs)}
Длительность
{formatDuration(work.endMs - work.startMs)}
+
Объём
+
{formatVolume(work.volumeMb)}
@@ -427,7 +429,7 @@ export function ShootPanel({
{routeError}
) : route ? (
- Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° + Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° · объём {formatVolume(route.volumeMb)}
центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index de9a1b2..f505ca2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -741,7 +741,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, rollDeg: savedRoute?.roll ?? routeRoll, captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, centerLat: savedRoute?.lat, - centerLon: savedRoute?.longitude + centerLon: savedRoute?.longitude, + volumeMb: savedRoute?.volumeMb }) ); setEditingWorkId(undefined); @@ -788,6 +789,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setDropSaving(true); setDropError(undefined); let savedDropId: number | undefined; + let savedDropVolumeMb: number | undefined; try { // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. const draftId = await ensureDraft(); @@ -806,6 +808,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planEnd: toLocalIso(selectedPlan.endMs) }); savedDropId = savedDrop.id; + savedDropVolumeMb = savedDrop.volumeMb; // Перечитываем включения DRAFT (источник истины) и ревалидируем. void refreshDraftModes(draftId); } catch (error) { @@ -824,7 +827,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, endMs: zoneStopMsVal, label: `Сброс · ${stationNm}`, station: { id: String(selectedZone.stationId), name: stationNm }, - planModeId: savedDropId + planModeId: savedDropId, + volumeMb: savedDropVolumeMb }) ); setEditingWorkId(undefined); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts index 6a1cb94..ceab45e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts @@ -10,6 +10,8 @@ export type PlanSurveyMode = { duration: number; contourWkt: string | null; roll: number; + /** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; }; export type PlanDropMode = { @@ -21,6 +23,8 @@ export type PlanDropMode = { station: string | null; duration: number; surveys: number[]; + /** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; }; export type PlanMode = PlanSurveyMode | PlanDropMode; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 280cc32..1665d11 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -29,6 +29,8 @@ export type AddShootInput = { captureAngleDeg?: number; centerLat?: number; centerLon?: number; + /** Объём маршрута, МБ (из ответа бэка). */ + volumeMb?: number; }; export type AddDownlinkInput = { @@ -39,6 +41,8 @@ export type AddDownlinkInput = { station: EditorGroundStation; /** id сохранённого режима сброса в БД черновика (если сброс персистнут). */ planModeId?: number; + /** Объём сброса, МБ (из ответа бэка). */ + volumeMb?: number; }; export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { @@ -80,6 +84,7 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] { rollDeg: mode.roll, centerLat: mode.lat, centerLon: mode.longitude, + volumeMb: mode.volumeMb, // Центр контура — он же опорная точка для проверки видимости. targetLat: mode.lat, targetLon: mode.longitude @@ -96,7 +101,8 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] { planModeId: mode.id, revolution: mode.revolution, stationId: mode.station ?? undefined, - surveyIds: mode.surveys + surveyIds: mode.surveys, + volumeMb: mode.volumeMb }]; }); } @@ -160,6 +166,7 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft { captureAngleDeg: input.captureAngleDeg, centerLat: input.centerLat, centerLon: input.centerLon, + volumeMb: input.volumeMb, origin: "added", isNew: true }; @@ -176,6 +183,7 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft { endMs: input.endMs, stationId: input.station.id, planModeId: input.planModeId, + volumeMb: input.volumeMb, origin: "added", isNew: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index 99b3765..629e22d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -1,5 +1,8 @@ import type { EditorWorkKind } from "./model/editorTypes"; +// Объём данных — общий форматтер (используется и в комплексном плане). +export { formatVolume } from "../../utils/dataVolume"; + export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]; export const DEFAULT_EDITOR_MODES = [ diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts index 2a69a9d..59fc57c 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts @@ -12,6 +12,8 @@ export type EditorWorkBase = { endMs: number; origin: EditorWorkOrigin; isNew?: boolean; + /** Объём включения, МБ (считает бэк: скорость × длительность). Есть у включений плана и сохранённых правок. */ + volumeMb?: number; }; export type EditorShootWork = EditorWorkBase & { diff --git a/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts b/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts new file mode 100644 index 0000000..dbd03be --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts @@ -0,0 +1,16 @@ +/** + * Форматирование объёма данных режима (съёмка/сброс). Бэк отдаёт объём в мегабайтах + * (см. ModeDataRates в pcp-types-lib: накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с ÷ 8). + */ + +/** До 1000 — в МБ, дальше — в ГБ (1 ГБ = 1000 МБ). Тут МБ = мегабайт. «—», если значения нет. */ +export function formatVolume(mb?: number | null): string { + if (mb == null || !Number.isFinite(mb) || mb <= 0) { + return "—"; + } + if (mb >= 1000) { + const gb = mb / 1000; + return `${gb >= 100 ? Math.round(gb) : gb.toFixed(1)} ГБ`; + } + return `${Math.round(mb)} МБ`; +} From c1259c310561273a11bf80baca2d1a0b8097fd02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Sat, 6 Jun 2026 23:47:19 +0300 Subject: [PATCH 74/79] =?UTF-8?q?pcp-request-service:=20=D0=B0=D1=83=D0=B4?= =?UTF-8?q?=D0=B8=D1=82-=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20RS1/RS3/RS6/RS7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RS1 (#29): планировщик истечения заявок — RequestExpiryService (@Scheduled + FOR UPDATE SKIP LOCKED, мультиинстанс-safe), refreshStatus вызывается и при создании заявки. RS7 (#35): единый топик pcp.request.status.v1 → geoportal (COMPLETED/EXPIRED в payload), замена orphan pcp.request.completed.v1; эмиссия из matching и expiry через тот же outbox; backoff/next_attempt_at/cap для outbox FAILED. RS3 (#31): DefaultErrorHandler + DeadLetterPublishingRecoverer (DLQ pcp.route.georeference-dlq.v1), bounded retry, контрактные ошибки в DLQ. RS6 (#34): проброс coverageRequiredPercent из API, разведение терминов покрытие/важность. Доки: бэклог, аудит сервиса заданий, карты взаимодействий. RS2 (#30) отложено до #28, RS5 (#33) — до роста требований к точности. --- config-repo/pcp-request-service.yaml | 21 +- docs/architect/PCP_ARCHITECTURE.md | 8 +- docs/architect/PCP_SERVICE_INTERACTIONS.md | 4 +- docs/аудит-сервис-заданий.md | 310 ++++++++++++++++++ docs/бэклог-pcp-tgu-ops-ui.md | 42 ++- .../config/KafkaConsumerConfig.kt | 131 ++++++++ .../requestservice/config/OutboxProperties.kt | 9 +- .../config/OutboxTransactionConfig.kt | 11 + .../config/RequestExpiryProperties.kt | 19 ++ .../config/RouteConsumerProperties.kt | 28 ++ .../requestservice/dto/RequestApiDtos.kt | 9 + .../entity/OutboxEventEntity.kt | 8 + .../repository/OutboxEventRepository.kt | 15 +- .../repository/RequestRepository.kt | 23 ++ .../service/OutboxPublisherService.kt | 54 ++- .../service/RequestExpiryService.kt | 118 +++++++ .../requestservice/service/RequestService.kt | 12 +- ...rvice.kt => RequestStatusOutboxService.kt} | 34 +- .../service/RouteMatchingService.kt | 4 +- .../src/main/resources/application.yaml | 5 +- .../db/migration/V1__create_schema.sql | 3 + .../requestservice/MigrationSchemaTest.kt | 3 + .../config/KafkaConsumerConfigTest.kt | 175 ++++++++++ .../RequestControllerOpenApiSkeletonTest.kt | 7 +- .../dto/RequestApiDtoContractTest.kt | 25 ++ .../OutboxEventRepositoryJpaTest.kt | 66 +++- .../repository/RequestRepositoryJpaTest.kt | 104 ++++++ .../service/OutboxPublisherServiceTest.kt | 86 +++-- .../RequestCompletedOutboxServiceTest.kt | 65 ---- .../service/RequestExpiryServiceTest.kt | 203 ++++++++++++ .../service/RequestServiceTest.kt | 20 +- .../service/RequestStatusOutboxServiceTest.kt | 107 ++++++ .../service/RouteMatchingServiceTest.kt | 10 +- 33 files changed, 1600 insertions(+), 139 deletions(-) create mode 100644 docs/аудит-сервис-заданий.md create mode 100644 services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt create mode 100644 services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt create mode 100644 services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt create mode 100644 services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt rename services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/{RequestCompletedOutboxService.kt => RequestStatusOutboxService.kt} (55%) create mode 100644 services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt create mode 100644 services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt delete mode 100644 services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt create mode 100644 services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt create mode 100644 services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt diff --git a/config-repo/pcp-request-service.yaml b/config-repo/pcp-request-service.yaml index ef094a1..239cacd 100644 --- a/config-repo/pcp-request-service.yaml +++ b/config-repo/pcp-request-service.yaml @@ -3,9 +3,18 @@ server: pcp: outbox: - request-completed-topic: pcp.request.completed.v1 + request-status-topic: pcp.request.status.v1 publish-batch-size: 50 publish-fixed-delay-ms: 5000 + max-attempts: 10 + initial-backoff-ms: 1000 + max-backoff-ms: 60000 + request: + expiry: + enabled: true + fixed-delay-ms: 60000 + batch-size: 200 + max-batches-per-run: 50 spring: boot: @@ -60,10 +69,12 @@ logging: app: kafka: topics: - request: pcp.request.in.v1 route: pcp.route.georeference.v1 - request: - cleanup-retention-days: 30 - cleanup-cron: "0 0 3 * * *" + consumer: + route: + dlq-topic: pcp.route.georeference-dlq.v1 + max-attempts: 5 + backoff-ms: 1000 + max-backoff-ms: 30000 grid: enabled: true diff --git a/docs/architect/PCP_ARCHITECTURE.md b/docs/architect/PCP_ARCHITECTURE.md index 3f96ce4..51a7197 100644 --- a/docs/architect/PCP_ARCHITECTURE.md +++ b/docs/architect/PCP_ARCHITECTURE.md @@ -14,7 +14,7 @@ | Сервис | Зона ответственности | REST base | Хранит | Kafka (in / out) | Зрелость* | |---|---|---|---|---|---| -| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.completed.v1` (outbox) | 🟢 высокая | +| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.status.v1` (outbox → geoportal) | 🟢 высокая | | **pcp-route-processing-service** | Приём «паспортов маршрутов», нормализация/геопривязка, ретрансляция дальше; собственный outbox + DLQ | `/api/route-processing` (test) | outbox маршрутов | in: `pcp.request.survey-georeference.v1` · out: `pcp.route.georeference.v1`, `pcp.route.in.v1`, DLQ | 🟢 высокая | | **pcp-satellite-catalog-service** | Каталог КА (источник истины): спутники, группы, observation/slot-профили | `/api/satellites`, `/api/satellite-groups` | каталог КА, профили | out: `pcp.satellites` (`SatelliteDeletedEvent`) | 🟡 средняя (мало тестов) | | **pcp-ballistics-service** | Баллистика: орбиты, ЗРВ/RVA, узлы, трассы, time-extract по TLE | `/api/satellites`, `/api/obj-view`, `/api/tle`, `/api/rva` | орбитальные данные | in: `pcp.tle` (IC/ICRV/TLE фильтры), `pcp.satellites` | 🟡 средняя | @@ -89,7 +89,7 @@ flowchart LR EXT[Внешний источник / UI ⚠] -->|pcp.request.survey-georeference.v1| RP[route-processing] RP -->|pcp.route.georeference.v1| REQ[request-service] RP -->|pcp.route.in.v1| MP[mission-planing] - REQ -->|pcp.request.completed.v1 ⚠ потребитель| Q1{{?}} + REQ -->|pcp.request.status.v1 · COMPLETED/EXPIRED| GEO[geoportal · внешний] CAT[satellite-catalog] -->|pcp.satellites / SatelliteDeletedEvent| BAL[ballistics] CAT --> TLEM[tle-monitoring] @@ -175,7 +175,7 @@ sequenceDiagram | `pcp.route.georeference.v1` | route-processing | request-service | `RoutePassportDto` (геопривязанный) | ⚠ | | `pcp.route.in.v1` | route-processing | mission-planing (`ModeStatusChangedEvent`) | processed route / mode-status | ⚠ | | `pcp.request.in.v1` | request-service / UI ⚠ | ⚠ требует уточнения | `RequestPlacedEvent` | ⚠ | -| `pcp.request.completed.v1` | request-service (outbox) | ⚠ требует уточнения | RequestCompleted | `aggregateId` (UUID заявки) | +| `pcp.request.status.v1` | request-service (outbox) | **geoportal** (внешний) | `REQUEST_TERMINAL_STATUS` (COMPLETED/EXPIRED) | `aggregateId` (UUID заявки) | | `pcp.slots.status.v1` | slots-service | mission-planing, slots-service (self) | `BookedSlotsStatusChangedEvent` | ⚠ | | `pcp.tgu.satellite-plan-decision.v1` | Mission Ops / КА ⚠ | tgu-service | `PlanDecisionMessage` (ACCEPTED/REJECTED, `attemptId`) | ⚠ | | `pcp.tgu.visibility-windows-changed.v1` | tgu-service | tgu-service | `VisibilityWindowsChangedMessage` | ⚠ | @@ -255,7 +255,7 @@ tgu : GET /api/plans/{spacecraftId} ; /api/platforms ; Эти пункты помечены `⚠` выше — их стоит зафиксировать до изменений: 1. Producer топиков `pcp.request.survey-georeference.v1` и `pcp.request.in.v1` (внешняя система или UI?). -2. Consumer `pcp.request.completed.v1` — кто слушает завершённые заявки (предположительно complex-mission/планирование). +2. ~~Consumer `pcp.request.completed.v1`~~ — уточнено: потребитель терминальных статусов заявки = внешний **geoportal**, топик переименован в `pcp.request.status.v1` (COMPLETED/EXPIRED, #35). 3. Producer `pcp.tgu.satellite-plan-decision.v1` — реальный Mission Ops/КА vs текущая имитация в `FlowService`. 4. Точные DTO payload для `pcp.tle`, `pcp.route.in.v1`, `pcp.slots.status.v1` — зафиксировать схемы. 5. Возможная циклическая зависимость `complex-mission ⇄ coverage-scheme` в рантайме. diff --git a/docs/architect/PCP_SERVICE_INTERACTIONS.md b/docs/architect/PCP_SERVICE_INTERACTIONS.md index 9e1dd06..bd0c5ac 100644 --- a/docs/architect/PCP_SERVICE_INTERACTIONS.md +++ b/docs/architect/PCP_SERVICE_INTERACTIONS.md @@ -55,7 +55,7 @@ | `pcp.route.georeference.v1` | `pcp-route-processing-service` (`route-georeference`) | `pcp-request-service` (`RequestKafkaListener.consumeRoute`) | геопривязка маршрута к заявке | | `pcp.request.survey-georeference.v1` | (инжект `RouteProcessingTestController` / внешний источник) | `pcp-route-processing-service` (`RoutePassportMessageListener`, вход) | входной маршрутный паспорт на обработку | | `pcp.request.survey-georeference-dlq.v1` | `pcp-route-processing-service` (DLQ) | — | dead-letter для входных паспортов | -| `pcp.request.completed.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | *(в репозитории нет консьюмера)* | заявка завершена (REQUEST_COMPLETED) | +| `pcp.request.status.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | **geoportal** (внешний, в репо нет) | терминальный статус заявки `COMPLETED`/`EXPIRED` (`REQUEST_TERMINAL_STATUS`) | | `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) | ### Поток данных (упрощённо) @@ -76,7 +76,7 @@ route-processing ──pcp.route.georeference.v1──> request-service (вход) pcp.request.survey-georeference.v1 ──> route-processing mission-planing ──pcp.slots.status.v1(BookedSlots)──────> slots-service -request-service ──pcp.request.completed.v1 (outbox)─────> (нет консьюмера в репо) +request-service ──pcp.request.status.v1 (outbox)────────> geoportal (внешний) ``` ### ТГУ-топики (`pcp.tgu.*`) diff --git a/docs/аудит-сервис-заданий.md b/docs/аудит-сервис-заданий.md new file mode 100644 index 0000000..dc353b0 --- /dev/null +++ b/docs/аудит-сервис-заданий.md @@ -0,0 +1,310 @@ +# Аудит сервиса заданий — pcp-request-service + +Дата: 2026-06-06. Автор: Claude (Opus 4.8). +Охват: сервис `pcp-request-service` («сервис заданий») — задача [#26](бэклог-pcp-tgu-ops-ui.md). +Метод: статический разбор кода (контроллеры, Kafka-listener, сервисы сопоставления и жизненного +цикла, outbox, earth-grid), схема БД и Flyway-миграция, конфиг в `config-repo`, обзор тестов. + +Парные документы: общий [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md), +карты [PCP_ARCHITECTURE.md](architect/PCP_ARCHITECTURE.md) и +[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md). +Стык с сервисом паспорта маршрута (`pcp-route-processing-service`) — отдельная задача +[#28](бэклог-pcp-tgu-ops-ui.md), здесь только обозначен. + +## Сводка по состоянию + +Базовое здоровье — хорошее. Сервис держит жизненный цикл заявок на съёмку, ведёт учёт отснятой +территории геометрическим вычитанием и сопоставляет приходящие паспорта маршрутов с заявками. +Конкурентность и работа в нескольких инстансах продуманы: per-row пессимистичные блокировки, +`SKIP LOCKED` в outbox, advisory-lock на пересборку сетки, дедуп по уникальным ключам, +transactional outbox. 19 тестовых файлов с покрытием ключевых сервисов и репозиториев. + +Главные риски — не в инфраструктуре, а в **бизнес-логике закрытия и сопоставления**: +закрытие «по времени» само по себе не срабатывает (нет планировщика истечения), а матчинг +засчитывает покрытие без проверки совместимости аппарата. Приоритеты: **RS1**, **RS2** (высокий), +далее RS3/RS4 (средний), затем долг RS5–RS7. + +Порт 7005. БД `pcp_requests` (PostgreSQL + PostGIS), Flyway `V1`, `ddl-auto: validate`. +Пакет `org.nstart.dep265.requestservice` (несогласован с остальными — см. B3 общего аудита). + +## Назначение и контур + +Заявки создаются **только по HTTP**; паспорта маршрутов приходят **из Kafka**; завершение +заявки уходит наружу через **transactional outbox**. + +``` + HTTP (оператор / pcp-tgu-ops-ui через Vite-прокси) + POST/GET/DELETE /v1/requests · GET /v1/cells · /v1/grid + │ + ▼ + ┌───────────────────────────────────┐ + Kafka │ pcp-request-service │ Kafka + IN ───▶│ заявки · покрытие · lifecycle · │── OUT ──▶ pcp.request.status.v1 → geoportal + │ earth-grid · transactional outbox │ (outbox; COMPLETED/EXPIRED, #35) + └───────────────────────────────────┘ + ▲ │ + │ ▼ +pcp.route.georeference.v1 DB: pcp_requests (PostgreSQL + PostGIS) + │ + pcp-route-processing-service ◀── связь для задачи #28 + («сервис паспорта маршрута») +``` + +### Карта взаимодействий + +| Направление | Канал | Контрагент | Примечание | +|---|---|---|---| +| IN (HTTP) | `/v1/requests` (POST/GET/`/map`/`/{id}`/`/{id}/cells`/DELETE) | UI / оператор | заявки заводятся только здесь | +| IN (HTTP) | `/v1/cells` (+`/priority-map`), `/v1/grid` (settings, `/rebuild`) | UI / оператор | сетка и карта важности | +| IN (Kafka) | `pcp.route.georeference.v1` | **pcp-route-processing-service** | `KafkaMessage`, контракт из `pcp-types-lib` | +| OUT (Kafka) | `pcp.request.status.v1` | **geoportal** (внешний) | outbox `REQUEST_TERMINAL_STATUS`; терминальный статус `COMPLETED`/`EXPIRED` в payload (#35) | +| — | `pcp.request.in.v1` (`app.kafka.topics.request`) | — | в конфиге есть, **слушателя нет** — мёртвый конфиг (RS4) | +| DB | `pcp_requests` (Postgres + PostGIS) | — | Flyway `V1`, `ddl-auto: validate` | + +HTTP-поверхность: [RequestController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt), +[CellsController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt), +[GridManagementController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt). + +## Доменная модель и БД + +Источник: [V1__create_schema.sql](../services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql). + +- **`requests`** — заявка/задание: `geometry` (исходная) + `remaining_geometry` (что осталось снять), + `geometry_area` / `remaining_area`, `coverage_required_percent`, окно `begin/end_date_time`, + `status`, `match_count`, `last_matched_at`, `completed_at`, мягкое удаление `deleted_at`, + `@Version version` (оптимистичная блокировка). Статусы CHECK-ограничением: + `ACCEPTED · ACTIVE · COMPLETED · EXPIRED · DELETED`. +- **`request_optics_params` / `request_rsa_params`** — параметры съёмки (разрешение, угол Солнца, + облачность / поляризация, интерферометрия). Хранятся, но в матчинге **не используются** (RS2). +- **`request_route_matches`** — аудит и идемпотентность сопоставления: `UNIQUE(request_id, route_id)`, + `original_intersection_*`, `applied_intersection_*`, `coverage_delta_percent`, `contributes_to_coverage`. +- **`outbox_events`** — transactional outbox: `UNIQUE(event_type, aggregate_id)`, статусы `NEW/PUBLISHED/FAILED`. +- **`earth_cells` / `request_cells` / `earth_grid_settings`** — сетка Земли и проекция заявок на неё; + PostGIS-триггеры заполняют `contour_geom` и пересчитывают `earth_cells.importance` + (= Σ `request_cell.importance × coverage_percent/100`). Это **карта важности**, не прогресс съёмки (RS6). + +Геометрия хранится как WKT (`TEXT`); вычисления — JTS (`jts-core`), без hibernate-spatial. + +## Учёт отснятой территории + +Ядро — [RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt), +геометрия — [GeometryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt). + +На каждый паспорт маршрута: +1. Кандидаты выбираются по **окну времени + статусу** (`ACCEPTED`/`ACTIVE`, не удалённые) — + [findRequestIdsForRoute](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt). +2. Идемпотентность: `existsByRequestIdAndRouteId` + `request_route_matches UNIQUE` + обработка гонки вставки. +3. На каждую заявку — своя транзакция с `findByIdForUpdate` (row-lock): маршрут пересекается с + **`remaining_geometry`** (не с исходной), остаток уменьшается `difference`, пересчитывается `remaining_area`. +4. `coveredPercent = (geometry_area − remaining_area) / geometry_area × 100`. + +Поскольку учёт идёт по остатку, **перекрывающиеся маршруты не задваиваются** — второй маршрут +засчитывает только новую площадь. Это корректная кумулятивная модель покрытия. + +Ограничения учёта вынесены в находки **RS2** (игнор совместимости аппарата), **RS5** (планарная +площадь в градусах), **RS6** (earth-grid — это важность, а не покрытие). + +## Жизненный цикл и закрытие заданий + +Вся логика — пересчёт статуса «с нуля» в +[RequestLifecycleService.refreshStatus](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt), +по приоритету сверху вниз: + +| Условие | Статус | +|---|---| +| `coveredPercent ≥ coverage_required_percent` | **COMPLETED** (ставится `completed_at`) — приоритет над сроком | +| иначе `now < begin_date_time` | ACCEPTED | +| иначе `now > end_date_time` | **EXPIRED** | +| иначе | ACTIVE | + +При переходе в терминальный статус (`COMPLETED` или `EXPIRED`) — транзакционный outbox-эвент +`REQUEST_TERMINAL_STATUS` (идемпотентно по `(event_type, aggregate_id)`, +[RequestStatusOutboxService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt)), +который затем публикуется +[OutboxPublisherService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt) +в `pcp.request.status.v1` (geoportal). См. #35. + +Ключевая особенность (бывший источник RS1): `refreshStatus` исторически вызывался **только из матчинга +маршрута**. После #29 его дополнительно дёргают планировщик истечения (`RequestExpiryService`) и +`createRequest`, поэтому закрытие «по времени» теперь срабатывает (см. RS1). + +## Горизонтальное масштабирование (несколько инстансов) + +Сервис **спроектирован под несколько одинаковых инстансов** — это сильная сторона. + +Безопасно при N инстансов: +- **Матчинг** — `findByIdForUpdate` (`SELECT … FOR UPDATE`, пессимистичная блокировка строки заявки) + в отдельной транзакции на заявку (`PROPAGATION_REQUIRED` из не-транзакционного listener → + одна независимая транзакция на заявку), плюс `@Version` и дедуп `(request_id, route_id)`. + Инстансы делят партиции топика (consumer-group `pcp-request-service-route`). +- **Outbox** — `SELECT … FOR UPDATE SKIP LOCKED` + `UNIQUE(event_type, aggregate_id)` → + параллельные инстансы не публикуют эвент дважды + ([OutboxEventRepository](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt)). +- **Пересборка сетки** — `pg_try_advisory_xact_lock` + ([GridRebuildLockService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt)) → + ребилдит только один инстанс. +- **Создание заявки** — `existsById` + PK + обработка `23505` → дубликат не пройдёт при гонке. + +Семантика доставки — **at-least-once**; повторная обработка маршрута безопасна за счёт дедупа. +После #31 «ядовитое» сообщение уходит в DLQ (`DefaultErrorHandler` + bounded retry), а не блокирует +партицию. Outbox FAILED после #35 ретраится с backoff и пределом попыток. Параллелизм потребления +маршрутов ограничен числом партиций топика. + +--- + +## Находки + +### RS1 (высокий). Нет планировщика истечения — закрытие «по времени» не срабатывает — ✅ устранено (#29) +Файлы: [RequestLifecycleService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt), +[RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt) + +`refreshStatus` — единственный путь смены статуса — вызывается **только при сопоставлении маршрута**. +Единственный `@Scheduled` в сервисе — публикация outbox. Поэтому если по заявке после `end_date_time` +**не пришло ни одного маршрута**, она навсегда останется `ACTIVE`/`ACCEPTED` и **никогда не станет +`EXPIRED`**. В `EXPIRED` заявка попадает лишь побочно — если какой-то маршрут случайно пересечётся +с ней уже после истечения срока. Дополнительно: `createRequest` всегда ставит `ACCEPTED`, даже если +окно уже в прошлом (`refreshStatus` при создании не вызывается). + +Рекомендация: периодический job (`@Scheduled` + advisory-lock или `SKIP LOCKED`-выборка), который +переводит просроченные заявки в `EXPIRED` без участия маршрутов; вызывать `refreshStatus` и при создании. + +**Готово (#29):** добавлен [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt) — +`@Scheduled`-воркер переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`, забирая заявки батчами через +`findExpirableForUpdateSkipLocked` (`FOR UPDATE SKIP LOCKED` + верхний лимит батчей — безопасно при нескольких +инстансах). `refreshStatus` теперь вызывается и в `createRequest`, поэтому статус приёма отражает окно +(`ACCEPTED`/`ACTIVE`/`EXPIRED`). Конфиг — `pcp.request.expiry.*`. Тесты: `RequestExpiryServiceTest`, +`RequestRepositoryJpaTest`. + +### RS2 (высокий). Матчинг игнорирует совместимость аппарата +Файлы: [RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt), +[RequestValidationService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt) + +Кандидаты подбираются только по окну времени и статусу, а покрытие засчитывается по любому +геометрическому пересечению. `survey_type` (оптика/РСА), разрешение, угол Солнца, облачность и +**крен** в фильтрации **не участвуют**: крен парсится и валидируется (0–90°), но как фильтр не +используется; параметры съёмки лежат в БД мёртвым грузом. Следствие: РСА-маршрут «закроет» +оптическую заявку, маршрут с негодным разрешением/освещённостью засчитается как покрытие. +Перекликается с UI-задачей #4. + +Рекомендация: добавить проверку совместимости маршрут↔заявка перед зачётом покрытия (как минимум +survey_type и крен; в идеале — разрешение и угол Солнца). Требует, чтобы паспорт маршрута нёс тип +сенсора — согласовать в рамках #28. + +**Решение (2026-06-06): отложено до #28.** `RoutePassportDto` сейчас не несёт явный `surveyType` +(есть `polarisation`, `resolutionRange`/`Azimuth`, `rollAngle`, `visirAngle`). Фиксируем контракт +паспорта в рамках аудита связи сервисов #28 и тогда же добавляем фильтр совместимости. До этого +matching не трогаем. + +### RS3 (средний). Нет DLQ / обработчика ошибок Kafka — poison-сообщение блокирует партицию — ✅ устранено (#31) +Файл: [RequestKafkaListener.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt) + +Listener ловит исключение, логирует и **перебрасывает** его. DLQ и кастомный `CommonErrorHandler` +не настроены — работают дефолты Spring Kafka. Невалидное («ядовитое») сообщение маршрута будет +ретраиться бесконечно и **заблокирует партицию**. У парного `pcp-route-processing-service` DLQ есть, +здесь — нет. + +Рекомендация: `DefaultErrorHandler` с ограниченными ретраями и `DeadLetterPublishingRecoverer` +(топик вида `pcp.route.georeference-dlq.v1`); невалидные по контракту сообщения отправлять в DLQ, +а не зацикливать. + +**Готово (#31):** [KafkaConsumerConfig](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt) — +`DefaultErrorHandler` + `DeadLetterPublishingRecoverer` (DLQ `pcp.route.georeference-dlq.v1`), +экспоненциальный bounded retry; контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) +не ретраятся, а сразу уходят в DLQ с диагностическими заголовками (`pcp-exception-class`, +`pcp-original-topic`/`-partition`/`-offset`). Настройки — `app.kafka.consumer.route.*`. По образцу +`pcp-route-processing-service`. Тест — `KafkaConsumerConfigTest`. + +### RS4 (средний). Мёртвый конфиг: cleanup и входной топик заявок — ✅ устранено (#32) +Файл: [config-repo/pcp-request-service.yaml](../config-repo/pcp-request-service.yaml) + +`app.request.cleanup-cron` / `cleanup-retention-days` были заданы, но **кода под них нет** — архивации +или удаления старых заявок не происходило. Аналогично `app.kafka.topics.request: pcp.request.in.v1` +был сконфигурирован, но **без слушателя** (заявки заводятся только по HTTP). Конфиг вводил в заблуждение. + +Решение (#32): ключи удалены из `config-repo` (привязок `@Value` не было). Если cleanup/приём заявок +из Kafka понадобятся — заводить конфиг вместе с реализацией. + +### RS5 (низкий). Планарная площадь в градусах вместо геодезической +Файл: [GeometryService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt) + +Площади считаются JTS `geometry.area` в координатах EPSG:4326 (градусы). Для компактных заявок +процент покрытия как отношение почти корректен, но для крупных и высокоширотных областей даёт +систематическое искажение (градус долготы у полюса короче). + +Рекомендация: при росте требований к точности — считать площадь геодезически (например, PostGIS +`ST_Area(geography)` или проекция в равновеликую СК), а не планарно в градусах. + +**Решение (2026-06-06): отложено.** Покрытие считается отношением площадей, поэтому для обычных AOI +планарная оценка близка к геодезической. Переключение `area()` на геодезию ripple-эффектом затронет +учёт `coverageDeltaPercent` и точечные ассерты площадей в `RouteMatchingServiceTest`; делаем при +реальном росте требований к точности (крупные/высокоширотные заявки). + +### RS6 (низкий). `coverage_required_percent` захардкожен в 100%; earth-grid ≠ покрытие — ✅ устранено (#34) +Файлы: [RequestService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt), +[RequestGridProjectionService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt) + +`createRequest` всегда ставит `DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0`, игнорируя значение из API, +хотя колонка и поле его поддерживают. Отдельно стоит зафиксировать: `request_cells.coverage_percent` +и `/priority-map` — это **доля футпринта заявки в ячейке × важность** (карта приоритетов), считается +один раз при создании и **маршрутами не обновляется**. Реальный прогресс съёмки живёт только в +`remaining_geometry` / `remaining_area`. Это легко перепутать по названию поля. + +Рекомендация: пробрасывать требуемый процент из API; в документации/именовании развести «важность +ячейки» и «прогресс покрытия». + +**Готово (#34):** в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` +(0 < x ≤ 100, по умолчанию 100); оно пробрасывается в сущность и служит порогом `COMPLETED`, +константа `DEFAULT_COVERAGE_REQUIRED_PERCENT` убрана. В KDoc разведены «прогресс покрытия» +(`coverageRequiredPercent`/`remaining_area`) и «важность заявки» (`importance` → карта приоритетов +ячеек). Тесты — `RequestServiceTest`, `RequestApiDtoContractTest`. + +### RS7 (средний). Уведомление geoportal о финале заявки; outbox FAILED без backoff — ✅ устранено (#35) +Файлы: [OutboxPublisherService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt), +[OutboxEventRepository.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt), +[RequestStatusOutboxService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt) + +Потребитель завершения заявок — внешний сервис **geoportal** (в этом монорепо его нет, поэтому в +[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md) консьюмер не виден). Ему нужно +сообщать про **оба** терминальных исхода: «выполнено» (`COMPLETED`) и «срок вышел» (`EXPIRED`). Сейчас +эмитится только `COMPLETED` (в `pcp.request.completed.v1`), а `EXPIRED`-эвента нет вовсе. Плюс +публикуемая выборка берёт `NEW` + `FAILED`, поэтому FAILED **ретраится**, но без backoff и без cap: +при устойчивом сбое Kafka каждые 5 с все инстансы перевыбирают FAILED (нагрузка, но не потеря). + +Решение: свести уведомления в единый топик **`pcp.request.status.v1`** (по аналогии с +`pcp.slots.status.v1`) с терминальным статусом в payload, заменив orphan `pcp.request.completed.v1`; +эмитить `EXPIRED`-эвент через тот же transactional outbox (зависит от планировщика истечения **RS1**, +который и создаёт переход в `EXPIRED`). Outbox дополнить `next_attempt_at`/экспоненциальным backoff и +пределом попыток — по образцу `RoutePassportOutboxWorker` из `pcp-route-processing-service`. + +**Готово (#35):** единый топик `pcp.request.status.v1` с терминальным статусом в payload +(`COMPLETED`/`EXPIRED`, плюс `occurredAt`/`coveragePercent`) — заменил orphan `pcp.request.completed.v1`. +Единое событие `REQUEST_TERMINAL_STATUS` пишется в той же транзакции из двух мест: +[RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt) +(по достижению покрытия) и [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt) +(по истечению срока). Outbox получил `attempts`/`next_attempt_at`: при сбое — удвоение backoff от +`initial-backoff-ms` до `max-backoff-ms`, после `max-attempts` событие становится терминальным `FAILED` +и из выборки выпадает. Конфиг — `pcp.outbox.*`. Мелкий зазор: заявка, **созданная** уже просроченной, +терминальное событие не порождает (вырожденный кейс; обычные пути ACTIVE→COMPLETED/EXPIRED покрыты). + +## Что в порядке (намеренно не отмечаем как проблемы) + +- Конкурентность и мультиинстанс: row-lock на заявку, `SKIP LOCKED` в outbox, advisory-lock на сетку, + `@Version`, дедуп по уникальным ключам — сделано грамотно. +- Transactional outbox: терминальный эвент `REQUEST_TERMINAL_STATUS` пишется в той же транзакции, что и завершение/истечение заявки. +- Идемпотентность сопоставления: повтор маршрута безопасен (`request_route_matches UNIQUE` + проверки). +- Кумулятивный учёт покрытия по остатку (без задвоения перекрытий). +- Тесты: `RouteMatchingServiceTest`, `OutboxPublisherServiceTest`, + `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RequestRepositoryJpaTest`, `MigrationSchemaTest`. + +## Статус работ + +Сделано: +- ✅ **RS1** — устранено (#29): планировщик истечения `RequestExpiryService` + `refreshStatus` при создании. +- ✅ **RS3** — устранено (#31): `DefaultErrorHandler` + DLQ `pcp.route.georeference-dlq.v1`. +- ✅ **RS4** — устранено (#32): мёртвый конфиг удалён из `config-repo`. +- ✅ **RS6** — устранено (#34): проброс `coverageRequiredPercent` из API + разведение терминов. +- ✅ **RS7** — устранено (#35): единый топик `pcp.request.status.v1` (COMPLETED/EXPIRED) + backoff/cap outbox. + +Отложено (осознанно): +- ⏸ **RS2** (#30) — фильтр совместимости аппарата. Отложено **до #28**: паспорт маршрута не несёт явный `surveyType`, контракт фиксируется в аудите связи сервисов. +- ⏸ **RS5** (#33) — геодезическая площадь. Отложено до роста требований к точности (переключение `area()` затрагивает matching и тесты; покрытие — отношение площадей). diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index b406ffa..8ede38b 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -10,7 +10,10 @@ У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер. -_Обновлено: 2026-06-05_ +> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации +> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). + +_Обновлено: 2026-06-06_ --- @@ -31,12 +34,49 @@ _Обновлено: 2026-06-05_ ### Бэкенд / инфраструктура - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). +### Проверка сервисов +- [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности. +- [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой. + ### Баги - [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». +### Аудит — надёжность и бэкенд +_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ +- [ ] **#12** — (B1, высокий) Camunda job-воркеры в `FlowService` молча глотают исключения (`catch { logger.warn(ex.message) }`) при `autoComplete=false`: задача не завершается и не падает, BPMN-процесс зависает без инцидента. Завершать через `newFailCommand`/проброс наверх, логировать `error(msg, ex)`. +- [ ] **#13** — (B2, средний) Снять симуляционную заглушку `Thread.sleep(5000)` («имитация подтверждения плана») в `FlowService.sendPlan` при появлении реального канала подтверждений. +- [ ] **#14** — (B3, средний) Привести в порядок несогласованные пакеты-копипасты (ballistics→`pcp_request_service`, complex-mission→`pcp_satellites_service`, ui-service и slots-service делят `slots_service`, request→`org.nstart.dep265`, tgu→без `.pcp.`). Отдельной задачей из-за широкого охвата. +- [ ] **#15** — (B4, низкий) Опечатка «srpring» в `pcp-srpring-boot-admin-server` растиражирована по каталогу, пакету, CI, docker-compose, gradle. Чинить вместе с #14. +- [ ] **#16** — (B5, низкий) ~9 мест `logger.warn/error(ex.message)` теряют stack trace — заменить на `logger.error("...", ex)`. + +### Аудит — фронтенд +_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ +- [ ] **#17** — (F1, средний) Вынести общий `src/api/http.ts` (`fetchJson` + `extractErrorText` + маппинг ошибок): `extractErrorText` продублирован в 5 клиентах, fetch-обвязка повторяется ~22 раза. Заодно устранить расхождение приоритета полей ошибки (F5). +- [ ] **#18** — (F2, низкий) Перевести нетипизированный островок `features/constellation/*` (`.jsx`/`.js`, `allowJs`) на TS или добавить декларации типов на границе. +- [ ] **#19** — (F3, низкий) Удалить мёртвый barrel `features/constellation/index.js` (нигде не импортируется). +- [ ] **#20** — (F4, низкий) Декомпозировать God-компонент `TguEditorTab.tsx` (1727 строк): вынести хуки (`useDraftHistory`, `useRouteBuilder`), разнести панели. + +### Аудит — гигиена репозитория и конфиги +_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ +- [ ] **#21** — (R1, средний) Убрать из git крупные дампы в корне (`pcp_requests_converted_create_requests.json` ~228 КБ, `response_заданияКубернетс.json` ~157 КБ, `response_заданияЕмельянов.json` ~66 КБ) — перенести в `src/test/resources`/`docs` или удалить с добавлением в `.gitignore`. +- [ ] **#22** — (R2, низкий) Удалить посторонние артефакты в корне: `node_modules/` + пустой `package-lock.json`, `obj_terra.zip`; при желании добавить `/package-lock.json` в `.gitignore`. +- [ ] **#23** — (R3, низкий) Прогнать `./gradlew clean` / удалить 26 устаревших каталогов `bin/` (задваивают исходники, шумят при grep). +- [ ] **#24** — (C1, средний) Унифицировать креды БД в config-repo: привести все сервисы к переопределяемому `password: ${DB_PASSWORD:...}` (как в tgu-service), убрать зашитые `password: password`. +- [ ] **#25** — (C2, низкий) Сузить CORS для прода в `pcp-ui-service` (`allowedOriginPatterns("*")` + `allowCredentials`) до конкретных origin; держать в курсе отсутствие CORS у напрямую вызываемых сервисов (работает только через прокси/nginx). + +### Аудит — сервис заданий (pcp-request-service) +_Источник и рекомендации: [аудит-сервис-заданий.md](аудит-сервис-заданий.md) (коды находок RS1–RS7)._ +- [ ] **#30** — (RS2, высокий) Матчинг засчитывает покрытие по любому геометрическому пересечению, игнорируя совместимость аппарата (`survey_type`, крен, разрешение, угол Солнца). Добавить фильтр совместимости маршрут↔заявка перед зачётом покрытия; тип сенсора в паспорте маршрута согласовать в рамках #28. _Отложено до #28 (решение, 2026-06-06): `RoutePassportDto` не несёт явный `surveyType` — контракт фиксируется в рамках аудита связи сервисов #28, тогда же делаем фильтр._ +- [ ] **#33** — (RS5, низкий) Площади считаются планарно в градусах (JTS `area` на EPSG:4326) — искажение для крупных/высокоширотных заявок. Перейти на геодезию (`ST_Area(geography)` или равновеликая проекция), когда вырастут требования к точности. _Отложено по решению аудита: покрытие считается отношением площадей (планар ≈ геодезия для обычных AOI), а переключение `area()` затрагивает matching и точечные ассерты тестов — вводим при росте требований к точности._ --- ## Выполнено +- [x] **#34** — (RS6) Проброс требуемого процента покрытия из API + разведение терминов. _Готово: в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` (0 < x ≤ 100, по умолчанию 100), оно пробрасывается в сущность и используется как порог COMPLETED; захардкоженная константа убрана. В KDoc/коде разведены «прогресс покрытия» (`coverageRequiredPercent`) и «важность заявки» (`importance`, карта приоритетов ячеек). Тесты: `RequestServiceTest`, `RequestApiDtoContractTest`._ +- [x] **#31** — (RS3) DLQ и error-handler для Kafka-листенера маршрутов. _Готово: `KafkaConsumerConfig` — `DefaultErrorHandler` + `DeadLetterPublishingRecoverer` в топик `pcp.route.georeference-dlq.v1`, экспоненциальный bounded retry, контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) → DLQ без ретраев, диагностические заголовки. Настройки `app.kafka.consumer.route.*`, активируется при заданном `spring.kafka.bootstrap-servers`. Тест `KafkaConsumerConfigTest`._ +- [x] **#35** — (RS7) Уведомление **geoportal** о финале заявки. _Готово: единый топик `pcp.request.status.v1` (заменил orphan `pcp.request.completed.v1`) с терминальным статусом в payload (`COMPLETED`/`EXPIRED`). Эмиссия из двух мест через тот же transactional outbox: `RouteMatchingService` (по покрытию) и `RequestExpiryService` (по сроку, #29). Сервис `RequestCompletedOutboxService`→`RequestStatusOutboxService`, eventType `REQUEST_TERMINAL_STATUS`. Outbox получил backoff: колонки `attempts`/`next_attempt_at`, удвоение `initial→max`, предел `max-attempts` (терминальный FAILED). Конфиг `pcp.outbox.*`. Тесты: `OutboxPublisherServiceTest`, `OutboxEventRepositoryJpaTest`, `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RouteMatchingServiceTest`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._ +- [x] **#29** — (RS1) Планировщик истечения заявок. _Готово: `RequestExpiryService` (`@Scheduled`, `FOR UPDATE SKIP LOCKED`, батчи с верхним лимитом) переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`; несколько инстансов безопасны. `refreshStatus` теперь вызывается и при создании заявки — статус приёма отражает окно (`ACCEPTED`/`ACTIVE`/`EXPIRED`), а не всегда `ACCEPTED`. Конфиг `pcp.request.expiry.*` в config-repo. Тесты: `RequestExpiryServiceTest` (юнит) + `RequestRepositoryJpaTest` (H2). Разблокирует EXPIRED-эмиссию в #35._ +- [x] **#32** — (RS4) Удалить мёртвый конфиг сервиса заданий. _Готово: из `config-repo/pcp-request-service.yaml` убраны `app.request.cleanup-cron`/`cleanup-retention-days` и неиспользуемый топик `app.kafka.topics.request` (`pcp.request.in.v1`); кода под ними не было, `@Value`-привязок нет._ +- [x] **#26** — Проверить сервис заданий (`pcp-request-service`) по отдельности. _Готово: аудит в [аудит-сервис-заданий.md](аудит-сервис-заданий.md) — карта взаимодействий, учёт покрытия, жизненный цикл, мультиинстанс; находки RS1–RS7 (RS1/RS2 — высокий приоритет)._ - [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._ - [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._ - [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._ diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt new file mode 100644 index 0000000..8a998c8 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt @@ -0,0 +1,131 @@ +package org.nstart.dep265.requestservice.config + +import com.fasterxml.jackson.core.JsonProcessingException +import jakarta.validation.ConstraintViolationException +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.header.Headers +import org.apache.kafka.common.header.internals.RecordHeader +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.util.backoff.ExponentialBackOff +import java.nio.charset.StandardCharsets + +/** + * Обработка ошибок Kafka-консьюмера маршрутов: bounded retry + DLQ. + * + * Без этого «ядовитое» сообщение бесконечно ретраится и блокирует партицию. Невалидные по контракту + * (ошибка десериализации/валидации) уходят в DLQ сразу, прочие — после ограниченного числа попыток. + * + * Активируется только при заданном `spring.kafka.bootstrap-servers`, чтобы не требовать Kafka в тестах + * без брокера. + */ +@Configuration +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +class KafkaConsumerConfig( + @param:Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String, + @param:Value("\${spring.kafka.consumer.group-id}") + private val groupId: String, + @param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}") + private val autoOffsetReset: String, +) { + + @Bean + fun routeConsumerFactory(): ConsumerFactory = + DefaultKafkaConsumerFactory( + mutableMapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ), + ) + + @Bean + fun kafkaListenerContainerFactory( + routeKafkaErrorHandler: DefaultErrorHandler, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + setConsumerFactory(routeConsumerFactory()) + setCommonErrorHandler(routeKafkaErrorHandler) + } + + @Bean + fun routeDeadLetterPublishingRecoverer( + kafkaTemplate: KafkaTemplate, + routeConsumerProperties: RouteConsumerProperties, + ): DeadLetterPublishingRecoverer = + DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ -> + TopicPartition(routeConsumerProperties.dlqTopic, record.partition()) + }.apply { + addHeadersFunction { record, exception -> routeDlqHeaders(record, exception) } + } + + @Bean + fun routeKafkaErrorHandler( + routeDeadLetterPublishingRecoverer: DeadLetterPublishingRecoverer, + routeConsumerProperties: RouteConsumerProperties, + ): DefaultErrorHandler = + DefaultErrorHandler( + routeDeadLetterPublishingRecoverer, + routeConsumerBackOff(routeConsumerProperties), + ).apply { + // Контрактные ошибки (битый JSON, провал валидации) бесполезно ретраить — сразу в DLQ. + addNotRetryableExceptions( + JsonProcessingException::class.java, + ConstraintViolationException::class.java, + ) + setCommitRecovered(true) + } + + private fun routeConsumerBackOff(properties: RouteConsumerProperties): ExponentialBackOff = + ExponentialBackOff().apply { + initialInterval = properties.backoffMs + multiplier = RETRY_BACKOFF_MULTIPLIER + maxInterval = properties.maxBackoffMs + maxAttempts = (properties.maxAttempts - 1).coerceAtLeast(0) + } + + private fun routeDlqHeaders( + record: ConsumerRecord<*, *>, + exception: Exception, + ): Headers = + RecordHeaders().apply { + addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME) + addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name) + addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty()) + addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic()) + addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString()) + addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString()) + } + + private fun Headers.addStringHeader(name: String, value: String) { + add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8))) + } + + private companion object { + const val SERVICE_NAME = "pcp-request-service" + const val RETRY_BACKOFF_MULTIPLIER = 2.0 + + const val HEADER_SERVICE_NAME = "pcp-service-name" + const val HEADER_EXCEPTION_CLASS = "pcp-exception-class" + const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message" + const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic" + const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition" + const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset" + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt index 2bcd7d9..96721b0 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt @@ -4,7 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "pcp.outbox") data class OutboxProperties( - val requestCompletedTopic: String = "pcp.request.completed.v1", + /** Топик терминальных статусов заявки (COMPLETED/EXPIRED) для geoportal. */ + val requestStatusTopic: String = "pcp.request.status.v1", val publishBatchSize: Int = 50, val publishFixedDelayMs: Long = 5_000, + /** Сколько раз пытаемся опубликовать событие до перевода в терминальный FAILED. */ + val maxAttempts: Int = 10, + /** Backoff после первой неудачной публикации. */ + val initialBackoffMs: Long = 1_000, + /** Верхняя граница backoff между попытками. */ + val maxBackoffMs: Long = 60_000, ) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt index f4d6de2..db18f26 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt @@ -22,4 +22,15 @@ class OutboxTransactionConfig { propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW } } + + /** + * Каждый батч планировщика истечения коммитится отдельно, поэтому между батчами + * row-level блокировки (FOR UPDATE SKIP LOCKED) освобождаются. + */ + @Bean("requestExpiryTransactionOperations") + fun requestExpiryTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations { + return TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + } } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt new file mode 100644 index 0000000..967e151 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt @@ -0,0 +1,19 @@ +package org.nstart.dep265.requestservice.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Настройки планировщика истечения заявок: периодически переводит просроченные заявки + * (status ACCEPTED/ACTIVE, окно которых уже закончилось) в EXPIRED. + */ +@ConfigurationProperties(prefix = "pcp.request.expiry") +data class RequestExpiryProperties( + /** Включён ли планировщик. */ + val enabled: Boolean = true, + /** Период запуска (мс), читается также в @Scheduled. */ + val fixedDelayMs: Long = 60_000, + /** Сколько заявок захватывать за одну транзакцию (FOR UPDATE SKIP LOCKED). */ + val batchSize: Int = 200, + /** Верхняя граница числа батчей за один прогон, чтобы не удерживать поток планировщика. */ + val maxBatchesPerRun: Int = 50, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt new file mode 100644 index 0000000..dd92386 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt @@ -0,0 +1,28 @@ +package org.nstart.dep265.requestservice.config + +import jakarta.validation.constraints.Min +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +/** + * Настройки обработки ошибок Kafka-консьюмера маршрутов: DLQ-топик и bounded retry. + * «Ядовитое» (невалидное по контракту) сообщение уходит в DLQ, а не блокирует партицию. + */ +@Validated +@ConfigurationProperties(prefix = "app.kafka.consumer.route") +data class RouteConsumerProperties( + /** Топик dead-letter для маршрутов, не прошедших обработку. */ + val dlqTopic: String = "pcp.route.georeference-dlq.v1", + + /** Максимум попыток обработки, включая первичную доставку. */ + @field:Min(1) + val maxAttempts: Long = 5, + + /** Начальная задержка retry, мс. */ + @field:Min(0) + val backoffMs: Long = 1_000, + + /** Верхняя граница задержки retry, мс. */ + @field:Min(0) + val maxBackoffMs: Long = 30_000, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt index a67df9a..efdddf1 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt @@ -60,6 +60,15 @@ data class CreateRequestDto( /** Признак высокоприоритетной передачи. */ val highPriorityTransmit: Boolean = false, + /** + * Требуемый процент покрытия заявки (0 < x ≤ 100), при достижении которого заявка + * считается выполненной (COMPLETED). Это **прогресс съёмки**, не путать с [importance] + * (важность заявки для карты приоритетов ячеек). По умолчанию — 100%. + */ + @field:DecimalMin(value = "0", inclusive = false) + @field:DecimalMax("100") + val coverageRequiredPercent: Double = 100.0, + /** Параметры оптической съемки. */ @field:Valid val optics: OpticsParamsDto? = null, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt index 77c8805..03fdee2 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt @@ -48,6 +48,14 @@ class OutboxEventEntity( @Column(name = "created_at", nullable = false) var createdAt: LocalDateTime, + /** Число выполненных попыток публикации. */ + @Column(name = "attempts", nullable = false) + var attempts: Int = 0, + + /** Не раньше этого момента событие снова попадёт в выборку публикации (backoff). */ + @Column(name = "next_attempt_at", nullable = false) + var nextAttemptAt: LocalDateTime = createdAt, + @Column(name = "published_at") var publishedAt: LocalDateTime? = null, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt index 02459c1..890ac1c 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt @@ -16,8 +16,9 @@ interface OutboxEventRepository : JpaRepository { fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List /** - * Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already - * being published by another transaction. + * Locks publishable events so parallel service instances skip rows already being published by + * another transaction. Учитывает backoff (`next_attempt_at`) и предел попыток (`attempts`): + * терминальные FAILED (`attempts >= maxAttempts`) и ещё не «дозревшие» строки исключаются. */ @Query( value = """ @@ -25,6 +26,8 @@ interface OutboxEventRepository : JpaRepository { FROM outbox_events WHERE event_type = :eventType AND status IN ('NEW', 'FAILED') + AND attempts < :maxAttempts + AND next_attempt_at <= :now ORDER BY CASE WHEN status = 'NEW' THEN 0 ELSE 1 END, created_at ASC @@ -33,8 +36,10 @@ interface OutboxEventRepository : JpaRepository { """, nativeQuery = true, ) - fun findRequestCompletedPublishableForUpdateSkipLocked( + fun findPublishableForUpdateSkipLocked( @Param("eventType") eventType: String, + @Param("now") now: LocalDateTime, + @Param("maxAttempts") maxAttempts: Int, @Param("batchSize") batchSize: Int, ): List @@ -52,6 +57,8 @@ interface OutboxEventRepository : JpaRepository { aggregate_id, payload, status, + attempts, + next_attempt_at, created_at, published_at, error_message @@ -63,6 +70,8 @@ interface OutboxEventRepository : JpaRepository { :aggregateId, CAST(:payload AS jsonb), :status, + 0, + :createdAt, :createdAt, NULL, NULL diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt index 8b05de9..895660d 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt @@ -106,4 +106,27 @@ interface RequestRepository : JpaRepository { ): List fun findByStatusIn(statuses: Collection): List + + /** + * Захватывает порцию просроченных заявок (окно уже закончилось, статус ещё незавершённый) + * под row-level lock. FOR UPDATE SKIP LOCKED позволяет нескольким инстансам разбирать + * непересекающиеся строки, не конкурируя за одни и те же заявки. + */ + @Query( + value = """ + SELECT * + FROM requests + WHERE status IN ('ACCEPTED', 'ACTIVE') + AND end_date_time < :now + AND deleted_at IS NULL + ORDER BY end_date_time ASC + LIMIT :batchSize + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true, + ) + fun findExpirableForUpdateSkipLocked( + @Param("now") now: LocalDateTime, + @Param("batchSize") batchSize: Int, + ): List } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt index 31627eb..7077639 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt @@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations +import java.time.Duration import java.time.LocalDateTime import java.util.concurrent.ExecutionException @@ -41,60 +42,91 @@ class OutboxPublisherService( return try { transactionOperations.execute( TransactionCallback { - val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( - eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, + val now = LocalDateTime.now() + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE, + now = now, + maxAttempts = outboxProperties.maxAttempts, batchSize = batchSize, ).distinctBy { it.id } events.forEach { event -> - publishAndUpdateStatus(event) + publishAndUpdateStatus(event, now) } events.size }, ) ?: 0 } catch (exception: Exception) { - log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception) + log.error("Failed to process REQUEST_TERMINAL_STATUS outbox event batch", exception) 0 } } - private fun publishAndUpdateStatus(event: OutboxEventEntity) { + private fun publishAndUpdateStatus(event: OutboxEventEntity, now: LocalDateTime) { try { kafkaTemplate .send( - outboxProperties.requestCompletedTopic, + outboxProperties.requestStatusTopic, event.aggregateId.toString(), event.payload.toString(), ) .get() event.status = OutboxEventStatus.PUBLISHED - event.publishedAt = LocalDateTime.now() + event.publishedAt = now event.errorMessage = null outboxEventRepository.save(event) log.info( - "Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", + "Published REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, topic={}", event.id, event.aggregateId, - outboxProperties.requestCompletedTopic, + outboxProperties.requestStatusTopic, ) } catch (exception: Exception) { val publishException = exception.unwrapExecutionException() + + event.attempts += 1 event.status = OutboxEventStatus.FAILED event.errorMessage = publishException.shortMessage() + // attempts >= maxAttempts → терминальный FAILED (выборка публикации его уже не вернёт); + // иначе планируем повтор с backoff. + if (event.attempts < outboxProperties.maxAttempts) { + event.nextAttemptAt = now.plus(Duration.ofMillis(calculateBackoffMs(event.attempts))) + } outboxEventRepository.save(event) + val exhausted = event.attempts >= outboxProperties.maxAttempts log.warn( - "Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", + "Failed to publish REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, " + + "topic={}, attempts={}, maxAttempts={}, exhausted={}, nextAttemptAt={}", event.id, event.aggregateId, - outboxProperties.requestCompletedTopic, + outboxProperties.requestStatusTopic, + event.attempts, + outboxProperties.maxAttempts, + exhausted, + event.nextAttemptAt, publishException, ) } } + /** + * Удваивает backoff от [OutboxProperties.initialBackoffMs] до [OutboxProperties.maxBackoffMs]. + */ + private fun calculateBackoffMs(attempts: Int): Long { + var backoffMs = outboxProperties.initialBackoffMs.coerceAtMost(outboxProperties.maxBackoffMs) + repeat((attempts - 1).coerceAtLeast(0)) { + backoffMs = if (backoffMs >= outboxProperties.maxBackoffMs / 2) { + outboxProperties.maxBackoffMs + } else { + (backoffMs * 2).coerceAtMost(outboxProperties.maxBackoffMs) + } + } + return backoffMs + } + private fun Exception.unwrapExecutionException(): Throwable { return if (this is ExecutionException && cause != null) { cause!! diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt new file mode 100644 index 0000000..fd73d08 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt @@ -0,0 +1,118 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.config.RequestExpiryProperties +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime + +/** + * Планировщик истечения заявок. + * + * [RequestLifecycleService.refreshStatus] вызывается только при матчинге маршрута, поэтому заявка, + * к которой не пришло ни одного маршрута, никогда не уходила в EXPIRED после окончания окна. + * Этот воркер периодически переводит такие заявки в EXPIRED. + * + * Несколько инстансов безопасны: батч строк захватывается через FOR UPDATE SKIP LOCKED, так что + * параллельные инстансы разбирают непересекающиеся заявки. + */ +@Service +class RequestExpiryService( + private val requestRepository: RequestRepository, + + private val requestLifecycleService: RequestLifecycleService, + + private val requestStatusOutboxService: RequestStatusOutboxService, + + private val requestExpiryProperties: RequestExpiryProperties, + + @param:Qualifier("requestExpiryTransactionOperations") + private val transactionOperations: TransactionOperations, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @Scheduled(fixedDelayString = "\${pcp.request.expiry.fixed-delay-ms:60000}") + fun expireOverdueScheduledBatch() { + if (!requestExpiryProperties.enabled) { + return + } + expireOverdue() + } + + /** + * Переводит просроченные заявки (ACCEPTED/ACTIVE с истёкшим окном) в EXPIRED. + * + * @return число заявок, перешедших в EXPIRED за прогон. + */ + fun expireOverdue(): Int { + val batchSize = requestExpiryProperties.batchSize.coerceAtLeast(0) + if (batchSize == 0) { + return 0 + } + val maxBatches = requestExpiryProperties.maxBatchesPerRun.coerceAtLeast(1) + val now = LocalDateTime.now() + + var expiredTotal = 0 + var batchIndex = 0 + while (batchIndex < maxBatches) { + val result = try { + transactionOperations.execute( + TransactionCallback { expireBatch(now, batchSize) }, + ) ?: ExpireBatchResult.EMPTY + } catch (exception: Exception) { + log.error("Failed to process request expiry batch", exception) + break + } + + expiredTotal += result.expiredCount + batchIndex += 1 + if (result.claimedCount < batchSize) { + break + } + } + + if (expiredTotal > 0) { + log.info("Request expiry sweep finished: expired={}", expiredTotal) + } + return expiredTotal + } + + private fun expireBatch(now: LocalDateTime, batchSize: Int): ExpireBatchResult { + val candidates = requestRepository.findExpirableForUpdateSkipLocked(now, batchSize) + var expiredCount = 0 + + candidates.forEach { request -> + val refresh = requestLifecycleService.refreshStatus(request, now) + if (refresh.statusChanged) { + request.updatedAt = now + requestRepository.save(request) + if (request.status == RequestStatus.EXPIRED) { + expiredCount += 1 + // Терминальный статус → уведомляем geoportal через тот же transactional outbox. + requestStatusOutboxService.createTerminalIfAbsent(request, now) + log.info( + "Request expired: requestId={}, endDateTime={}", + request.id, + request.endDateTime, + ) + } + } + } + + return ExpireBatchResult(claimedCount = candidates.size, expiredCount = expiredCount) + } + + private data class ExpireBatchResult( + val claimedCount: Int, + val expiredCount: Int, + ) { + companion object { + val EMPTY = ExpireBatchResult(claimedCount = 0, expiredCount = 0) + } + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index 13d366e..e420f55 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -51,6 +51,7 @@ class RequestService( private val requestCellRepository: RequestCellRepository, private val geometryService: GeometryService, private val requestGridProjectionService: RequestGridProjectionService, + private val requestLifecycleService: RequestLifecycleService, private val entityManager: EntityManager, ) { @Transactional @@ -78,7 +79,9 @@ class RequestService( remainingGeometry = normalizedGeometryWkt, geometryArea = geometryArea, remainingArea = geometryArea, - coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, + // Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки, + // в отличие от importance — важности заявки для карты приоритетов ячеек. + coverageRequiredPercent = request.coverageRequiredPercent, importance = importance, beginDateTime = request.beginDateTime, endDateTime = request.endDateTime, @@ -90,6 +93,10 @@ class RequestService( updatedAt = now, ) + // Окно заявки может уже идти или закончиться к моменту приёма — сразу выставляем + // корректный статус (ACCEPTED/ACTIVE/EXPIRED), а не всегда ACCEPTED. + requestLifecycleService.refreshStatus(requestEntity, now) + persistNewRequest(requestEntity) replaceSurveyParams(requestEntity.id, request.optics, request.rsa) requestGridProjectionService.rebuildProjection(requestEntity) @@ -97,7 +104,7 @@ class RequestService( return CreateRequestResponseDto( id = requestEntity.id, name = requestEntity.name, - status = RequestStatusDto.ACCEPTED, + status = requestEntity.status.toDto(), surveyType = requestEntity.surveyType.toDto(), importance = requestEntity.importance, message = "Заявка успешно создана", @@ -369,7 +376,6 @@ class RequestService( } private companion object { - const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0 const val POSTGRES_UNIQUE_VIOLATION = "23505" const val REQUESTS_PRIMARY_KEY = "requests_pkey" const val DEFAULT_SORT_FIELD = "createdAt" diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt similarity index 55% rename from services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt rename to services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt index 044c9af..088dfa1 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt @@ -12,8 +12,15 @@ import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.util.UUID +/** + * Складывает в transactional outbox единое событие о **терминальном** статусе заявки + * (`COMPLETED` или `EXPIRED`) для geoportal. Идемпотентно по `(event_type, aggregate_id)`: + * на заявку приходится не более одного терминального события. + * + * Публикуется [OutboxPublisherService] в топик `pcp.request.status.v1` со статусом в payload. + */ @Service -class RequestCompletedOutboxService( +class RequestStatusOutboxService( private val outboxEventRepository: OutboxEventRepository, private val objectMapper: ObjectMapper, @@ -23,12 +30,17 @@ class RequestCompletedOutboxService( private val log = LoggerFactory.getLogger(this::class.java) @Transactional(propagation = Propagation.MANDATORY) - fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) { - val completedAt = request.completedAt ?: return - val payload = RequestCompletedPayload( + fun createTerminalIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) { + val status = request.status + require(status == RequestStatus.COMPLETED || status == RequestStatus.EXPIRED) { + "Терминальное outbox-событие требует COMPLETED/EXPIRED, получено: $status" + } + + val payload = RequestStatusPayload( requestId = request.id, - status = RequestStatus.COMPLETED.name, - completedAt = completedAt, + status = status.name, + // Для COMPLETED — момент достижения покрытия, для EXPIRED — момент фиксации истечения. + occurredAt = request.completedAt ?: eventCreatedAt, coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea), matchedAt = request.lastMatchedAt, eventCreatedAt = eventCreatedAt, @@ -36,7 +48,7 @@ class RequestCompletedOutboxService( val inserted = outboxEventRepository.insertIfAbsent( id = UUID.randomUUID(), - eventType = REQUEST_COMPLETED_EVENT_TYPE, + eventType = REQUEST_STATUS_EVENT_TYPE, aggregateType = REQUEST_AGGREGATE_TYPE, aggregateId = request.id, payload = objectMapper.writeValueAsString(payload), @@ -46,23 +58,23 @@ class RequestCompletedOutboxService( if (inserted == 0) { log.debug( - "REQUEST_COMPLETED outbox event already exists for requestId={}", + "Терминальное outbox-событие уже существует для requestId={}", request.id, ) } } - private data class RequestCompletedPayload( + private data class RequestStatusPayload( val requestId: UUID, val status: String, - val completedAt: LocalDateTime, + val occurredAt: LocalDateTime, val coveragePercent: Double, val matchedAt: LocalDateTime?, val eventCreatedAt: LocalDateTime, ) companion object { - const val REQUEST_COMPLETED_EVENT_TYPE = "REQUEST_COMPLETED" + const val REQUEST_STATUS_EVENT_TYPE = "REQUEST_TERMINAL_STATUS" const val REQUEST_AGGREGATE_TYPE = "REQUEST" } } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt index 0fb6ed7..5e5934c 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt @@ -31,7 +31,7 @@ class RouteMatchingService( private val requestLifecycleService: RequestLifecycleService, - private val requestCompletedOutboxService: RequestCompletedOutboxService, + private val requestStatusOutboxService: RequestStatusOutboxService, @param:Qualifier("routeMatchingTransactionOperations") private val routeMatchingTransactionOperations: TransactionOperations, @@ -168,7 +168,7 @@ class RouteMatchingService( val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt) requestRepository.save(request) if (statusRefresh.completedNow) { - requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt) + requestStatusOutboxService.createTerminalIfAbsent(request, matchedAt) } return true } diff --git a/services/pcp-request-service/src/main/resources/application.yaml b/services/pcp-request-service/src/main/resources/application.yaml index c3632e3..962dede 100644 --- a/services/pcp-request-service/src/main/resources/application.yaml +++ b/services/pcp-request-service/src/main/resources/application.yaml @@ -14,6 +14,9 @@ spring: pcp: outbox: - request-completed-topic: pcp.request.completed.v1 + request-status-topic: pcp.request.status.v1 publish-batch-size: 50 publish-fixed-delay-ms: 5000 + max-attempts: 10 + initial-backoff-ms: 1000 + max-backoff-ms: 60000 diff --git a/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql b/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql index 96df62e..6ba242d 100644 --- a/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql +++ b/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql @@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS outbox_events ( aggregate_id UUID NOT NULL, payload JSONB NOT NULL, status VARCHAR(32) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP NOT NULL DEFAULT now(), created_at TIMESTAMP NOT NULL, published_at TIMESTAMP NULL, error_message TEXT NULL, @@ -203,6 +205,7 @@ CREATE INDEX IF NOT EXISTS idx_request_kpp_request_id ON request_kpp(request_id) CREATE INDEX IF NOT EXISTS idx_request_kpp_kpp ON request_kpp(kpp); CREATE INDEX IF NOT EXISTS idx_request_route_matches_route_id ON request_route_matches(route_id); CREATE INDEX IF NOT EXISTS idx_outbox_events_status_created_at ON outbox_events(status, created_at); +CREATE INDEX IF NOT EXISTS idx_outbox_events_due ON outbox_events(event_type, status, next_attempt_at, created_at); CREATE INDEX IF NOT EXISTS idx_earth_cells_cell_num ON earth_cells(cell_num); CREATE INDEX IF NOT EXISTS idx_earth_cells_contour_geom ON earth_cells USING GIST (contour_geom); CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id); diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt index 7fb6226..42a202a 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt @@ -49,8 +49,11 @@ class MigrationSchemaTest { assertTrue(outboxEvents.contains("payload jsonb not null")) assertTrue(outboxEvents.contains("status varchar(32) not null")) + assertTrue(outboxEvents.contains("attempts integer not null default 0")) + assertTrue(outboxEvents.contains("next_attempt_at timestamp not null")) assertTrue(outboxEvents.contains("constraint uk_outbox_events_event_type_aggregate_id unique (event_type, aggregate_id)")) assertTrue(schema.contains("on outbox_events(status, created_at)")) + assertTrue(schema.contains("on outbox_events(event_type, status, next_attempt_at, created_at)")) } @Test diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt new file mode 100644 index 0000000..b5c74b7 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt @@ -0,0 +1,175 @@ +package org.nstart.dep265.requestservice.config + +import jakarta.validation.ConstraintViolation +import jakarta.validation.ConstraintViolationException +import org.apache.kafka.clients.consumer.Consumer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.producer.ProducerRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.kafka.listener.MessageListenerContainer +import org.springframework.kafka.support.SendResult +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture + +class KafkaConsumerConfigTest { + + private val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java)) + .withUserConfiguration(TestConfiguration::class.java, KafkaConsumerConfig::class.java) + .withPropertyValues( + "spring.kafka.bootstrap-servers=localhost:9092", + "spring.kafka.consumer.group-id=pcp-request-service", + "spring.kafka.consumer.auto-offset-reset=earliest", + "app.kafka.consumer.route.dlq-topic=$DLQ_TOPIC", + "app.kafka.consumer.route.max-attempts=3", + "app.kafka.consumer.route.backoff-ms=0", + "app.kafka.consumer.route.max-backoff-ms=0", + ) + + @Test + fun `creates error handler and recoverer beans`() { + contextRunner.run { context -> + assertNotNull(context.getBean(DefaultErrorHandler::class.java)) + assertNotNull(context.getBean(DeadLetterPublishingRecoverer::class.java)) + + val properties = context.getBean(RouteConsumerProperties::class.java) + assertEquals(DLQ_TOPIC, properties.dlqTopic) + assertEquals(3L, properties.maxAttempts) + } + } + + @Test + fun `recoverer publishes original record to configured dlq topic with diagnostics headers`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java) + val record = consumerRecord() + + recoverer.accept(record, ConstraintViolationException("route payload invalid", emptySet>())) + + val published = capturePublishedRecord(kafkaTemplate) + assertEquals(DLQ_TOPIC, published.topic()) + assertEquals(record.partition(), published.partition()) + assertEquals(record.key(), published.key()) + assertEquals(record.value(), published.value()) + assertEquals("pcp-request-service", published.headers().stringHeader("pcp-service-name")) + assertEquals( + ConstraintViolationException::class.java.name, + published.headers().stringHeader("pcp-exception-class"), + ) + assertEquals("route payload invalid", published.headers().stringHeader("pcp-exception-message")) + assertEquals(ROUTE_TOPIC, published.headers().stringHeader("pcp-original-topic")) + assertEquals("3", published.headers().stringHeader("pcp-original-partition")) + assertEquals("42", published.headers().stringHeader("pcp-original-offset")) + } + } + + @Test + fun `contract violation is recovered to dlq without retry`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val errorHandler = context.getBean(DefaultErrorHandler::class.java) + + val handled = errorHandler.handleOne( + ConstraintViolationException("route payload invalid", emptySet>()), + consumerRecord(), + mockConsumer(), + mock(MessageListenerContainer::class.java), + ) + + assertTrue(handled) + verify(kafkaTemplate, times(1)).send(anyProducerRecord()) + } + } + + @Test + fun `transient exception is retried with bounded policy before dlq recovery`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val errorHandler = context.getBean(DefaultErrorHandler::class.java) + val record = consumerRecord() + val consumer = mockConsumer() + val container = mock(MessageListenerContainer::class.java) + val exception = IllegalStateException("database is temporarily unavailable") + + // maxAttempts=3 → две первые доставки ретраятся (seek), на третьей — DLQ. + assertFalse(errorHandler.handleOne(exception, record, consumer, container)) + assertFalse(errorHandler.handleOne(exception, record, consumer, container)) + verify(kafkaTemplate, never()).send(anyProducerRecord()) + + assertTrue(errorHandler.handleOne(exception, record, consumer, container)) + verify(kafkaTemplate, times(1)).send(anyProducerRecord()) + } + } + + private fun consumerRecord(): ConsumerRecord = + ConsumerRecord(ROUTE_TOPIC, 3, 42L, "route-key", """{"routeId":"route-1"}""") + + private fun capturePublishedRecord( + kafkaTemplate: KafkaTemplate, + ): ProducerRecord { + val captor = producerRecordCaptor() + verify(kafkaTemplate, times(1)).send(captor.capture()) + return captor.value + } + + @Suppress("UNCHECKED_CAST") + private fun producerRecordCaptor(): ArgumentCaptor> = + ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor> + + @Suppress("UNCHECKED_CAST") + private fun mockConsumer(): Consumer = + mock(Consumer::class.java) as Consumer + + private fun org.apache.kafka.common.header.Headers.stringHeader(name: String): String? = + lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8) + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RouteConsumerProperties::class) + private class TestConfiguration { + + @Bean + @Suppress("UNCHECKED_CAST") + fun kafkaTemplate(): KafkaTemplate { + val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + val sendResult = mock(SendResult::class.java) as SendResult + `when`(kafkaTemplate.send(anyProducerRecord())) + .thenReturn(CompletableFuture.completedFuture(sendResult)) + return kafkaTemplate + } + } + + private companion object { + const val ROUTE_TOPIC = "pcp.route.georeference.v1" + const val DLQ_TOPIC = "pcp.route.georeference-dlq.v1" + + @Suppress("UNCHECKED_CAST") + fun anyProducerRecord(): ProducerRecord = + (any(ProducerRecord::class.java) as ProducerRecord?) + ?: ProducerRecord("stub-topic", "stub-key", "stub-payload") + + @Suppress("UNCHECKED_CAST") + fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate = + getBean(KafkaTemplate::class.java) as KafkaTemplate + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt index c91a450..13dbf15 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt @@ -25,6 +25,7 @@ import org.nstart.dep265.requestservice.repository.RequestRepository import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository import org.nstart.dep265.requestservice.service.GeometryService import org.nstart.dep265.requestservice.service.RequestGridProjectionService +import org.nstart.dep265.requestservice.service.RequestLifecycleService import org.nstart.dep265.requestservice.service.RequestService import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest @@ -98,6 +99,7 @@ class RequestControllerOpenApiSkeletonTest { requestCellRepository = requestCellRepository, geometryService = GeometryService(), requestGridProjectionService = mock(RequestGridProjectionService::class.java), + requestLifecycleService = RequestLifecycleService(GeometryService()), entityManager = entityManager, ) ) @@ -114,6 +116,7 @@ class RequestControllerOpenApiSkeletonTest { @Test fun `post v1 requests returns create response`() { + // Окно в будущем → приём детерминированно даёт ACCEPTED (createRequest зовёт refreshStatus). mockMvc.perform( post("/v1/requests") .contentType(MediaType.APPLICATION_JSON) @@ -125,8 +128,8 @@ class RequestControllerOpenApiSkeletonTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2090-01-01T00:00:00Z", + "endDateTime": "2090-01-31T23:59:59Z", "kpp": [1, 2, 3], "highPriorityTransmit": false, "optics": { diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt index 3996a45..d9cabac 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt @@ -45,6 +45,31 @@ class RequestApiDtoContractTest { assertEquals(10.0, request.importance) assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime) assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType) + // Поле опционально: при отсутствии в payload — порог покрытия по умолчанию 100%. + assertEquals(100.0, request.coverageRequiredPercent) + } + + @Test + fun `create request dto binds explicit coverage required percent`() { + val request = objectMapper.readValue( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", + "coverageRequiredPercent": 80.0, + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ) + + assertEquals(80.0, request.coverageRequiredPercent) } @Test diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt index 7cf9b2c..38fe235 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt @@ -82,19 +82,77 @@ class OutboxEventRepositoryJpaTest @Autowired constructor( ) outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType)) - val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( - eventType = "REQUEST_COMPLETED", + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = "REQUEST_TERMINAL_STATUS", + now = LocalDateTime.parse("2026-06-06T00:00:00"), + maxAttempts = 10, batchSize = 10, ) assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id }) } + @Test + @Transactional + fun `publishable query excludes events whose backoff has not elapsed`() { + val now = LocalDateTime.parse("2026-06-06T00:00:00") + val dueFailed = outboxEvent( + requestId = UUID.fromString("aaaaaaaa-0000-4000-8000-000000000001"), + status = OutboxEventStatus.FAILED, + attempts = 1, + nextAttemptAt = now.minusMinutes(1), + ) + val backoffPendingFailed = outboxEvent( + requestId = UUID.fromString("bbbbbbbb-0000-4000-8000-000000000002"), + status = OutboxEventStatus.FAILED, + attempts = 1, + nextAttemptAt = now.plusMinutes(5), + ) + outboxEventRepository.saveAllAndFlush(listOf(dueFailed, backoffPendingFailed)) + + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = "REQUEST_TERMINAL_STATUS", + now = now, + maxAttempts = 10, + batchSize = 10, + ) + + assertEquals(listOf(dueFailed.id), events.map { it.id }) + } + + @Test + @Transactional + fun `publishable query excludes events that exhausted max attempts`() { + val now = LocalDateTime.parse("2026-06-06T00:00:00") + val retriable = outboxEvent( + requestId = UUID.fromString("cccccccc-0000-4000-8000-000000000003"), + status = OutboxEventStatus.FAILED, + attempts = 9, + ) + val exhausted = outboxEvent( + requestId = UUID.fromString("dddddddd-0000-4000-8000-000000000004"), + status = OutboxEventStatus.FAILED, + attempts = 10, + ) + outboxEventRepository.saveAllAndFlush(listOf(retriable, exhausted)) + + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = "REQUEST_TERMINAL_STATUS", + now = now, + maxAttempts = 10, + batchSize = 10, + ) + + assertEquals(listOf(retriable.id), events.map { it.id }) + } + private fun outboxEvent( requestId: UUID, - eventType: String = "REQUEST_COMPLETED", + eventType: String = "REQUEST_TERMINAL_STATUS", status: OutboxEventStatus = OutboxEventStatus.NEW, createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), + attempts: Int = 0, + nextAttemptAt: LocalDateTime = createdAt, ): OutboxEventEntity { return OutboxEventEntity( id = UUID.randomUUID(), @@ -104,6 +162,8 @@ class OutboxEventRepositoryJpaTest @Autowired constructor( payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""), status = status, createdAt = createdAt, + attempts = attempts, + nextAttemptAt = nextAttemptAt, ) } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt new file mode 100644 index 0000000..aa4179f --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt @@ -0,0 +1,104 @@ +package org.nstart.dep265.requestservice.repository + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.nstart.dep265.requestservice.PcpRequestServiceApplication +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals + +@SpringBootTest( + classes = [PcpRequestServiceApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = [ + "spring.config.import=", + "spring.cloud.config.enabled=false", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:request-expiry;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.kafka.listener.auto-startup=false", + "spring.kafka.consumer.group-id=request-service-test", + "app.kafka.topics.route=request-service-test-route", + ], +) +class RequestRepositoryJpaTest @Autowired constructor( + private val requestRepository: RequestRepository, +) { + private val now: LocalDateTime = LocalDateTime.parse("2026-06-06T12:00:00") + + @BeforeEach + fun setUp() { + requestRepository.deleteAll() + } + + @Test + @Transactional + fun `expirable query returns only overdue accepted and active requests`() { + val overdueAccepted = request(status = RequestStatus.ACCEPTED, end = now.minusHours(1)) + val overdueActive = request(status = RequestStatus.ACTIVE, end = now.minusHours(2)) + val activeInWindow = request(status = RequestStatus.ACTIVE, end = now.plusHours(1)) + val alreadyCompleted = request(status = RequestStatus.COMPLETED, end = now.minusHours(1)) + val alreadyExpired = request(status = RequestStatus.EXPIRED, end = now.minusHours(1)) + val deletedOverdue = request( + status = RequestStatus.DELETED, + end = now.minusHours(1), + deletedAt = now.minusMinutes(30), + ) + requestRepository.saveAllAndFlush( + listOf(overdueAccepted, overdueActive, activeInWindow, alreadyCompleted, alreadyExpired, deletedOverdue), + ) + + val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 100) + + // Только незавершённые (ACCEPTED/ACTIVE) с истёкшим окном и без soft-delete, по возрастанию end. + assertEquals(listOf(overdueActive.id, overdueAccepted.id), expirable.map { it.id }) + } + + @Test + @Transactional + fun `expirable query honours the batch size limit`() { + val oldest = request(status = RequestStatus.ACTIVE, end = now.minusHours(3)) + val middle = request(status = RequestStatus.ACCEPTED, end = now.minusHours(2)) + val newest = request(status = RequestStatus.ACTIVE, end = now.minusHours(1)) + requestRepository.saveAllAndFlush(listOf(newest, oldest, middle)) + + val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 2) + + assertEquals(listOf(oldest.id, middle.id), expirable.map { it.id }) + } + + private fun request( + status: RequestStatus, + end: LocalDateTime, + begin: LocalDateTime = end.minusDays(1), + deletedAt: LocalDateTime? = null, + ): RequestEntity { + val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))" + return RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 1.0, + importance = 10.0, + beginDateTime = begin, + endDateTime = end, + surveyType = RequestSurveyType.OPTICS, + status = status, + createdAt = begin.minusDays(1), + updatedAt = begin.minusDays(1), + deletedAt = deletedAt, + ) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt index 83b327c..fac212c 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt @@ -6,6 +6,8 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.doReturn import org.mockito.Mockito.doAnswer @@ -30,13 +32,16 @@ import java.util.concurrent.CompletableFuture class OutboxPublisherServiceTest { private val objectMapper = jacksonObjectMapper() private val properties = OutboxProperties( - requestCompletedTopic = "pcp.request.completed.v1", + requestStatusTopic = "pcp.request.status.v1", publishBatchSize = 50, publishFixedDelayMs = 5_000, + maxAttempts = 10, + initialBackoffMs = 1_000, + maxBackoffMs = 60_000, ) @Test - fun `new request completed event is published and marked as published`() { + fun `new terminal event is published and marked as published`() { val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4") val event = outboxEvent( requestId = requestId, @@ -50,7 +55,7 @@ class OutboxPublisherServiceTest { assertEquals(1, processedCount) verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), event.payload.toString(), ) @@ -61,11 +66,12 @@ class OutboxPublisherServiceTest { } @Test - fun `failed request completed event is picked for retry`() { + fun `failed event is picked for retry`() { val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f") val event = outboxEvent( requestId = requestId, status = OutboxEventStatus.FAILED, + attempts = 1, errorMessage = "previous failure", ) val outboxEventRepository = repositoryReturning(listOf(event)) @@ -75,7 +81,7 @@ class OutboxPublisherServiceTest { service.publishPending() verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), event.payload.toString(), ) @@ -95,14 +101,14 @@ class OutboxPublisherServiceTest { service.publishPending() verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), objectMapper.readTree(savedPayload).toString(), ) } @Test - fun `kafka failure marks event as failed and stores short error message`() { + fun `kafka failure increments attempts, schedules backoff and stores short error message`() { val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb") val event = outboxEvent(requestId = requestId) val outboxEventRepository = repositoryReturning(listOf(event)) @@ -113,17 +119,42 @@ class OutboxPublisherServiceTest { assertEquals(1, processedCount) assertEquals(OutboxEventStatus.FAILED, event.status) + assertEquals(1, event.attempts) assertTrue(event.errorMessage!!.contains("broker unavailable")) assertNull(event.publishedAt) + // backoff: следующая попытка отложена в будущее относительно создания события. + assertTrue(event.nextAttemptAt.isAfter(event.createdAt)) verify(outboxEventRepository).save(event) } + @Test + fun `event exhausting max attempts becomes terminal and is not rescheduled`() { + val requestId = UUID.fromString("c0ffee00-0000-4000-8000-000000000001") + val event = outboxEvent( + requestId = requestId, + status = OutboxEventStatus.FAILED, + attempts = properties.maxAttempts - 1, + errorMessage = "previous failure", + ) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken"))) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + service.publishPending() + + assertEquals(OutboxEventStatus.FAILED, event.status) + assertEquals(properties.maxAttempts, event.attempts) + // Исчерпав попытки, событие терминально: backoff не назначается (next_attempt_at не двигаем). + assertEquals(event.createdAt, event.nextAttemptAt) + } + @Test fun `failed event is not retried repeatedly in same publish pending call`() { val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7") val event = outboxEvent( requestId = requestId, status = OutboxEventStatus.FAILED, + attempts = 1, errorMessage = "previous failure", ) val outboxEventRepository = repositoryReturning(listOf(event)) @@ -134,15 +165,18 @@ class OutboxPublisherServiceTest { assertEquals(1, processedCount) verify(kafkaTemplate, times(1)).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), event.payload.toString(), ) - verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked( - RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, - properties.publishBatchSize, + verify(outboxEventRepository, times(1)).findPublishableForUpdateSkipLocked( + anyString(), + anyLocalDateTime(), + anyInt(), + anyInt(), ) assertEquals(OutboxEventStatus.FAILED, event.status) + assertEquals(2, event.attempts) assertTrue(event.errorMessage!!.contains("still broken")) } @@ -153,6 +187,7 @@ class OutboxPublisherServiceTest { val failedEvent = outboxEvent( requestId = failedRequestId, status = OutboxEventStatus.FAILED, + attempts = 1, errorMessage = "previous failure", ) val newEvent = outboxEvent(requestId = newRequestId) @@ -164,12 +199,12 @@ class OutboxPublisherServiceTest { assertEquals(2, processedCount) verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, failedRequestId.toString(), failedEvent.payload.toString(), ) verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, newRequestId.toString(), newEvent.payload.toString(), ) @@ -206,9 +241,11 @@ class OutboxPublisherServiceTest { val outboxEventRepository = mock(OutboxEventRepository::class.java) doReturn(events) .`when`(outboxEventRepository) - .findRequestCompletedPublishableForUpdateSkipLocked( - RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, - properties.publishBatchSize, + .findPublishableForUpdateSkipLocked( + anyString(), + anyLocalDateTime(), + anyInt(), + anyInt(), ) return outboxEventRepository } @@ -256,20 +293,31 @@ class OutboxPublisherServiceTest { requestId: UUID, payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""", status: OutboxEventStatus = OutboxEventStatus.NEW, + attempts: Int = 0, errorMessage: String? = null, ): OutboxEventEntity { + val createdAt = LocalDateTime.parse("2026-01-01T10:00:00") return OutboxEventEntity( id = UUID.randomUUID(), - eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, - aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE, + eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE, + aggregateType = RequestStatusOutboxService.REQUEST_AGGREGATE_TYPE, aggregateId = requestId, payload = objectMapper.readTree(payload), status = status, - createdAt = LocalDateTime.parse("2026-01-01T10:00:00"), + createdAt = createdAt, + attempts = attempts, + nextAttemptAt = createdAt, errorMessage = errorMessage, ) } + // Регистрирует matcher, но возвращает non-null заглушку (any() на non-null LocalDateTime в Kotlin + // вернул бы null и сломал стек матчеров Mockito). + private fun anyLocalDateTime(): LocalDateTime { + any(LocalDateTime::class.java) + return LocalDateTime.now() + } + private class ImmediateTransactionOperations : TransactionOperations { override fun execute(action: TransactionCallback): T { return action.doInTransaction(SimpleTransactionStatus()) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt deleted file mode 100644 index 944eea5..0000000 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.nstart.dep265.requestservice.service - -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.mockingDetails -import org.nstart.dep265.requestservice.entity.RequestEntity -import org.nstart.dep265.requestservice.entity.RequestStatus -import org.nstart.dep265.requestservice.entity.RequestSurveyType -import org.nstart.dep265.requestservice.repository.OutboxEventRepository -import java.time.LocalDateTime -import java.util.UUID - -class RequestCompletedOutboxServiceTest { - private val objectMapper = jacksonObjectMapper() - .registerModule(JavaTimeModule()) - .findAndRegisterModules() - - @Test - fun `request completed payload contains required fields`() { - val outboxEventRepository = mock(OutboxEventRepository::class.java) - val service = RequestCompletedOutboxService( - outboxEventRepository = outboxEventRepository, - objectMapper = objectMapper, - geometryService = GeometryService(), - ) - val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846") - val completedAt = LocalDateTime.parse("2026-01-01T10:15:30") - val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00") - val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00") - val request = RequestEntity( - id = requestId, - name = "Тест1", - geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", - remainingGeometry = "POLYGON EMPTY", - geometryArea = 1.0, - remainingArea = 0.0, - importance = 10.0, - beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), - endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"), - surveyType = RequestSurveyType.OPTICS, - status = RequestStatus.COMPLETED, - lastMatchedAt = matchedAt, - completedAt = completedAt, - createdAt = LocalDateTime.parse("2026-01-01T09:00:00"), - updatedAt = completedAt, - ) - - service.createRequestCompletedIfAbsent(request, eventCreatedAt) - - val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { - invocation -> invocation.method.name == "insertIfAbsent" - } - val payload = objectMapper.readTree(insertInvocation.arguments[4] as String) - assertEquals(requestId.toString(), payload["requestId"].asText()) - assertEquals("COMPLETED", payload["status"].asText()) - assertNotNull(payload["completedAt"]) - assertEquals(100.0, payload["coveragePercent"].asDouble()) - assertNotNull(payload["matchedAt"]) - assertNotNull(payload["eventCreatedAt"]) - } -} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt new file mode 100644 index 0000000..31c0cb8 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt @@ -0,0 +1,203 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockingDetails +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.nstart.dep265.requestservice.config.RequestExpiryProperties +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime +import java.util.UUID + +class RequestExpiryServiceTest { + + @Test + fun `expires overdue accepted and active requests and emits terminal events`() { + val requestRepository = mock(RequestRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200)) + + val expiredAccepted = overdueRequest(status = RequestStatus.ACCEPTED) + val expiredActive = overdueRequest(status = RequestStatus.ACTIVE) + doReturn(listOf(expiredAccepted, expiredActive)) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(2, expiredCount) + assertEquals(RequestStatus.EXPIRED, expiredAccepted.status) + assertEquals(RequestStatus.EXPIRED, expiredActive.status) + verify(requestRepository).save(expiredAccepted) + verify(requestRepository).save(expiredActive) + + // По каждой истёкшей заявке — терминальное событие в outbox для geoportal. + val terminalInserts = mockingDetails(outboxEventRepository).invocations + .filter { invocation -> invocation.method.name == "insertIfAbsent" } + assertEquals(2, terminalInserts.size) + assertTrue(terminalInserts.all { invocation -> invocation.arguments[1] == "REQUEST_TERMINAL_STATUS" }) + } + + @Test + fun `keeps fetching while a full batch is returned`() { + val requestRepository = mock(RequestRepository::class.java) + val service = expiryService( + requestRepository, + mock(OutboxEventRepository::class.java), + RequestExpiryProperties(batchSize = 1), + ) + + val first = overdueRequest(status = RequestStatus.ACTIVE) + val second = overdueRequest(status = RequestStatus.ACCEPTED) + doReturn(listOf(first), listOf(second), emptyList()) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(2, expiredCount) + verify(requestRepository, times(3)) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + } + + @Test + fun `empty sweep returns zero and emits nothing`() { + val requestRepository = mock(RequestRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200)) + + doReturn(emptyList()) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(0, expiredCount) + verify(requestRepository, never()).save(any(RequestEntity::class.java)) + verifyNoInteractions(outboxEventRepository) + } + + @Test + fun `request still inside its window is not expired`() { + val requestRepository = mock(RequestRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200)) + + val now = LocalDateTime.now() + val inWindow = request( + status = RequestStatus.ACTIVE, + beginDateTime = now.minusDays(1), + endDateTime = now.plusDays(1), + ) + doReturn(listOf(inWindow)) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(0, expiredCount) + assertEquals(RequestStatus.ACTIVE, inWindow.status) + verify(requestRepository, never()).save(any(RequestEntity::class.java)) + verifyNoInteractions(outboxEventRepository) + } + + @Test + fun `disabled scheduler does not sweep`() { + val requestRepository = mock(RequestRepository::class.java) + val service = expiryService( + requestRepository, + mock(OutboxEventRepository::class.java), + RequestExpiryProperties(enabled = false), + ) + + service.expireOverdueScheduledBatch() + + verify(requestRepository, never()) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + } + + private fun expiryService( + requestRepository: RequestRepository, + outboxEventRepository: OutboxEventRepository, + properties: RequestExpiryProperties, + ): RequestExpiryService { + val geometryService = GeometryService() + return RequestExpiryService( + requestRepository = requestRepository, + requestLifecycleService = RequestLifecycleService(geometryService), + requestStatusOutboxService = RequestStatusOutboxService( + outboxEventRepository = outboxEventRepository, + objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules(), + geometryService = geometryService, + ), + requestExpiryProperties = properties, + transactionOperations = ImmediateTransactionOperations(), + ) + } + + private fun overdueRequest(status: RequestStatus): RequestEntity { + val now = LocalDateTime.now() + return request( + status = status, + beginDateTime = now.minusDays(10), + endDateTime = now.minusDays(1), + ) + } + + private fun request( + status: RequestStatus, + beginDateTime: LocalDateTime, + endDateTime: LocalDateTime, + ): RequestEntity { + val geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + return RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 1.0, + importance = 10.0, + beginDateTime = beginDateTime, + endDateTime = endDateTime, + surveyType = RequestSurveyType.OPTICS, + status = status, + createdAt = beginDateTime.minusDays(1), + updatedAt = beginDateTime.minusDays(1), + ) + } + + // Регистрирует matcher, но возвращает non-null заглушку — иначе any() на non-null LocalDateTime + // в Kotlin отдаёт null и ломает стек матчеров Mockito. + private fun anyLocalDateTime(): LocalDateTime { + any(LocalDateTime::class.java) + return LocalDateTime.now() + } + + private class ImmediateTransactionOperations : TransactionOperations { + override fun execute(action: TransactionCallback): T { + val status: TransactionStatus = SimpleTransactionStatus() + return action.doInTransaction(status) + } + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt index 3f02619..4b95e86 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt @@ -50,6 +50,7 @@ class RequestServiceTest { requestCellRepository = requestCellRepository, geometryService = GeometryService(), requestGridProjectionService = requestGridProjectionService, + requestLifecycleService = RequestLifecycleService(GeometryService()), entityManager = entityManager, ) @@ -95,6 +96,22 @@ class RequestServiceTest { verify(requestGridProjectionService).rebuildProjection(persistedRequests.single()) } + @Test + fun `create request passes through explicit coverage required percent from api`() { + val persistedRequests = mutableListOf() + doReturn(false) + .`when`(requestRepository) + .existsById(REQUEST_ID) + doAnswer { invocation -> + persistedRequests += invocation.getArgument(0) + null + }.`when`(entityManager).persist(any(RequestEntity::class.java)) + + service.createRequest(createRequestDto(coverageRequiredPercent = 80.0)) + + assertEquals(80.0, persistedRequests.single().coverageRequiredPercent) + } + @Test fun `get request by id returns name and importance`() { val request = existingRequest() @@ -279,7 +296,7 @@ class RequestServiceTest { verify(requestCellRepository).deleteByRequestId(REQUEST_ID) } - private fun createRequestDto(): CreateRequestDto { + private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto { return CreateRequestDto( id = REQUEST_ID, name = REQUEST_NAME, @@ -287,6 +304,7 @@ class RequestServiceTest { importance = REQUEST_IMPORTANCE, beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), + coverageRequiredPercent = coverageRequiredPercent, optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt new file mode 100644 index 0000000..cc8f322 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt @@ -0,0 +1,107 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockingDetails +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import java.time.LocalDateTime +import java.util.UUID + +class RequestStatusOutboxServiceTest { + private val objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules() + + @Test + fun `completed terminal payload contains required fields`() { + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = service(outboxEventRepository) + val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846") + val completedAt = LocalDateTime.parse("2026-01-01T10:15:30") + val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00") + val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00") + val request = RequestEntity( + id = requestId, + name = "Тест1", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + remainingGeometry = "POLYGON EMPTY", + geometryArea = 1.0, + remainingArea = 0.0, + importance = 10.0, + beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), + endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"), + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.COMPLETED, + lastMatchedAt = matchedAt, + completedAt = completedAt, + createdAt = LocalDateTime.parse("2026-01-01T09:00:00"), + updatedAt = completedAt, + ) + + service.createTerminalIfAbsent(request, eventCreatedAt) + + val payload = objectMapper.readTree(insertedPayload(outboxEventRepository)) + assertEquals(requestId.toString(), payload["requestId"].asText()) + assertEquals("COMPLETED", payload["status"].asText()) + assertEquals(completedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java)) + assertEquals(100.0, payload["coveragePercent"].asDouble()) + assertNotNull(payload["matchedAt"]) + assertNotNull(payload["eventCreatedAt"]) + } + + @Test + fun `expired terminal payload carries expired status and partial coverage`() { + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = service(outboxEventRepository) + val requestId = UUID.fromString("9c2f0c4b-3a51-4e2d-9d8a-1f4b2c6e7a01") + val eventCreatedAt = LocalDateTime.parse("2026-02-01T00:00:00") + val request = RequestEntity( + id = requestId, + name = "Тест1", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + remainingGeometry = "POLYGON((0 0, 0.5 0, 0.5 1, 0 1, 0 0))", + geometryArea = 1.0, + remainingArea = 0.5, + importance = 10.0, + beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"), + endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"), + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.EXPIRED, + completedAt = null, + createdAt = LocalDateTime.parse("2025-12-31T00:00:00"), + updatedAt = eventCreatedAt, + ) + + service.createTerminalIfAbsent(request, eventCreatedAt) + + val payload = objectMapper.readTree(insertedPayload(outboxEventRepository)) + assertEquals("EXPIRED", payload["status"].asText()) + // completedAt отсутствует у EXPIRED → occurredAt = момент фиксации истечения. + assertEquals(eventCreatedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java)) + assertEquals(50.0, payload["coveragePercent"].asDouble()) + assertTrue(payload["matchedAt"].isNull) + } + + private fun service(outboxEventRepository: OutboxEventRepository): RequestStatusOutboxService { + return RequestStatusOutboxService( + outboxEventRepository = outboxEventRepository, + objectMapper = objectMapper, + geometryService = GeometryService(), + ) + } + + private fun insertedPayload(outboxEventRepository: OutboxEventRepository): String { + val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { invocation -> + invocation.method.name == "insertIfAbsent" + } + return insertInvocation.arguments[4] as String + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt index 6af3400..d4fb832 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt @@ -288,7 +288,7 @@ class RouteMatchingServiceTest { invocation -> invocation.method.name == "insertIfAbsent" } assertEquals(1, insertInvocations.size) - assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1]) + assertEquals("REQUEST_TERMINAL_STATUS", insertInvocations.single().arguments[1]) assertEquals(request.id, insertInvocations.single().arguments[3]) assertEquals(RequestStatus.COMPLETED, request.status) } @@ -305,16 +305,16 @@ class RouteMatchingServiceTest { requestValidationService = RequestValidationService(geometryService), geometryService = geometryService, requestLifecycleService = RequestLifecycleService(geometryService), - requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService), + requestStatusOutboxService = requestStatusOutboxService(outboxEventRepository, geometryService), routeMatchingTransactionOperations = ImmediateTransactionOperations(), ) } - private fun requestCompletedOutboxService( + private fun requestStatusOutboxService( outboxEventRepository: OutboxEventRepository, geometryService: GeometryService, - ): RequestCompletedOutboxService { - return RequestCompletedOutboxService( + ): RequestStatusOutboxService { + return RequestStatusOutboxService( outboxEventRepository = outboxEventRepository, objectMapper = jacksonObjectMapper() .registerModule(JavaTimeModule()) From c74327011aec716a9bf5118783f5dbef60c964db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 8 Jun 2026 13:55:01 +0300 Subject: [PATCH 75/79] =?UTF-8?q?docs(=D0=B1=D1=8D=D0=BA=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?):=20#39=20=E2=80=94=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=91=D1=82?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B=20=D1=81=20?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D0=BD=D0=BE=D0=BC=20>45=C2=B0=20(slots/?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зафиксирована диагностика: корень — оптические полосы Emissio в каталоге (satellite_slot_angle) уходят до 50.42°, slots-service берёт центр полосы без зажима по 45°. Версия «логика локации» проверена и отклонена. --- docs/бэклог-pcp-tgu-ops-ui.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index 8ede38b..4bef3cf 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -13,7 +13,7 @@ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). -_Обновлено: 2026-06-06_ +_Обновлено: 2026-06-08_ --- @@ -22,17 +22,22 @@ _Обновлено: 2026-06-06_ ### Карта и отображение - [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца. - [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. -- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». На вкладке «Редактор» они должны быть другого цвета и выделяться. +- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться. + +### Группировка +- [ ] **#36** — Заполнение бортового накопителя на вкладке «Группировка». Сейчас (MVP) считается на фронте из объёмов маршрутов плана (`volumeMb`): старт 0 на начало интервала плана, `+vol` на конце съёмки, `−vol` на конце сброса, ёмкость борта — модельные 500 ГБ (`ONBOARD_CAPACITY_MB` в `features/constellation/realData.ts`). Довести до реального источника: брать ёмкость борта из НСИ платформы (не хардкод), а заполнение — из телеметрии/бортового учёта, когда такой канал появится; уточнить модель «съёмка копит / сброс опустошает» (частичные сбросы, очерёдность). ### Редактор - [ ] **#4** — Показывать на вкладке «Редактор» только те заявки, которые может удовлетворить аппарат (например, для оптики не показывать заявку, для которой доступна только локация). - [ ] **#5** — Дать возможность превратить план в статусе `PLANNED` (построенный цепочкой от последнего `LAID_IN`) в черновик и вручную наполнить его включениями — до того, как он будет автоматически выдан по таймеру. +- [ ] **#38** — Правка SLOTS-маршрута «телепортирует» его (исчезает с карты). При «Редактировать» редактор пересобирает контур через `survey-route/build`, передавая сохранённый `timeStart`. Для маршрутов `source=MANUAL` это корректно (их `timeStart` = время построения, контур воспроизводится). Для `source=SLOTS` `timeStart`/виток/контур посчитаны другим конвейером и не согласованы: напр. режим 174 хранит `timeStart=2026-06-07T01:51:38`, виток 152, контур над Чёрным морем (центр 45.42, 39.40), но в 01:51:38 спутник по flight-line над Индией (12–16° с.ш., виток 150) — пересборка даёт контур над Индией (10.73, 77.06), исходный прячется → маршрут «пропадает» из вида. Корень — та же баллистическая рассинхронизация, что и [#37]. _Решение (2026-06-08): чиним только на бэкенде — согласовать `timeStart`/виток/контур у SLOTS-режимов, чтобы `survey-route/build` воспроизводил их. UI-страховку (не прятать исходный контур / блокировать правку SLOTS) сознательно НЕ делаем — симптом остаётся до бэкенд-фикса._ ### Комплексный план - [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки. ### Бэкенд / инфраструктура - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). +- [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:50–12:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene` → `visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. ### Проверка сервисов - [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности. @@ -40,6 +45,7 @@ _Обновлено: 2026-06-06_ ### Баги - [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». +- [ ] **#39** — Авторасчёт плана выдаёт нереализуемые маршруты съёмки с креном > 45° (напр. `survey-211`, Emissio 1, крен 46.8°). Такой маршрут нельзя построить вручную: фронт зажимает `MAX_ROUTE_ROLL_DEG = 45` (`TguEditorTab.tsx`), а баллистика/геометрия маршрута и фильтр видимости (МПЛ, `abs(kren) <= 45°`) определены только до 45°. _Диагностика (2026-06-08): корень — данные каталога, не логика. slots-service строит крен слота как центр угловой полосы из каталога (`SlotService.kt:903-905`, `gamma=(begin+end)/2`) и предел 45° не учитывает; полосы берутся напрямую из `satellite_slot_angle`. В сиде каталога (`pcp-satellite-catalog-service/.../V1__initial.sql`) у **оптических** Emissio (id 1-6, 22) собственный профиль полос уходит до 50.42°, и полоса №17 [46.08°, 47.51°] даёт центр ровно 46.8°. Версия «посчитали по логике локации» проверена и НЕ подтвердилась: в slots-service нет ветвления по типу КА (оптика/локация) — все КА строятся одним `TestSatelliteImpl`, солвер выбирается стратегией запроса (GREEDY/CONTINUOUS), `type_code` не используется. Reflexio (локация) наоборот имеет узкие полосы ~17-25°. Решение (вернуться позже): либо обрезать/убрать в каталоге оптические полосы за 45° (поз. №16-20 у Emissio), либо добавить в slots-service зажим/отсев слотов по рабочему пределу крена КА. Истинный предел Emissio подтвердить у владельца каталога/баллистиков перед правкой._ ### Аудит — надёжность и бэкенд _Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ @@ -80,3 +86,12 @@ _Источник и рекомендации: [аудит-сервис-зада - [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._ - [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._ - [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._ + + + + + + + +Добавил вручную: +- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают \ No newline at end of file From ff0b2639c95312192373fe0aee027fde1fdae4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 8 Jun 2026 13:59:46 +0300 Subject: [PATCH 76/79] =?UTF-8?q?feat(editor-map):=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=86=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B8=20=D1=81=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=B0=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сброс рисуется приглушённо-малиновым (rgba(214,96,138,0.82), ширина 5) поверх трассы: контрастно к бирюзовым виткам, не сливается с заявками и контурами съёмки. --- .../src/features/tgu-map-2d/canvas/drawTracks.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts index f428eaa..07a58f0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts @@ -2,8 +2,11 @@ import type { MapProjection } from "../geometry/mapProjection"; import type { MapLine } from "../model/mapTypes"; const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 }; -// Сброс — яркая толстая линия поверх трассы, на отрезке от начала до конца сеанса. -const DROP_STYLE = { stroke: "#ffcf3f", width: 5 }; +// Сброс — толстая линия поверх трассы, на отрезке от начала до конца сеанса. +// Цвет приглушённо-малиновый: отдельный тёплый оттенок, контрастный к бирюзовым +// виткам трассы (не теряется среди них) и не совпадающий с цветами заявок/маршрутов, +// но без кричащей яркости, чтобы не мешать просмотру контуров съёмки. +const DROP_STYLE = { stroke: "rgba(214, 96, 138, 0.82)", width: 5 }; export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) { ctx.save(); From 2c8b317c420dbc62a8fe2eab5376bd55b1ec1954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 8 Jun 2026 13:59:53 +0300 Subject: [PATCH 77/79] =?UTF-8?q?fix(editor):=20=D0=B7=D0=B0=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BA=D1=80=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=B0=D1=80?= =?UTF-8?q?=D1=88=D1=80=D1=83=D1=82=D0=B0=20=D0=BA=20=C2=B145=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд считает геометрию видимости/маршрута только в |крен| ≤ 45°, поэтому предел снижен с 89° до 45°. Введён общий clampRoll (зажим + округление до 0.1°), применён при ручной правке крена (кнопки ±, прямой ввод) и при загрузке сохранённого маршрута в окно построения (легаси-маршрут вне ±45° зажимается). --- .../src/features/tgu-editor/TguEditorTab.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index f505ca2..a4335e6 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -74,8 +74,15 @@ const SHOOT_WINDOW_MS = 6 * 60 * 1000; const DEFAULT_CAPTURE_ANGLE_DEG = 1.5; /** Дефолтная длительность строящегося маршрута съёмки (мс). */ const DEFAULT_ROUTE_DURATION_MS = 30_000; -/** Предел крена (gamma) маршрута при ручной правке, градусы. */ -const MAX_ROUTE_ROLL_DEG = 89; +/** + * Предел крена (gamma) маршрута, градусы. Параметры видимости/маршрута бэкенд считает только + * в диапазоне |крен| ≤ 45°; за его пределами геометрия маршрута не определена, поэтому жёстко + * зажимаем здесь — и при ручной правке, и при загрузке сохранённого маршрута в окно построения. + */ +const MAX_ROUTE_ROLL_DEG = 45; +/** Крен с зажимом в допустимый диапазон и округлением до 1 знака (как при ручном вводе). */ +const clampRoll = (deg: number): number => + Math.round(Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) * 10) / 10; /** Допустимый выход окна маршрута за пределы окна видимости, мс (±30 минут). */ const ROUTE_TIME_MARGIN_MS = 30 * 60_000; const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { @@ -421,15 +428,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, if (prefill) { editShootPrefillRef.current = null; setRouteWindow({ startMs: prefill.startMs, endMs: prefill.endMs }); - setRouteRoll(Math.round(prefill.roll * 10) / 10); + // Сохранённый маршрут мог быть с креном вне ±45° (легаси) — зажимаем при загрузке в окно. + setRouteRoll(clampRoll(prefill.roll)); return; } const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs; const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2; const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; setRouteWindow({ startMs: mid - half, endMs: mid + half }); - // Крен — не точнее одного знака после запятой (как и при ручном вводе). - setRouteRoll(Math.round((selectedShootPass.rollDeg ?? 0) * 10) / 10); + setRouteRoll(clampRoll(selectedShootPass.rollDeg ?? 0)); }, [rightMode, selectedShootPass]); useEffect(() => { @@ -1325,14 +1332,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }) } onRouteRollChange={(deltaDeg) => - setRouteRoll((current) => - Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, (current ?? 0) + deltaDeg)) - ) + setRouteRoll((current) => clampRoll((current ?? 0) + deltaDeg)) } onRouteRollSet={(deg) => - setRouteRoll((current) => - Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current - ) + setRouteRoll((current) => (Number.isFinite(deg) ? clampRoll(deg) : current)) } onSelectPass={(pass) => { setSelectedShootPass(pass); From 5fa3ceaac90e0c70c2cd184210023b25bd39c0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 8 Jun 2026 14:00:03 +0300 Subject: [PATCH 78/79] =?UTF-8?q?feat(=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0):=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81=D1=8A=D1=91=D0=BC=D0=BA=D0=B0?= =?UTF-8?q?/=D1=81=D0=B1=D1=80=D0=BE=D1=81/=D1=81=D0=B2=D1=8F=D0=B7=D1=8C?= =?UTF-8?q?=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B1=D0=BE=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дашборд «Группировка» наполняется из плана: режимы SURVEY/DROP → интервалы съёмки и сброса с контуром и станцией, окна ЗРВ выделены в отдельную «связь» (раньше показывались как сброс). Заполнение бортового накопителя считается из объёмов маршрутов (volumeMb) с модельной ёмкостью 500 ГБ (ONBOARD_CAPACITY_MB) — memAt по таймлайну, подсветка контура/линии сброса на карте. --- .../constellation/ConstellationDashboard.jsx | 29 ++- .../src/features/constellation/MapView.jsx | 28 ++- .../src/features/constellation/Timeline.jsx | 5 +- .../src/features/constellation/realData.ts | 186 ++++++++++++++++-- .../src/features/constellation/sim.js | 23 ++- 5 files changed, 238 insertions(+), 33 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx index 887010c..97701f8 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx +++ b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx @@ -44,7 +44,7 @@ function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) { const liveRef = useRef(false); const focusRef = useRef(null); const hoverRef = useRef(null); - const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, env.memAt ? env.memAt(s, env.epochMs) : s.mem0]))); const lastT = useRef(performance.now()); const [, setTick] = useState(0); @@ -80,17 +80,24 @@ function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) { else simRef.current += dt * speedRef.current; if (simRef.current > domain.end) simRef.current = domain.end; if (simRef.current < domain.start) simRef.current = domain.start; - // memory dynamics - const secEq = (dt * speedRef.current) / 1000; - for (const s of env.sats) { - const stt = SIM.statusAt(s, simRef.current, env); - let m = memRef.current[s.id]; - if (stt.state === "capture") m += 0.7 * secEq; - else if (stt.state === "downlink") m -= 1.5 * secEq; - else m += 0.02 * secEq; // idle housekeeping drift - memRef.current[s.id] = Math.max(0, Math.min(100, m)); + // memory dynamics — только когда нет авторитетного источника заполнения борта + if (!env.memAt) { + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } } } + // Реальное заполнение борта (объёмы маршрутов плана) — сэмплируем каждый кадр, + // чтобы значение следовало и за скрабом на паузе. + if (env.memAt) { + for (const s of env.sats) memRef.current[s.id] = env.memAt(s, simRef.current); + } if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); acc += dt; if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } @@ -164,6 +171,7 @@ function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) {
+
@@ -206,6 +214,7 @@ function MapLegend() {
ЛОКАЦИЯ
МАРШРУТ СЪЁМКИ
СБРОС НА СТАНЦИЮ
+
СВЯЗЬ С КА
ТЕНЬ ЗЕМЛИ
); diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx index ec8512d..e8952ef 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx +++ b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx @@ -31,6 +31,7 @@ const MAP_C = { gratText: "rgba(150,180,178,0.25)", amber: "#d99a4e", blue: "#5b9bd5", + link: "#6f8fae", red: "#d9685f", teal: "#5fb3a3", night: "rgba(20,28,46,0.55)", @@ -217,13 +218,32 @@ function MapView({ env, apiRef }) { } strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); ctx.setLineDash([]); - tracePoly(ctx, s.proj, stt.roi.poly); - ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); - ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + // контур съёмки есть не у всех режимов плана — рисуем только когда задан + if (stt.roi && stt.roi.poly && stt.roi.poly.length) { + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + } + + // link (связь с КА) — пунктир к станции на всём окне радиовидимости (ЗРВ), + // даже когда сброс не идёт. Тоньше/бледнее сброса; реальный сброс ляжет поверх. + if (env.sched) { + const lk = SIM.intervalAt((env.sched[sat.id] || {}).link, tMs); + if (lk && lk.lon != null && lk.lat != null) { + const lp = s.proj([lk.lon, lk.lat]); + if (lp) { + ctx.save(); + ctx.setLineDash([3, 6]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.link, dim ? 0.22 : 0.55); ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(lp[0], lp[1]); ctx.stroke(); + ctx.restore(); + } + } } // downlink (сброс) — animated dashed line to station - if (stt.state === "downlink") { + if (stt.state === "downlink" && stt.station && stt.station.lon != null && stt.station.lat != null) { const sp = s.proj([stt.station.lon, stt.station.lat]); if (sp) { ctx.save(); diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx index c83e318..cbb6ba6 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx +++ b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx @@ -14,6 +14,7 @@ const TL_AXIS = 26; const TL_COL = { capture: "#d99a4e", downlink: "#5b9bd5", + link: "rgba(111,143,174,0.38)", // связь с КА (ЗРВ) — фоновое окно радиовидимости prohibit: "#d9685f", shadow: "rgba(120,140,180,0.30)", }; @@ -125,7 +126,9 @@ function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live {/* shadow (under) */} {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} - {/* downlink */} + {/* link (связь с КА — окна радиовидимости, фоновая полоса) */} + {(tr.link || []).map((iv, k) => band(iv, "lk" + k, TL_COL.link, TL_ROW - 10))} + {/* downlink (реальный сброс из плана) */} {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} {/* capture */} {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts index fde7a1b..3558a0e 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts +++ b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts @@ -10,19 +10,23 @@ Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа, так что ConstellationDashboard рендерит реальные данные без изменений разметки. ============================================================ */ -import { fetchPlatforms, fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; +import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi"; import { fetchStations, type StationDto } from "../../api/stationsApi"; import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchSatelliteRva } from "../../api/ballisticsApi"; -import type { TguPlatform } from "../../model/tguTypes"; +import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi"; +import type { TguPlatform, TguPlan } from "../../model/tguTypes"; import type { RequestMapItem } from "../../model/requestTypes"; import { isOperationalPlatform } from "../tgu-planning/tguTimelineMapper"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseLocal } from "../../utils/localTime"; // sim.js/data.js — JS-модули прототипа (allowJs); типы any, нам достаточно. -import { buildSchedule, wrapLon } from "./sim.js"; +import { buildSchedule, wrapLon, pointInPoly } from "./sim.js"; import { COL } from "./data.js"; +/** Полный объём бортового накопителя, Мбайт (500 ГБ). Согласовано как модельная ёмкость. */ +const ONBOARD_CAPACITY_MB = 500 * 1024; + type Payload = "optic" | "radar"; export interface ConstellationSat { @@ -54,6 +58,20 @@ export interface Interval { start: number; end: number; label?: string; + /** Контур съёмки (для подсветки на карте), [lon,lat][]. Только у интервалов capture. */ + poly?: [number, number][]; + /** Координаты станции приёма (для линии сброса на карте). Только у интервалов downlink. */ + lon?: number; + lat?: number; +} + +export interface SchedTracks { + capture: Interval[]; + downlink: Interval[]; + /** Связь с КА — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». */ + link: Interval[]; + prohibit: Interval[]; + shadow: Interval[]; } export interface ConstellationEnv { @@ -64,12 +82,16 @@ export interface ConstellationEnv { prohibits: Record; /** Подспутниковая точка по реальной трассе; null — нет данных на это время. */ subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null; + /** Готовое расписание (съёмка/сброс/связь/тень) — из него statusAt берёт состояние. */ + sched?: Record; + /** Заполнение бортового накопителя на момент tMs, % от ёмкости. Из объёмов маршрутов плана. */ + memAt?: (sat: ConstellationSat, tMs: number) => number; } export interface ConstellationConfig { env: ConstellationEnv; domain: { start: number; end: number }; - sched: Record; + sched: Record; } // --- классификация полезной нагрузки по имени КА (из НСИ) ------------------- @@ -164,6 +186,115 @@ function compareSats(a: ConstellationSat, b: ConstellationSat): number { return a.name.localeCompare(b.name, "ru"); } +// --- съёмка/сброс из реального плана ----------------------------------------- +type StationGeo = { name: string; lon: number; lat: number }; + +interface PlanTracks { + capture: Interval[]; + downlink: Interval[]; + /** Точки изменения заполнения борта: +volumeMb на конце съёмки, −volumeMb на конце сброса. */ + memEvents: { t: number; delta: number }[]; +} + +// Историю тянем так, чтобы покрыть начало горизонта (now-6ч): дни назад + запас. +function planHistoryDays(domainStart: number): number { + const back = Math.ceil(Math.max(0, Date.now() - domainStart) / 86400e3); + return Math.max(1, back + 1); +} + +// Имя заявки, над которой идёт съёмка (по центру контура) — для подписи на таймлайне/карте. +function captureLabel(lon: number, lat: number, rois: ConstellationRoi[]): string { + for (const r of rois) if (pointInPoly(lon, lat, r.poly)) return r.name; + return "Съёмка"; +} + +// Режимы плана (SURVEY/DROP) → интервалы съёмки/сброса + дельты заполнения борта. +function buildPlanTracks( + modes: PlanMode[], + rois: ConstellationRoi[], + stationByNumber: Map +): PlanTracks { + const capture: Interval[] = []; + const downlink: Interval[] = []; + const memEvents: { t: number; delta: number }[] = []; + + for (const m of modes) { + const start = m.timeStart ? parseLocal(m.timeStart) : NaN; + if (!Number.isFinite(start)) continue; + const end = start + (m.duration ?? 0) * 1000; + const vol = m.volumeMb ?? 0; + if (m.type === "SURVEY") { + const rings = m.contourWkt ? parseWktPolygon(m.contourWkt) : []; + const poly = rings[0]?.map((pt) => [wrapLon(pt.lon), pt.lat] as [number, number]); + capture.push({ start, end, label: captureLabel(m.longitude, m.lat, rois), poly }); + memEvents.push({ t: end, delta: vol }); + } else if (m.type === "DROP") { + const st = m.station != null ? stationByNumber.get(Number(m.station)) : undefined; + downlink.push({ + start, + end, + label: st?.name ?? (m.station != null ? `ст. ${m.station}` : "Сброс"), + lon: st?.lon, + lat: st?.lat + }); + memEvents.push({ t: end, delta: -vol }); + } + } + + capture.sort((a, b) => a.start - b.start); + downlink.sort((a, b) => a.start - b.start); + memEvents.sort((a, b) => a.t - b.t); + return { capture, downlink, memEvents }; +} + +// Планы из pcp-tgu, пересекающие горизонт, → их режимы по КА (spacecraftId == NORAD). +async function loadPlanModesBySat( + satIds: Set, + domainStart: number, + domainEnd: number +): Promise> { + const byId = new Map(); + let plans: TguPlan[]; + try { + plans = await fetchPlans(planHistoryDays(domainStart)); + } catch { + return byId; // нет планов — съёмка/сброс просто не покажутся + } + const relevant = plans.filter((p) => { + if (!satIds.has(p.spacecraftId)) return false; + const s = parseLocal(p.startTime); + const e = parseLocal(p.endTime); + return Number.isFinite(s) && Number.isFinite(e) && e >= domainStart && s <= domainEnd; + }); + const loaded = await Promise.all( + relevant.map((p) => + fetchPlanModes(p.planId) + .then((m) => ({ sid: p.spacecraftId, m })) + .catch(() => ({ sid: p.spacecraftId, m: [] as PlanMode[] })) + ) + ); + for (const { sid, m } of loaded) { + const arr = byId.get(sid) ?? []; + arr.push(...m); + byId.set(sid, arr); + } + return byId; +} + +// Заполнение борта на момент t: накопленный объём маршрутов (старт 0), % от ёмкости. +function memorySampler(memEvents: { t: number; delta: number }[]): (t: number) => number { + if (memEvents.length === 0) return () => 0; + return (t: number) => { + let mb = 0; + for (const e of memEvents) { + if (e.t > t) break; + mb += e.delta; + } + const pct = (Math.max(0, Math.min(ONBOARD_CAPACITY_MB, mb)) / ONBOARD_CAPACITY_MB) * 100; + return pct; + }; +} + /** * Грузит реальный состав и геометрию группировки и собирает конфиг дашборда. * Горизонт по умолчанию now-6ч … now+18ч (как в прототипе). КА без трассы @@ -187,8 +318,11 @@ export async function loadConstellationEnv(): Promise { lon: s.position.long, lat: s.position.lat })); - const stationNameByNumber = new Map( - (stationDtos as StationDto[]).map((s) => [s.number, s.name]) + const stationByNumber = new Map( + (stationDtos as StationDto[]).map((s) => [ + s.number, + { name: s.name, lon: s.position.long, lat: s.position.lat } + ]) ); const rois: ConstellationRoi[] = requestItems @@ -237,6 +371,7 @@ export async function loadConstellationEnv(): Promise { sats.sort(compareSats); + // Запреты работы (ВНЗ/питание) — источника в проекте нет: оставляем пустыми (заготовка). const prohibits: Record = Object.fromEntries(sats.map((s) => [s.id, []])); const env: ConstellationEnv = { @@ -248,23 +383,42 @@ export async function loadConstellationEnv(): Promise { subSat: (sat, t) => (samplers[sat.id] ? samplers[sat.id](t) : null) }; - // Съёмка (по пролёту над ROI), тень и геометрический сброс — из реальных позиций. - // Шаг 0.5 мин, чтобы не проскочить мелкие полигоны заявок. + // Съёмку и сброс берём из реального плана; тень считаем геометрически по трассе. + const satIds = new Set(sats.map((s) => s.id)); + const planModesBySat = await loadPlanModesBySat(satIds, domain.start, domain.end); + + // buildSchedule даёт нам тень (shadow) по реальным позициям; съёмку/сброс из него игнорируем. const sched = buildSchedule(sats, env, domain.start, domain.end, 0.5) as ConstellationConfig["sched"]; - // Сброс заменяем авторитетными зонами радиовидимости, где они есть. + const memSamplers: Record number> = {}; for (const s of sats) { + const tracks = sched[s.id]; + // Съёмка и сброс — из режимов плана (SURVEY/DROP). + const plan = buildPlanTracks(planModesBySat.get(s.id) ?? [], rois, stationByNumber); + tracks.capture = plan.capture; + tracks.downlink = plan.downlink; + memSamplers[s.id] = memorySampler(plan.memEvents); + + // «Связь с КА» — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». const rva = rvaBySat[s.id]; - if (!rva || rva.length === 0) continue; - sched[s.id].downlink = rva - .map((z) => ({ - start: parseLocal(z.onStart.time), - end: parseLocal(z.onStop.time), - label: stationNameByNumber.get(z.stationId) ?? `ст. ${z.stationId}` - })) + tracks.link = (rva ?? []) + .map((z) => { + const st = stationByNumber.get(z.stationId); + return { + start: parseLocal(z.onStart.time), + end: parseLocal(z.onStop.time), + label: st?.name ?? `ст. ${z.stationId}`, + lon: st?.lon, + lat: st?.lat + }; + }) .filter((iv) => Number.isFinite(iv.start) && Number.isFinite(iv.end) && iv.end > iv.start) .sort((a, b) => a.start - b.start); } + // Расписание и заполнение борта — на env, чтобы statusAt/панель читали авторитетные данные. + env.sched = sched; + env.memAt = (sat, t) => (memSamplers[sat.id] ? memSamplers[sat.id](t) : 0); + return { env, domain, sched }; } diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js index f35a7d5..c247507 100644 --- a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js +++ b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js @@ -109,12 +109,31 @@ const D2R = Math.PI / 180, R2D = 180 / Math.PI; return env.subSat ? env.subSat(sat, tMs) : subSat(sat, tMs, env.epochMs); } + // interval containing t in a sorted/unsorted list, else null + function intervalAt(list, tMs) { + if (!list) return null; + for (const iv of list) if (tMs >= iv.start && tMs < iv.end) return iv; + return null; + } + // live status of a sat at time t (priority order) // returns {state, roi, station, eclipse, prohibit} + // С реальным расписанием (env.sched) состояние берём из готовых интервалов плана + // (съёмка/сброс/запрет); иначе — встроенная геометрическая симуляция прототипа. function statusAt(sat, tMs, env) { const p = subSatOf(sat, tMs, env); - const prohibit = inProhibit(env.prohibits[sat.id], tMs); const eclipse = inShadow(p.lon, p.lat, tMs); + if (env.sched) { + const tr = env.sched[sat.id] || {}; + const prohibit = intervalAt(tr.prohibit, tMs); + if (prohibit) return { state: "prohibit", p, prohibit: { reason: prohibit.label }, eclipse }; + const cap = intervalAt(tr.capture, tMs); + if (cap) return { state: "capture", p, roi: { name: cap.label, poly: cap.poly }, eclipse }; + const dl = intervalAt(tr.downlink, tMs); + if (dl) return { state: "downlink", p, station: { name: dl.label, lon: dl.lon, lat: dl.lat }, eclipse }; + return { state: "operational", p, eclipse }; + } + const prohibit = inProhibit(env.prohibits[sat.id], tMs); if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; const roi = overROI(sat, p, env.rois); if (roi) return { state: "capture", p, roi, eclipse }; @@ -160,5 +179,5 @@ const D2R = Math.PI / 180, R2D = 180 / Math.PI; export { D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, pointInPoly, overROI, nearStation, buildProhibits, inProhibit, - statusAt, buildSchedule, STATION_RANGE, + statusAt, intervalAt, buildSchedule, STATION_RANGE, }; From 8ac4afa195dc68ae45ee15b3e52aad6d4732210f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 8 Jun 2026 14:45:50 +0300 Subject: [PATCH 79/79] =?UTF-8?q?config:=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=B8=D0=BD=D1=84=D1=80=D0=B0=D1=81=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D1=8B=20=D0=BD=D0=B0=20localhost=20(ap?= =?UTF-8?q?plication-dev/docker-local,=20create=5Fpcp=5Fdatabases)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-repo/application-dev.yaml | 8 ++++---- config-repo/application-docker-local.yaml | 2 +- create_pcp_databases.sh | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config-repo/application-dev.yaml b/config-repo/application-dev.yaml index 288ae0c..947eea6 100644 --- a/config-repo/application-dev.yaml +++ b/config-repo/application-dev.yaml @@ -1,16 +1,16 @@ # TODO before merge request: вернуть старые адреса окружения. -# pcp.infra.postgres.host: 192.168.60.146 +# pcp.infra.postgres.host: localhost pcp: infra: postgres: - host: 192.168.60.146 + host: localhost port: 35400 kafka: - host: 192.168.60.146 + host: localhost port: 19092 network: - host: 192.168.60.146 + host: localhost ports: config: 8888 diff --git a/config-repo/application-docker-local.yaml b/config-repo/application-docker-local.yaml index eb1019f..7b479db 100644 --- a/config-repo/application-docker-local.yaml +++ b/config-repo/application-docker-local.yaml @@ -56,7 +56,7 @@ spring: camunda: client: enabled: ${CAMUNDA_CLIENT_ENABLED:false} - grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.146:26500} + grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500} auth: method: none prefer-rest-over-grpc: false diff --git a/create_pcp_databases.sh b/create_pcp_databases.sh index cf62fea..d257b3c 100755 --- a/create_pcp_databases.sh +++ b/create_pcp_databases.sh @@ -3,7 +3,7 @@ set -euo pipefail # PostgreSQL connection settings. Override via env if needed: # PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... ./create_pcp_databases.sh -PGHOST="${PGHOST:-192.168.60.146}" +PGHOST="${PGHOST:-localhost}" PGPORT="${PGPORT:-5432}" PGUSER="${PGUSER:-postgres}" PGPASSWORD="${PGPASSWORD:-password}"