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")) + } +}