pcp: создание плана новой цепочки из вкладки «Планы» (база/без истории, окно из двух ЗРВ)
Оператор берёт за основу прошлый план (предыстория) или начинает цепочку с нуля,
набирает окно из двух реальных ЗРВ и создаёт план-кандидат; сразу показывается прогноз
будущей цепочки от него. Рабочим (якорем) план становится после ручной отправки (promote)
и приёмки — отдельного механизма якорения не вводим.
pcp-tgu-service:
- сырые ЗРВ: PlanCalculationService.rawWindows + PlanProjectionService.rawZrvWindows,
GET /api/plans/{spacecraftId}/zrv (исходные окна снапшота, не cooked-проекция);
- CreateChainPlanUseCase + POST /api/plans/new-chain: опциональная база (клон включений
в окно) либо пустая миссия без истории; прогноз цепочки через projectAfterWindow;
- ZrvWindowMatcher (старт+КПП одной зоны, конец — другой); assertDraftSlotStillValid
переведён на него — единая проверка для replace/fork-forward и окна из двух ЗРВ;
- тесты: CreateChainPlanUseCaseTest (8), ZrvWindowMatcherTest (7), правки promote-тестов.
pcp-mission-planing-service:
- POST /api/missions/blank — пустая миссия для сценария «без истории» (+ 2 теста).
pcp-tgu-ops-ui:
- API fetchZrvWindows/createChainPlan; диалог «Создать план» во вкладке «Планы»
(выбор базы, стартовой и конечной ЗРВ) с показом спрогнозированной цепочки и ручной
отправкой; подсветка ЗРВ выбранного КА на таймлайне;
- «Целевая ЗРВ» убрана из редактора (он остаётся под replace-правку); удалён ставший
мёртвым editorForkForward; createChainWindows + тесты.
This commit is contained in:
+10
@@ -19,3 +19,13 @@ data class MissionCloneRequest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class MissionCloneResponse(val newMissionId: UUID)
|
data class MissionCloneResponse(val newMissionId: UUID)
|
||||||
|
|
||||||
|
/** Запрос создания пустой миссии (план новой цепочки «без истории»). */
|
||||||
|
data class MissionBlankRequest(
|
||||||
|
/** КА (= noradId). */
|
||||||
|
val satelliteId: Long,
|
||||||
|
/** Начало окна миссии (UTC naive). */
|
||||||
|
val missionStart: LocalDateTime,
|
||||||
|
/** Конец окна миссии (UTC naive). */
|
||||||
|
val missionStop: LocalDateTime
|
||||||
|
)
|
||||||
|
|||||||
+9
@@ -300,4 +300,13 @@ class MissionController(
|
|||||||
@PathVariable("mission_id") id: UUID,
|
@PathVariable("mission_id") id: UUID,
|
||||||
@RequestBody body: MissionCloneRequest
|
@RequestBody body: MissionCloneRequest
|
||||||
): MissionCloneResponse = missionPlaningService.cloneMission(id, body)
|
): MissionCloneResponse = missionPlaningService.cloneMission(id, body)
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Создать пустую миссию",
|
||||||
|
description = "Создаёт миссию без режимов на заданное окно — для плана новой цепочки «без истории». " +
|
||||||
|
"Возвращает newMissionId, который tgu-service использует как planId черновика."
|
||||||
|
)
|
||||||
|
@PostMapping("/blank")
|
||||||
|
fun createBlankMission(@RequestBody body: MissionBlankRequest): MissionCloneResponse =
|
||||||
|
missionPlaningService.createBlankMission(body)
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -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.SurveyRouteBuildRequestDTO
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO
|
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.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.MissionCloneRequest
|
||||||
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse
|
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.controller.MissionValidationResponse
|
||||||
@@ -245,6 +246,41 @@ class MissionPlaningService(
|
|||||||
return MissionCloneResponse(newMissionId = newMission.missionId)
|
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<ModeResponseDTO> {
|
fun modesByMission(id: UUID): List<ModeResponseDTO> {
|
||||||
if (!missionRepository.existsById(id)) {
|
if (!missionRepository.existsById(id)) {
|
||||||
throw notFound(id)
|
throw notFound(id)
|
||||||
|
|||||||
+39
@@ -218,6 +218,45 @@ class MissionEditModesTest {
|
|||||||
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- createBlankMission (план новой цепочки «без истории») ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createBlankMission persists a mission and its plan with the given window`() {
|
||||||
|
val saved = arrayOfNulls<space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity>(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<ResponseStatusException> {
|
||||||
|
service.createBlankMission(
|
||||||
|
space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest(
|
||||||
|
satelliteId = 25544, missionStart = t, missionStop = t
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
private fun survey(): SurveyModeEntity {
|
private fun survey(): SurveyModeEntity {
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ export type TguPlanResponse = {
|
|||||||
status: string;
|
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 планы плюс
|
* Цепочка планов одного КА из pcp-tgu-service (GET /api/plans/{spacecraftId}): persisted планы плюс
|
||||||
* будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет
|
* будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет
|
||||||
@@ -63,6 +90,38 @@ export async function fetchSpacecraftPlans(spacecraftId: string): Promise<TguPla
|
|||||||
return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : [];
|
return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Сырые» ЗРВ КА (границы реальных зон) из pcp-tgu-service (GET /api/plans/{spacecraftId}/zrv).
|
||||||
|
* Из них оператор набирает окно нового плана и видит доступные зоны на таймлайне.
|
||||||
|
*/
|
||||||
|
export async function fetchZrvWindows(spacecraftId: string): Promise<ZrvWindowResponse[]> {
|
||||||
|
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<CreateChainPlanResponse> {
|
||||||
|
return postJson<CreateChainPlanResponse>(
|
||||||
|
`${BASE}/api/plans/new-chain`,
|
||||||
|
request,
|
||||||
|
"Не удалось создать план новой цепочки."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт черновик плана (DRAFT) на основе базового плана
|
* Создаёт черновик плана (DRAFT) на основе базового плана
|
||||||
* (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает
|
* (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
||||||
import { formatDuration } from "./editorPresentation";
|
import { formatDuration } from "./editorPresentation";
|
||||||
import type { ZrvWindow } from "./editorForkForward";
|
|
||||||
import { formatUtcDateTime } from "../../utils/utcTime";
|
|
||||||
|
|
||||||
type EditorToolbarProps = {
|
type EditorToolbarProps = {
|
||||||
selectedSpacecraftId?: string;
|
selectedSpacecraftId?: string;
|
||||||
@@ -15,10 +13,6 @@ type EditorToolbarProps = {
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
canceling: boolean;
|
canceling: boolean;
|
||||||
futureWindows: ZrvWindow[];
|
|
||||||
targetWindowId?: string;
|
|
||||||
targetLocked: boolean;
|
|
||||||
onSelectTargetWindow: (planId: string | undefined) => void;
|
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||||
@@ -43,10 +37,6 @@ export function EditorToolbar({
|
|||||||
dirty,
|
dirty,
|
||||||
applying,
|
applying,
|
||||||
canceling,
|
canceling,
|
||||||
futureWindows,
|
|
||||||
targetWindowId,
|
|
||||||
targetLocked,
|
|
||||||
onSelectTargetWindow,
|
|
||||||
windowLabel,
|
windowLabel,
|
||||||
rightMode,
|
rightMode,
|
||||||
onSetRightMode,
|
onSetRightMode,
|
||||||
@@ -93,25 +83,6 @@ export function EditorToolbar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(futureWindows.length > 0 || targetWindowId) && (
|
|
||||||
<label className="tgu-editor-target-zrv" title="Целевая ЗРВ для перевыдачи невозвратного плана">
|
|
||||||
<span className="tgu-editor-target-zrv__label">Целевая ЗРВ</span>
|
|
||||||
<select
|
|
||||||
className="tgu-select"
|
|
||||||
value={targetWindowId ?? ""}
|
|
||||||
disabled={targetLocked}
|
|
||||||
onChange={(event) => onSelectTargetWindow(event.target.value || undefined)}
|
|
||||||
>
|
|
||||||
<option value="">Текущий слот (правка)</option>
|
|
||||||
{futureWindows.map((window) => (
|
|
||||||
<option key={window.planId} value={window.planId}>
|
|
||||||
{formatUtcDateTime(window.startMs)} · {window.kppId}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="tgu-editor-toolbar__spacer" />
|
<div className="tgu-editor-toolbar__spacer" />
|
||||||
|
|
||||||
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import {
|
|||||||
validateMission,
|
validateMission,
|
||||||
type SurveyRoute
|
type SurveyRoute
|
||||||
} from "../../api/missionPlanningApi";
|
} from "../../api/missionPlanningApi";
|
||||||
import { abandonDraft, createDraft, fetchSpacecraftPlans, promoteDraft } from "../../api/tguPlanApi";
|
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
|
||||||
import { futureZrvWindows, toCreateDraftRequest, type ZrvWindow } from "./editorForkForward";
|
|
||||||
import { parseUtc } from "../../utils/utcTime";
|
import { parseUtc } from "../../utils/utcTime";
|
||||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||||
@@ -122,10 +121,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
||||||
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
||||||
const [draftPlanId, setDraftPlanId] = useState<string>();
|
const [draftPlanId, setDraftPlanId] = useState<string>();
|
||||||
// Будущие ЗРВ КА (проекция PLANNED) и выбранная цель fork-forward. Пусто/undefined — правка
|
|
||||||
// текущего слота (replace); выбор окна заставляет ensureDraft создать черновик на следующей ЗРВ.
|
|
||||||
const [futureWindows, setFutureWindows] = useState<ZrvWindow[]>([]);
|
|
||||||
const [targetWindowId, setTargetWindowId] = useState<string>();
|
|
||||||
const [passFrom, setPassFrom] = useState(1);
|
const [passFrom, setPassFrom] = useState(1);
|
||||||
const [passTo, setPassTo] = useState(999);
|
const [passTo, setPassTo] = useState(999);
|
||||||
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
||||||
@@ -228,10 +223,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
||||||
// локальные несохранённые твики без DRAFT применять нечего.
|
// локальные несохранённые твики без DRAFT применять нечего.
|
||||||
const dirty = Boolean(draftPlanId);
|
const dirty = Boolean(draftPlanId);
|
||||||
const targetWindow = useMemo(
|
|
||||||
() => futureWindows.find((window) => window.planId === targetWindowId),
|
|
||||||
[futureWindows, targetWindowId]
|
|
||||||
);
|
|
||||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||||
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
||||||
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
||||||
@@ -256,33 +247,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
setApplyError(undefined);
|
setApplyError(undefined);
|
||||||
setPlanModes([]);
|
setPlanModes([]);
|
||||||
setTargetWindowId(undefined);
|
|
||||||
setPassFrom(1);
|
setPassFrom(1);
|
||||||
setPassTo(999);
|
setPassTo(999);
|
||||||
setFlightLineData([]);
|
setFlightLineData([]);
|
||||||
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!noradId) return;
|
if (!noradId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -484,31 +453,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
changeDraft((current) => updateWork(current, workId, patch));
|
changeDraft((current) => updateWork(current, workId, patch));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT и возвращает его id.
|
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
|
||||||
// Без выбранной целевой ЗРВ — replace (тот же слот); с выбранной — fork-forward на следующую ЗРВ
|
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
|
||||||
// (для невозвратного плана). Дальше все правки/валидация/promote адресуются именно сюда.
|
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
|
||||||
const ensureDraft = useCallback(async (): Promise<string> => {
|
const ensureDraft = useCallback(async (): Promise<string> => {
|
||||||
if (draftPlanId) return draftPlanId;
|
if (draftPlanId) return draftPlanId;
|
||||||
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
||||||
const created = await createDraft(selectedPlan.planId, toCreateDraftRequest(targetWindow));
|
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
||||||
setDraftPlanId(created.draftPlanId);
|
setDraftPlanId(created.draftPlanId);
|
||||||
return created.draftPlanId;
|
return created.draftPlanId;
|
||||||
}, [draftPlanId, selectedPlan, targetWindow]);
|
}, [draftPlanId, selectedPlan]);
|
||||||
|
|
||||||
// Выбор целевой ЗРВ для 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.
|
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
|
||||||
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
|
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
|
||||||
@@ -828,10 +782,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
applying={applying}
|
applying={applying}
|
||||||
canceling={canceling}
|
canceling={canceling}
|
||||||
futureWindows={futureWindows}
|
|
||||||
targetWindowId={targetWindowId}
|
|
||||||
targetLocked={dirty}
|
|
||||||
onSelectTargetWindow={selectTargetWindow}
|
|
||||||
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
||||||
rightMode={rightMode}
|
rightMode={rightMode}
|
||||||
onSetRightMode={(mode) => {
|
onSetRightMode={(mode) => {
|
||||||
|
|||||||
@@ -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>): 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"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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<ZrvOption[]>([]);
|
||||||
|
const [zrvLoading, setZrvLoading] = useState(true);
|
||||||
|
const [zrvError, setZrvError] = useState<string>();
|
||||||
|
|
||||||
|
const [basePlanId, setBasePlanId] = useState<string>(""); // "" — без истории
|
||||||
|
const [startId, setStartId] = useState<string>("");
|
||||||
|
const [endId, setEndId] = useState<string>("");
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [result, setResult] = useState<CreateChainPlanResponse>();
|
||||||
|
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 (
|
||||||
|
<div className="tgu-chain-dialog__backdrop" role="dialog" aria-modal="true" aria-label="Создать план">
|
||||||
|
<div className="tgu-chain-dialog">
|
||||||
|
<div className="tgu-chain-dialog__header">
|
||||||
|
<h2>Создать план</h2>
|
||||||
|
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть">
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!result ? (
|
||||||
|
<>
|
||||||
|
<label className="tgu-chain-dialog__field">
|
||||||
|
<span>Базовое состояние (предыстория)</span>
|
||||||
|
<select className="tgu-select" value={basePlanId} onChange={(e) => setBasePlanId(e.target.value)}>
|
||||||
|
<option value="">Без истории (новая цепочка с нуля)</option>
|
||||||
|
{baseOptions.map((plan) => (
|
||||||
|
<option key={plan.planId} value={plan.planId}>
|
||||||
|
{formatUtcDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{zrvError && <div className="tgu-alert tgu-alert--error">{zrvError}</div>}
|
||||||
|
{zrvLoading ? (
|
||||||
|
<div className="tgu-chain-dialog__hint">Загрузка ЗРВ…</div>
|
||||||
|
) : zrvOptions.length === 0 ? (
|
||||||
|
<div className="tgu-chain-dialog__hint">Нет будущих ЗРВ для этого КА.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="tgu-chain-dialog__field">
|
||||||
|
<span>Стартовая ЗРВ (начало окна)</span>
|
||||||
|
<select className="tgu-select" value={startId} onChange={(e) => setStartId(e.target.value)}>
|
||||||
|
<option value="">— выберите —</option>
|
||||||
|
{zrvOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{formatUtcDateTime(option.startMs)} · {option.kppId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="tgu-chain-dialog__field">
|
||||||
|
<span>Конечная ЗРВ (конец окна)</span>
|
||||||
|
<select className="tgu-select" value={endId} onChange={(e) => setEndId(e.target.value)}>
|
||||||
|
<option value="">— выберите —</option>
|
||||||
|
{zrvOptions
|
||||||
|
.filter((option) => !start || option.endMs > start.startMs)
|
||||||
|
.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{formatUtcDateTime(option.endMs)} · {option.kppId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="tgu-chain-dialog__actions">
|
||||||
|
<button className="tgu-button" type="button" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button tgu-button--primary" type="button" onClick={create} disabled={!canCreate}>
|
||||||
|
{creating ? "Создание…" : "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="tgu-alert tgu-alert--success">
|
||||||
|
План-кандидат создан (черновик {result.draftPlanId}). Прогноз будущей цепочки от него:
|
||||||
|
</div>
|
||||||
|
<ul className="tgu-chain-dialog__chain">
|
||||||
|
{result.projectedChain.length === 0 ? (
|
||||||
|
<li className="tgu-chain-dialog__hint">Цепочка после этого плана пуста (нет дальнейших ЗРВ).</li>
|
||||||
|
) : (
|
||||||
|
result.projectedChain.map((plan) => (
|
||||||
|
<li key={plan.planId}>
|
||||||
|
{formatUtcDateTime(parseUtc(plan.startTime))} – {formatUtcDateTime(parseUtc(plan.endTime))} ·{" "}
|
||||||
|
{plan.kppId}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="tgu-chain-dialog__actions">
|
||||||
|
<button className="tgu-button" type="button" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button tgu-button--primary" type="button" onClick={send} disabled={sending}>
|
||||||
|
{sending ? "Отправка…" : "Отправить вручную"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="tgu-chain-dialog__hint">
|
||||||
|
После отправки план уходит в выдачу; рабочим (якорем цепочки) он станет после приёмки.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelin
|
|||||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||||
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||||||
|
import { fetchZrvWindows, type ZrvWindowResponse } from "../../api/tguPlanApi";
|
||||||
|
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
|
||||||
import { TguPlanDetails } from "./TguPlanDetails";
|
import { TguPlanDetails } from "./TguPlanDetails";
|
||||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||||
import { TguSidebar } from "./TguSidebar";
|
import { TguSidebar } from "./TguSidebar";
|
||||||
@@ -36,6 +38,9 @@ export function TguPlanningPage() {
|
|||||||
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||||
|
const [createChainOpen, setCreateChainOpen] = useState(false);
|
||||||
|
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
|
||||||
|
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
|
||||||
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
||||||
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
||||||
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
||||||
@@ -102,6 +107,33 @@ export function TguPlanningPage() {
|
|||||||
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
|
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
|
||||||
}, [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
|
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
||||||
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
||||||
const tabStyle = (tab: TguActiveTab) =>
|
const tabStyle = (tab: TguActiveTab) =>
|
||||||
@@ -194,10 +226,22 @@ export function TguPlanningPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<section className="tgu-center">
|
<section className="tgu-center">
|
||||||
|
<div className="tgu-center__actions">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--primary"
|
||||||
|
type="button"
|
||||||
|
disabled={!selectedSpacecraftId}
|
||||||
|
title={selectedSpacecraftId ? "Создать план новой цепочки" : "Сначала выберите КА"}
|
||||||
|
onClick={() => setCreateChainOpen(true)}
|
||||||
|
>
|
||||||
|
Создать план
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<TguTimeline
|
<TguTimeline
|
||||||
rows={rows}
|
rows={rows}
|
||||||
range={appliedRange}
|
range={appliedRange}
|
||||||
invalidRange={invalidRange}
|
invalidRange={invalidRange}
|
||||||
|
zrvBands={zrvBands}
|
||||||
selectedPlanId={selectedPlanId}
|
selectedPlanId={selectedPlanId}
|
||||||
onSelectPlan={(planId) => {
|
onSelectPlan={(planId) => {
|
||||||
setSelectedPlanId(planId);
|
setSelectedPlanId(planId);
|
||||||
@@ -217,6 +261,14 @@ export function TguPlanningPage() {
|
|||||||
onClose={() => setSelectedPlanId(undefined)}
|
onClose={() => setSelectedPlanId(undefined)}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
{createChainOpen && selectedSpacecraftId && (
|
||||||
|
<CreateChainPlanDialog
|
||||||
|
spacecraftId={selectedSpacecraftId}
|
||||||
|
plans={plans}
|
||||||
|
onClose={() => setCreateChainOpen(false)}
|
||||||
|
onSent={() => void loadData(appliedRange)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{visitedTabs.has("map") && (
|
{visitedTabs.has("map") && (
|
||||||
|
|||||||
@@ -15,15 +15,22 @@ import { platformLabel } from "./tguTimelineMapper";
|
|||||||
import { statusStyle } from "./tguStatus";
|
import { statusStyle } from "./tguStatus";
|
||||||
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
||||||
|
|
||||||
|
type ZrvBands = {
|
||||||
|
spacecraftId: string;
|
||||||
|
windows: { startMs: number; endMs: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
type TguTimelineProps = {
|
type TguTimelineProps = {
|
||||||
rows: TimelineRow[];
|
rows: TimelineRow[];
|
||||||
range: TimelineRange;
|
range: TimelineRange;
|
||||||
selectedPlanId?: string;
|
selectedPlanId?: string;
|
||||||
invalidRange?: boolean;
|
invalidRange?: boolean;
|
||||||
|
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
|
||||||
|
zrvBands?: ZrvBands;
|
||||||
onSelectPlan: (planId: string) => void;
|
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<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(1200);
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
||||||
@@ -124,6 +131,26 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
|
|||||||
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
||||||
NORAD {row.platform?.noradId || row.spacecraftId}
|
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||||
</text>
|
</text>
|
||||||
|
{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 (
|
||||||
|
<rect
|
||||||
|
key={`zrv-${zrvIndex}`}
|
||||||
|
className="tgu-timeline__zrv"
|
||||||
|
x={bx}
|
||||||
|
y={y + 6}
|
||||||
|
width={bw}
|
||||||
|
height={TIMELINE_ROW_HEIGHT - 12}
|
||||||
|
>
|
||||||
|
<title>ЗРВ {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||||
|
</rect>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1358,3 +1358,87 @@
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+26
-1
@@ -10,13 +10,18 @@ import org.springframework.web.bind.annotation.RequestBody
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
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.CreateDraftRequest
|
||||||
import space.nstart.pcp_tgu_service.dto.CreateDraftResponse
|
import space.nstart.pcp_tgu_service.dto.CreateDraftResponse
|
||||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
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.CreateDraftUseCase
|
||||||
import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase
|
import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase
|
||||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
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.PlanQueryService
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -26,7 +31,9 @@ class PlanController(
|
|||||||
private val planQueryService: PlanQueryService,
|
private val planQueryService: PlanQueryService,
|
||||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
private val issueDuePlanWorker: IssueDuePlanWorker,
|
||||||
private val createDraftUseCase: CreateDraftUseCase,
|
private val createDraftUseCase: CreateDraftUseCase,
|
||||||
private val deleteDraftUseCase: DeleteDraftUseCase
|
private val deleteDraftUseCase: DeleteDraftUseCase,
|
||||||
|
private val createChainPlanUseCase: CreateChainPlanUseCase,
|
||||||
|
private val planProjectionService: PlanProjectionService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
||||||
@@ -42,6 +49,24 @@ class PlanController(
|
|||||||
): List<PlanResponse> =
|
): List<PlanResponse> =
|
||||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Сырые» ЗРВ КА из последнего снапшота (границы реальных зон) — из них оператор набирает окно
|
||||||
|
* нового плана во вкладке «Планы». Пусто, если снапшота нет.
|
||||||
|
*/
|
||||||
|
@GetMapping("/{spacecraftId}/zrv")
|
||||||
|
fun getZrvWindows(@PathVariable spacecraftId: String): List<ZrvWindowResponse> =
|
||||||
|
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.
|
* Создаёт черновик плана (DRAFT) на основе basePlanId.
|
||||||
* Режим replace — черновик в том же слоте (база ещё правима).
|
* Режим replace — черновик в том же слоте (база ещё правима).
|
||||||
|
|||||||
+56
@@ -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<PlanResponse>
|
||||||
|
)
|
||||||
+26
@@ -17,6 +17,12 @@ data class MissionCloneRequest(
|
|||||||
|
|
||||||
data class MissionCloneResponse(val newMissionId: UUID)
|
data class MissionCloneResponse(val newMissionId: UUID)
|
||||||
|
|
||||||
|
data class MissionBlankRequest(
|
||||||
|
val satelliteId: Long,
|
||||||
|
val missionStart: LocalDateTime,
|
||||||
|
val missionStop: LocalDateTime
|
||||||
|
)
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
class MissionPlaningClient(properties: MissionPlaningProperties) {
|
class MissionPlaningClient(properties: MissionPlaningProperties) {
|
||||||
|
|
||||||
@@ -47,6 +53,26 @@ class MissionPlaningClient(properties: MissionPlaningProperties) {
|
|||||||
"Пустой ответ mission-planing на клон миссии $missionId"
|
"Пустой ответ 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. */
|
/** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */
|
||||||
fun deleteMission(missionId: UUID) {
|
fun deleteMission(missionId: UUID) {
|
||||||
webClient.delete()
|
webClient.delete()
|
||||||
|
|||||||
+168
@@ -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)
|
||||||
|
}
|
||||||
+7
-21
@@ -230,36 +230,22 @@ class IssueDuePlanWorker(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно
|
* Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно
|
||||||
* по-прежнему соответствовать реальной будущей ЗРВ. Для fork-forward слот есть в проекции;
|
* по-прежнему набираться из реальных ЗРВ снапшота. Сверка против «сырых» окон ЗРВ единым
|
||||||
* для replace слот = окно заменяемого базового плана, которое проекция (заякоренная ПОСЛЕ
|
* правилом [ZrvWindowMatcher] (начало+КПП одной зоны, конец — другой) — оно покрывает и окно
|
||||||
* плана в полёте) не возвращает, поэтому окно базы учитывается явно. Сверка по КПП и времени
|
* из одной ЗРВ (replace/fork-forward), и окно из двух ЗРВ (план новой цепочки). Не набирается →
|
||||||
* с точностью до секунды (как при создании черновика). Не совпало → снапшот сместился → 422.
|
* снапшот сместился → 422.
|
||||||
*/
|
*/
|
||||||
private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) {
|
private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) {
|
||||||
val candidates = planProjectionService.project(plan.spacecraftId)
|
val rawWindows = planProjectionService.rawZrvWindows(plan.spacecraftId)
|
||||||
.map { Triple(it.kppId, it.startTime, it.endTime) }
|
if (!ZrvWindowMatcher.matches(rawWindows, plan.startTime, plan.endTime, plan.kppId)) {
|
||||||
.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(
|
throw ResponseStatusException(
|
||||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
"Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " +
|
"Окно черновика ${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],
|
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
|
||||||
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
||||||
|
|||||||
+11
@@ -24,6 +24,17 @@ class PlanCalculationService(
|
|||||||
private val planningProperties: PlanningProperties
|
private val planningProperties: PlanningProperties
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает «сырые» окна ЗРВ КА (наблюдательные окна точек закладки как есть), без построения
|
||||||
|
* цепочки и без подбора целевой длительности: одно окно = один [CalculatedPlan] `[startTime, endTime]`
|
||||||
|
* с `kppId = pointId`. Из этих окон оператор вручную набирает план новой цепочки (старт и конец —
|
||||||
|
* границы реальных ЗРВ), и против них же валидируется выбранное окно.
|
||||||
|
*/
|
||||||
|
fun rawWindows(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> =
|
||||||
|
buildCandidates(spacecraftPoints).map { candidate ->
|
||||||
|
candidate.toCalculatedPlan(spacecraftPoints.spacecraftId, candidate.endTime)
|
||||||
|
}
|
||||||
|
|
||||||
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
|
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
|
||||||
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
|
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
|
||||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||||
|
|||||||
+27
@@ -42,6 +42,33 @@ class PlanProjectionService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Сырые» окна ЗРВ КА из последнего снапшота — границы реальных зон, из которых оператор
|
||||||
|
* вручную набирает окно нового плана. Пусто, если снапшота нет. В отличие от [project] это
|
||||||
|
* не цепочка, а исходные окна (без подбора длительности и нахлёста).
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun rawZrvWindows(spacecraftId: String): List<CalculatedPlan> {
|
||||||
|
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<CalculatedPlan> {
|
||||||
|
val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null)
|
||||||
|
?: return emptyList()
|
||||||
|
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
||||||
|
return planCalculationService.calculatePlansAfter(points, afterPlan)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка.
|
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка.
|
||||||
*
|
*
|
||||||
|
|||||||
+44
@@ -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<CalculatedPlan>,
|
||||||
|
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())
|
||||||
|
}
|
||||||
+218
@@ -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<MissionCloneRequest>())
|
||||||
|
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||||
|
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<MissionBlankRequest>())
|
||||||
|
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||||
|
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<ResponseStatusException> {
|
||||||
|
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId))
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||||
|
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||||
|
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||||
|
}
|
||||||
|
|
||||||
|
@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<ResponseStatusException> {
|
||||||
|
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<ResponseStatusException> {
|
||||||
|
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<ResponseStatusException> {
|
||||||
|
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<ResponseStatusException> {
|
||||||
|
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<ResponseStatusException> {
|
||||||
|
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<RuntimeException> {
|
||||||
|
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<PlannedPlanEntity?>
|
||||||
|
) {
|
||||||
|
val lastSaved: PlannedPlanEntity? get() = savedHolder[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fixture(
|
||||||
|
base: PlannedPlanEntity?,
|
||||||
|
cloneResult: UUID = UUID.randomUUID(),
|
||||||
|
blankResult: UUID = UUID.randomUUID(),
|
||||||
|
rawWindows: List<CalculatedPlan>? = null,
|
||||||
|
projectedTail: List<CalculatedPlan> = emptyList(),
|
||||||
|
failSave: Boolean = false,
|
||||||
|
spacecraftIdOverride: String = spacecraftId,
|
||||||
|
window: Pair<LocalDateTime, LocalDateTime> = time(5) to time(6)
|
||||||
|
): Fixture {
|
||||||
|
val repo = mock(PlannedPlanRepository::class.java)
|
||||||
|
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
||||||
|
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
||||||
|
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).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<MissionCloneRequest>()))
|
||||||
|
.thenReturn(MissionCloneResponse(newMissionId = cloneResult))
|
||||||
|
`when`(client.createBlankMission(anyNonNull<MissionBlankRequest>()))
|
||||||
|
.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 <T> anyNonNull(): T { ArgumentMatchers.any<T>(); return null as T }
|
||||||
|
private fun anyUuid(): UUID { ArgumentMatchers.any<UUID>(); return UUID.randomUUID() }
|
||||||
|
}
|
||||||
+16
-5
@@ -297,7 +297,7 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||||
planStore[draft.planId] = draft
|
planStore[draft.planId] = draft
|
||||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
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)
|
worker.promoteDraftNow(draft.planId)
|
||||||
|
|
||||||
@@ -365,7 +365,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||||
planStore[draft.planId] = draft
|
planStore[draft.planId] = draft
|
||||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
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) }
|
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||||
|
|
||||||
@@ -389,7 +390,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
activePlanId = UUID.randomUUID(),
|
activePlanId = UUID.randomUUID(),
|
||||||
activeAttemptId = 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) }
|
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||||
|
|
||||||
@@ -440,7 +442,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
|
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
|
||||||
state: SpacecraftPlanningStateEntity,
|
state: SpacecraftPlanningStateEntity,
|
||||||
projection: List<CalculatedPlan> = emptyList(),
|
projection: List<CalculatedPlan> = emptyList(),
|
||||||
projectionService: PlanProjectionService = projectionReturning(projection)
|
rawWindows: List<CalculatedPlan> = emptyList(),
|
||||||
|
projectionService: PlanProjectionService = projectionReturning(projection, rawWindows)
|
||||||
): IssueDuePlanWorker {
|
): IssueDuePlanWorker {
|
||||||
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
||||||
`when`(snapshotRepository.findAllByActiveTrue()).thenReturn(
|
`when`(snapshotRepository.findAllByActiveTrue()).thenReturn(
|
||||||
@@ -464,12 +467,20 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun projectionReturning(projection: List<CalculatedPlan>): PlanProjectionService {
|
private fun projectionReturning(
|
||||||
|
projection: List<CalculatedPlan>,
|
||||||
|
rawWindows: List<CalculatedPlan> = emptyList()
|
||||||
|
): PlanProjectionService {
|
||||||
val service = mock(PlanProjectionService::class.java)
|
val service = mock(PlanProjectionService::class.java)
|
||||||
`when`(service.project(spacecraftId)).thenReturn(projection)
|
`when`(service.project(spacecraftId)).thenReturn(projection)
|
||||||
|
`when`(service.rawZrvWindows(spacecraftId)).thenReturn(rawWindows)
|
||||||
return service
|
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<UUID, PlannedPlanEntity>): PlannedPlanRepository {
|
private fun plannedRepository(store: MutableMap<UUID, PlannedPlanEntity>): PlannedPlanRepository {
|
||||||
val repository = mock(PlannedPlanRepository::class.java)
|
val repository = mock(PlannedPlanRepository::class.java)
|
||||||
`when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) }
|
`when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) }
|
||||||
|
|||||||
+54
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user