pcp-tgu-ops-ui: Ф6 ядро — draft/validate/promote в редакторе (replace), прямой прокси /api/pcp-tgu
This commit is contained in:
@@ -287,9 +287,21 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
|
|||||||
- **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен).
|
- **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен).
|
||||||
- **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки.
|
- **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки.
|
||||||
|
|
||||||
### Фаза 6 — Фронт (редактор)
|
### Фаза 6 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote)
|
||||||
**Модель: Sonnet.**
|
**Модель: Sonnet.**
|
||||||
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку [saveEditedPlan](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts#L13) на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`.
|
|
||||||
|
> **Реализовано (ядро):**
|
||||||
|
> - API-слой: [missionPlanningApi.ts](services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts) — `updateSurveyRoute` (PATCH), `deleteMode` (DELETE), `validateMission` (POST validate, тип `MissionValidation`/`ModeConflict`); новый [tguPlanApi.ts](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts) — `createDraft`/`promoteDraft`. Прямые вызовы через Vite-прокси: `/api/pcp-mission`→:7010, новый `/api/pcp-tgu`→:7011 (без BFF, как и было решено).
|
||||||
|
> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): ленивое создание DRAFT (`ensureDraft`, mode=replace) на первую сохранённую правку; `saveSurveyRoute`/`saveDropRoute`/`deleteMode` адресуются в `draftPlanId`, а не в живой план; после каждой серверной правки — перечитывание включений DRAFT + `validate`; смена выбранного плана сбрасывает черновик; дедуп включения (локальная правка ⊕ перечитанная plan-origin копия по `planModeId`).
|
||||||
|
> - Маппинг авторитетных конфликтов бэка на включения редактора: [editorValidation.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts) (по `planModeId`) + 3 теста.
|
||||||
|
> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением.
|
||||||
|
> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные.
|
||||||
|
>
|
||||||
|
> **Вынесено в отдельные подзадачи (не входит в ядро):**
|
||||||
|
> - **fork-forward в UI**: выбор будущей ЗРВ (мини-панель/диалог) и `createDraft(mode=fork-forward, windowStart/Stop/kpp)` для `LAID_IN`-базы. Сейчас `ensureDraft` всегда шлёт `replace`; для заложенной базы бэк ответит 422 — сообщение показывается оператору.
|
||||||
|
> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote).
|
||||||
|
> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора.
|
||||||
|
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`.
|
||||||
- [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): lazy-создание черновика на первую правку; адресация правок в `DRAFT`; на каждой правке — `validate`.
|
- [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): lazy-создание черновика на первую правку; адресация правок в `DRAFT`; на каждой правке — `validate`.
|
||||||
- [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = `validate` (или скрыть), «Применить» = `promote` (гасить по гарду точки невозврата).
|
- [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = `validate` (или скрыть), «Применить» = `promote` (гасить по гарду точки невозврата).
|
||||||
- Выбор будущей ЗРВ для `fork-forward` (мини-панель/диалог); откат: список версий цепочки → «восстановить».
|
- Выбор будущей ЗРВ для `fork-forward` (мини-панель/диалог); откат: список версий цепочки → «восстановить».
|
||||||
|
|||||||
@@ -138,7 +138,79 @@ export async function saveDropRoute(planId: string, params: SaveDropRouteParams)
|
|||||||
return JSON.parse(text) as DropMode;
|
return JSON.parse(text) as DropMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): Promise<SurveyRoute> {
|
/**
|
||||||
|
* Перестроение контура существующего режима съёмки по новым параметрам окна/крена
|
||||||
|
* (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения
|
||||||
|
* в плане-черновике: контур пересчитывается тем же алгоритмом, что и /survey-route/build.
|
||||||
|
*/
|
||||||
|
export async function updateSurveyRoute(modeId: number, params: BuildSurveyRouteParams): Promise<SurveyRoute> {
|
||||||
|
return postSurveyRoute(`${BASE}/api/missions/survey-modes/${encodeURIComponent(modeId)}`, params, "PATCH");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление включения (съёмки или сброса) из плана-черновика
|
||||||
|
* (pcp-mission-planing-service, DELETE /api/missions/modes/{modeId}). Для съёмки бэк снимает
|
||||||
|
* брони слотов и связи со сбросами, осиротевший сброс удаляется; для сброса — только его связи.
|
||||||
|
*/
|
||||||
|
export async function deleteMode(modeId: number): Promise<void> {
|
||||||
|
const url = `${BASE}/api/missions/modes/${encodeURIComponent(modeId)}`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new TguApiError(extractErrorText(text) || "Ошибка удаления включения.", "http", response.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Уровень конфликта валидации: error блокирует «Применить», warning — информативный. */
|
||||||
|
export type ConflictSeverity = "error" | "warning";
|
||||||
|
|
||||||
|
/** Один конфликт на затронутом включении (дифф по режимам). */
|
||||||
|
export type ModeConflict = {
|
||||||
|
/** id режима (survey или drop), к которому относится конфликт. */
|
||||||
|
modeId: number;
|
||||||
|
/** Код правила (TIME_OVERLAP / TARGET_VISIBILITY / COMMS_SESSION / DROP_ORDER / ONBOARD_LIMIT). */
|
||||||
|
rule: string;
|
||||||
|
severity: ConflictSeverity;
|
||||||
|
message: string;
|
||||||
|
/** id второго участника парного правила (например, пересечения по времени), если есть. */
|
||||||
|
relatedModeId?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MissionValidation = {
|
||||||
|
missionId: string;
|
||||||
|
conflicts: ModeConflict[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидация миссии-черновика на конфликты/наложения включений
|
||||||
|
* (pcp-mission-planing-service, POST /api/missions/{id}/validate). MVP: бэк-заглушка возвращает
|
||||||
|
* корректный контракт с пустым списком конфликтов; контракт стабилен для UI.
|
||||||
|
*/
|
||||||
|
export async function validateMission(missionId: string): Promise<MissionValidation> {
|
||||||
|
const url = `${BASE}/api/missions/${encodeURIComponent(missionId)}/validate`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: "POST", headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network");
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TguApiError(extractErrorText(text) || "Ошибка валидации плана.", "http", response.status);
|
||||||
|
}
|
||||||
|
return JSON.parse(text) as MissionValidation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postSurveyRoute(
|
||||||
|
url: string,
|
||||||
|
params: BuildSurveyRouteParams,
|
||||||
|
method: "POST" | "PATCH" = "POST"
|
||||||
|
): Promise<SurveyRoute> {
|
||||||
const body = {
|
const body = {
|
||||||
satelliteId: params.satelliteId,
|
satelliteId: params.satelliteId,
|
||||||
time: params.time,
|
time: params.time,
|
||||||
@@ -154,7 +226,7 @@ async function postSurveyRoute(url: string, params: BuildSurveyRouteParams): Pro
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
method: "POST",
|
method,
|
||||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { TguApiError } from "../model/tguTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызовы версионирования/выдачи плана в pcp-tgu-service напрямую через Vite-прокси
|
||||||
|
* (`/api/pcp-tgu` → :7011), как и pcp-mission напрямую на :7010. BFF не используется.
|
||||||
|
*/
|
||||||
|
const BASE = "/api/pcp-tgu";
|
||||||
|
|
||||||
|
/** Режим наполнения черновика; та же строка уходит в clone миссии. */
|
||||||
|
export type DraftMode = "replace" | "fork-forward";
|
||||||
|
|
||||||
|
export type CreateDraftRequest = {
|
||||||
|
mode: DraftMode;
|
||||||
|
/** Начало окна целевой ЗРВ (UTC naive, ISO без зоны). Обязательно для fork-forward. */
|
||||||
|
windowStart?: string;
|
||||||
|
/** Конец окна целевой ЗРВ. Обязательно для fork-forward. */
|
||||||
|
windowStop?: string;
|
||||||
|
/** КПП целевой ЗРВ. Обязательно для fork-forward. */
|
||||||
|
windowKppId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateDraftResponse = {
|
||||||
|
/** Идентификатор созданного черновика (= mission_id клон-миссии). */
|
||||||
|
draftPlanId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromoteDraftResponse = {
|
||||||
|
processInstanceKey: number;
|
||||||
|
processDefinitionKey: number;
|
||||||
|
bpmnProcessId: string;
|
||||||
|
version: number;
|
||||||
|
planId: string;
|
||||||
|
attemptId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт черновик плана (DRAFT) на основе базового плана
|
||||||
|
* (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает
|
||||||
|
* draftPlanId, в который адресуются правки (Ф3) и который применяется promote (Ф5).
|
||||||
|
*/
|
||||||
|
export async function createDraft(basePlanId: string, request: CreateDraftRequest): Promise<CreateDraftResponse> {
|
||||||
|
return postJson<CreateDraftResponse>(
|
||||||
|
`${BASE}/api/plans/${encodeURIComponent(basePlanId)}/draft`,
|
||||||
|
request,
|
||||||
|
"Не удалось создать черновик плана."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Применить» черновик: промоутит DRAFT в живую цепочку и выдаёт его
|
||||||
|
* (pcp-tgu-service, POST /api/plans/{draftPlanId}/promote). Под оптимистичным локом базы и
|
||||||
|
* гардом точки невозврата: 409 — база уехала / уже идёт выдача; 422 — не DRAFT / слот сместился /
|
||||||
|
* заморожено; 404 — черновика нет.
|
||||||
|
*/
|
||||||
|
export async function promoteDraft(draftPlanId: string): Promise<PromoteDraftResponse> {
|
||||||
|
return postJson<PromoteDraftResponse>(
|
||||||
|
`${BASE}/api/plans/${encodeURIComponent(draftPlanId)}/promote`,
|
||||||
|
undefined,
|
||||||
|
"Не удалось применить черновик плана."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson<T>(url: string, body: unknown, fallbackMessage: string): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: body === undefined
|
||||||
|
? { Accept: "application/json" }
|
||||||
|
: { Accept: "application/json", "Content-Type": "application/json" },
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) })
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network");
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TguApiError(extractErrorText(text) || fallbackMessage, "http", response.status);
|
||||||
|
}
|
||||||
|
if (!text.trim()) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorText(body: string): string {
|
||||||
|
const trimmed = body.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
for (const field of ["message", "error", "reason", "detail"]) {
|
||||||
|
const value = parsed[field];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
@@ -5,7 +5,15 @@ import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
|
|||||||
import type { RequestMapItem } from "../../model/requestTypes";
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
|
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
|
||||||
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
|
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
|
||||||
import { buildSurveyRoute, saveDropRoute, saveSurveyRoute, type SurveyRoute } from "../../api/missionPlanningApi";
|
import {
|
||||||
|
buildSurveyRoute,
|
||||||
|
deleteMode,
|
||||||
|
saveDropRoute,
|
||||||
|
saveSurveyRoute,
|
||||||
|
validateMission,
|
||||||
|
type SurveyRoute
|
||||||
|
} from "../../api/missionPlanningApi";
|
||||||
|
import { createDraft, promoteDraft } from "../../api/tguPlanApi";
|
||||||
import { parseUtc } from "../../utils/utcTime";
|
import { 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";
|
||||||
@@ -13,7 +21,8 @@ import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
|
|||||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
||||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||||
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||||
import { fetchPlanModes, saveEditedPlan, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
||||||
|
import { validationToConflicts } from "./editorValidation";
|
||||||
import {
|
import {
|
||||||
addDownlink,
|
addDownlink,
|
||||||
addShoot,
|
addShoot,
|
||||||
@@ -107,6 +116,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||||
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
|
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
|
||||||
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
const [planModes, setPlanModes] = useState<PlanMode[]>([]);
|
||||||
|
// Ленивый черновик (Ф5/Ф6): создаётся клоном базового плана на ПЕРВУЮ правку (replace).
|
||||||
|
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
||||||
|
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
||||||
|
const [draftPlanId, setDraftPlanId] = 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[]>([]);
|
||||||
@@ -141,7 +154,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
const modes: EditorMode[] = DEFAULT_EDITOR_MODES;
|
||||||
// Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
|
// Существующие включения плана (read-only) + правки из черновика — то, что видно на таймлайне.
|
||||||
const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
|
const planWorks = useMemo<EditorWork[]>(() => planModesToWorks(planModes), [planModes]);
|
||||||
const displayWorks = useMemo<EditorWork[]>(() => [...planWorks, ...draft.works], [planWorks, draft.works]);
|
// id режимов, уже представленных локальной (редактируемой) работой черновика — чтобы не дублировать
|
||||||
|
// только что сохранённое включение его plan-origin копией из перечитанных planModes.
|
||||||
|
const addedModeIds = useMemo(
|
||||||
|
() => new Set(draft.works.map((work) => work.planModeId).filter((id): id is number => id != null)),
|
||||||
|
[draft.works]
|
||||||
|
);
|
||||||
|
const displayWorks = useMemo<EditorWork[]>(
|
||||||
|
() => [
|
||||||
|
...planWorks.filter((work) => work.planModeId == null || !addedModeIds.has(work.planModeId)),
|
||||||
|
...draft.works
|
||||||
|
],
|
||||||
|
[planWorks, draft.works, addedModeIds]
|
||||||
|
);
|
||||||
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
|
const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]);
|
||||||
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
|
const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]);
|
||||||
const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
|
const selectedWork = displayWorks.find((work) => work.id === selectedWorkId);
|
||||||
@@ -194,7 +219,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
.sort((a, b) => a.startMs - b.startMs);
|
.sort((a, b) => a.startMs - b.startMs);
|
||||||
}, [planModes, selectedZone]);
|
}, [planModes, selectedZone]);
|
||||||
|
|
||||||
const dirty = history.past.length > 0 || draft.works.some((work) => work.isNew);
|
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
||||||
|
// локальные несохранённые твики без DRAFT применять нечего.
|
||||||
|
const dirty = Boolean(draftPlanId);
|
||||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||||
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
||||||
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
||||||
@@ -383,17 +410,24 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
};
|
};
|
||||||
}, [rightMode, noradId, window.fromMs, window.toMs]);
|
}, [rightMode, noradId, window.fromMs, window.toMs]);
|
||||||
|
|
||||||
|
// Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPlan) return;
|
setDraftPlanId(undefined);
|
||||||
const planId = selectedPlan.planId;
|
setServiceConflicts({});
|
||||||
|
}, [selectedPlan]);
|
||||||
|
|
||||||
|
// Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
|
||||||
|
useEffect(() => {
|
||||||
|
const missionId = draftPlanId ?? selectedPlan?.planId;
|
||||||
|
if (!missionId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void fetchPlanModes(planId).then((modes) => {
|
void fetchPlanModes(missionId).then((modes) => {
|
||||||
if (!cancelled) setPlanModes(modes);
|
if (!cancelled) setPlanModes(modes);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// silently ignore — plan modes are supplementary display data
|
// silently ignore — plan modes are supplementary display data
|
||||||
});
|
});
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [selectedPlan]);
|
}, [selectedPlan, draftPlanId]);
|
||||||
|
|
||||||
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
|
const changeDraft = (transform: (draft: DraftHistory["draft"]) => DraftHistory["draft"]) => {
|
||||||
setHistory((current) => mutate(current, transform));
|
setHistory((current) => mutate(current, transform));
|
||||||
@@ -406,6 +440,34 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
changeDraft((current) => updateWork(current, workId, patch));
|
changeDraft((current) => updateWork(current, workId, patch));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT (replace)
|
||||||
|
// и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда.
|
||||||
|
const ensureDraft = useCallback(async (): Promise<string> => {
|
||||||
|
if (draftPlanId) return draftPlanId;
|
||||||
|
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
||||||
|
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
||||||
|
setDraftPlanId(created.draftPlanId);
|
||||||
|
return created.draftPlanId;
|
||||||
|
}, [draftPlanId, selectedPlan]);
|
||||||
|
|
||||||
|
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
|
||||||
|
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
|
||||||
|
const revalidate = useCallback(async (missionId: string, works: EditorWork[]) => {
|
||||||
|
try {
|
||||||
|
const validation = await validateMission(missionId);
|
||||||
|
setServiceConflicts(validationToConflicts(validation, works));
|
||||||
|
} catch {
|
||||||
|
// валидация вспомогательна — молча игнорируем сбой
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// После каждой серверной правки перечитываем включения DRAFT (источник истины) и ревалидируем.
|
||||||
|
const refreshDraftModes = useCallback(async (missionId: string) => {
|
||||||
|
const modes = await fetchPlanModes(missionId);
|
||||||
|
setPlanModes(modes);
|
||||||
|
void revalidate(missionId, planModesToWorks(modes));
|
||||||
|
}, [revalidate]);
|
||||||
|
|
||||||
const addShootWork = async () => {
|
const addShootWork = async () => {
|
||||||
if (!target || !selectedShootPass || !modeId) return;
|
if (!target || !selectedShootPass || !modeId) return;
|
||||||
|
|
||||||
@@ -413,13 +475,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
|
// выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут
|
||||||
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
|
// уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения.
|
||||||
const numericNorad = noradId ? Number(noradId) : NaN;
|
const numericNorad = noradId ? Number(noradId) : NaN;
|
||||||
|
// Маршрут, сохранённый в БД черновика (несёт surveyModeId для последующего delete/patch).
|
||||||
|
let savedRoute: SurveyRoute | undefined = surveyRoute;
|
||||||
if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) {
|
if (selectedPlan && surveyRoute && routeWindow && routeRoll != null && Number.isFinite(numericNorad)) {
|
||||||
const planId = selectedPlan.planId;
|
|
||||||
const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
|
const routeDurationSeconds = Math.max(1, (routeWindow.endMs - routeWindow.startMs) / 1000);
|
||||||
setRouteLoading(true);
|
setRouteLoading(true);
|
||||||
setRouteError(undefined);
|
setRouteError(undefined);
|
||||||
try {
|
try {
|
||||||
const saved = await saveSurveyRoute(planId, {
|
// Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план.
|
||||||
|
const draftId = await ensureDraft();
|
||||||
|
savedRoute = await saveSurveyRoute(draftId, {
|
||||||
satelliteId: numericNorad,
|
satelliteId: numericNorad,
|
||||||
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
|
time: new Date(routeWindow.startMs).toISOString().slice(0, 19),
|
||||||
durationSeconds: routeDurationSeconds,
|
durationSeconds: routeDurationSeconds,
|
||||||
@@ -431,7 +496,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
||||||
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
||||||
});
|
});
|
||||||
setSurveyRoute(saved);
|
setSurveyRoute(savedRoute);
|
||||||
|
void refreshDraftModes(draftId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
|
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
|
||||||
return;
|
return;
|
||||||
@@ -454,11 +520,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
label: mode?.label ?? "Съёмка",
|
label: mode?.label ?? "Съёмка",
|
||||||
modeId,
|
modeId,
|
||||||
target,
|
target,
|
||||||
revolution: surveyRoute?.revolution ?? selectedShootPass.revolution,
|
// id сохранённого режима съёмки — чтобы удаление/правка адресовались в БД черновика.
|
||||||
rollDeg: surveyRoute?.roll ?? routeRoll,
|
planModeId: savedRoute?.surveyModeId,
|
||||||
captureAngleDeg: surveyRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
|
revolution: savedRoute?.revolution ?? selectedShootPass.revolution,
|
||||||
centerLat: surveyRoute?.lat,
|
rollDeg: savedRoute?.roll ?? routeRoll,
|
||||||
centerLon: surveyRoute?.longitude
|
captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
|
||||||
|
centerLat: savedRoute?.lat,
|
||||||
|
centerLon: savedRoute?.longitude
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setSelectedWorkId(id);
|
setSelectedWorkId(id);
|
||||||
@@ -492,14 +560,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const planId = selectedPlan.planId;
|
|
||||||
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
|
const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time);
|
||||||
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
|
const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time);
|
||||||
|
|
||||||
setDropSaving(true);
|
setDropSaving(true);
|
||||||
setDropError(undefined);
|
setDropError(undefined);
|
||||||
|
let savedDropId: number | undefined;
|
||||||
try {
|
try {
|
||||||
await saveDropRoute(planId, {
|
// Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
|
||||||
|
const draftId = await ensureDraft();
|
||||||
|
const savedDrop = await saveDropRoute(draftId, {
|
||||||
satelliteId: numericNorad,
|
satelliteId: numericNorad,
|
||||||
station: String(selectedZone.stationId),
|
station: String(selectedZone.stationId),
|
||||||
revolution: selectedZone.revolution,
|
revolution: selectedZone.revolution,
|
||||||
@@ -511,6 +581,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
planStart: new Date(selectedPlan.startMs).toISOString().slice(0, 19),
|
||||||
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
planEnd: new Date(selectedPlan.endMs).toISOString().slice(0, 19)
|
||||||
});
|
});
|
||||||
|
savedDropId = savedDrop.id;
|
||||||
|
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
|
||||||
|
void refreshDraftModes(draftId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
|
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
|
||||||
return;
|
return;
|
||||||
@@ -518,8 +591,6 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setDropSaving(false);
|
setDropSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем включения плана и добавляем работу-сброс в черновик для отображения на таймлайне.
|
|
||||||
void fetchPlanModes(planId).then(setPlanModes).catch(() => {});
|
|
||||||
const stationNm = stationNameById(selectedZone.stationId);
|
const stationNm = stationNameById(selectedZone.stationId);
|
||||||
const id = nextWorkId(idCounterRef);
|
const id = nextWorkId(idCounterRef);
|
||||||
changeDraft((current) =>
|
changeDraft((current) =>
|
||||||
@@ -528,7 +599,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
startMs: zoneStartMsVal,
|
startMs: zoneStartMsVal,
|
||||||
endMs: zoneStopMsVal,
|
endMs: zoneStopMsVal,
|
||||||
label: `Сброс · ${stationNm}`,
|
label: `Сброс · ${stationNm}`,
|
||||||
station: { id: String(selectedZone.stationId), name: stationNm }
|
station: { id: String(selectedZone.stationId), name: stationNm },
|
||||||
|
planModeId: savedDropId
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setSelectedWorkId(id);
|
setSelectedWorkId(id);
|
||||||
@@ -537,17 +609,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setSelectedDropSurveyIds(new Set());
|
setSelectedDropSurveyIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const runCheck = () => {
|
// «Проверить»: если черновик уже создан — авторитетная бэкенд-валидация (Ф4); иначе локальное
|
||||||
|
// превью видимости (черновика на сервере ещё нет — правок, адресованных в DRAFT, не было).
|
||||||
|
const runCheck = async () => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
|
setApplyError(undefined);
|
||||||
setChecking(false);
|
try {
|
||||||
setNotice("Проверка видимости выполнена для текущего черновика.");
|
if (draftPlanId) {
|
||||||
|
const validation = await validateMission(draftPlanId);
|
||||||
|
setServiceConflicts(validationToConflicts(validation, planWorks));
|
||||||
|
setNotice("Проверка выполнена на сервере (валидация черновика).");
|
||||||
|
} else {
|
||||||
|
setServiceConflicts(checkVisibility(draft, { spacecraftId: selectedSpacecraftId, stations }));
|
||||||
|
setNotice("Проверка видимости выполнена для текущего черновика.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setApplyError(error instanceof Error ? error.message : "Не удалось выполнить проверку.");
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// «Применить» = promote черновика (Ф5): DRAFT уходит в живую цепочку и выдаётся. Под
|
||||||
|
// оптимистичным локом базы и гардом точки невозврата (409/422 с пояснением приходят с бэка).
|
||||||
const applyDraft = async () => {
|
const applyDraft = async () => {
|
||||||
const planId = selectedPlan?.planId ?? draft.planId;
|
if (!draftPlanId) {
|
||||||
if (!planId) {
|
setApplyError("Нет правок для применения — черновик ещё не создан.");
|
||||||
setApplyError("Нельзя применить черновик: план не выбран.");
|
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -556,8 +643,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setApplyError(undefined);
|
setApplyError(undefined);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
try {
|
try {
|
||||||
const result = await saveEditedPlan({ planId, draft });
|
const result = await promoteDraft(draftPlanId);
|
||||||
setNotice(result.message);
|
setNotice(`Черновик применён: план ${result.planId} отправлен на выдачу.`);
|
||||||
|
setDraftPlanId(undefined);
|
||||||
|
setServiceConflicts({});
|
||||||
setHistory((current) => ({ ...current, past: [], future: [] }));
|
setHistory((current) => ({ ...current, past: [], future: [] }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
|
setApplyError(error instanceof Error ? error.message : "Не удалось применить правки плана.");
|
||||||
@@ -566,6 +655,25 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
|
||||||
|
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
|
||||||
|
// (planModeId нет) — только локально.
|
||||||
|
const handleDeleteWork = async (workId: string) => {
|
||||||
|
const work = draft.works.find((item) => item.id === workId);
|
||||||
|
if (work?.planModeId != null && draftPlanId) {
|
||||||
|
try {
|
||||||
|
await deleteMode(work.planModeId);
|
||||||
|
await refreshDraftModes(draftPlanId);
|
||||||
|
} catch (error) {
|
||||||
|
setApplyError(error instanceof Error ? error.message : "Не удалось удалить включение.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changeDraft((current) => deleteWork(current, workId));
|
||||||
|
setSelectedWorkId(undefined);
|
||||||
|
setRightMode("none");
|
||||||
|
};
|
||||||
|
|
||||||
const zoom = (factor: number) => {
|
const zoom = (factor: number) => {
|
||||||
setWindow((current) => {
|
setWindow((current) => {
|
||||||
const center = (current.fromMs + current.toMs) / 2;
|
const center = (current.fromMs + current.toMs) / 2;
|
||||||
@@ -756,11 +864,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
conflicts={conflicts[selectedWork.id] ?? []}
|
conflicts={conflicts[selectedWork.id] ?? []}
|
||||||
readOnly={selectedWork.origin === "plan"}
|
readOnly={selectedWork.origin === "plan"}
|
||||||
onUpdate={onChangeWork}
|
onUpdate={onChangeWork}
|
||||||
onDelete={(workId) => {
|
onDelete={(workId) => { void handleDeleteWork(workId); }}
|
||||||
changeDraft((current) => deleteWork(current, workId));
|
|
||||||
setSelectedWorkId(undefined);
|
|
||||||
setRightMode("none");
|
|
||||||
}}
|
|
||||||
onDuplicate={(workId) => {
|
onDuplicate={(workId) => {
|
||||||
const id = nextWorkId(idCounterRef);
|
const id = nextWorkId(idCounterRef);
|
||||||
changeDraft((current) => duplicateWork(current, workId, id));
|
changeDraft((current) => duplicateWork(current, workId, id));
|
||||||
|
|||||||
@@ -1,23 +1,3 @@
|
|||||||
import type { Draft } from "./model/editorTypes";
|
|
||||||
|
|
||||||
export type SaveEditedPlanRequest = {
|
|
||||||
planId: string;
|
|
||||||
draft: Draft;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SaveEditedPlanResult = {
|
|
||||||
saved: false;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise<SaveEditedPlanResult> {
|
|
||||||
// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service.
|
|
||||||
return {
|
|
||||||
saved: false,
|
|
||||||
message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PlanSurveyMode = {
|
export type PlanSurveyMode = {
|
||||||
id: number;
|
id: number;
|
||||||
planId: number;
|
planId: number;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export type AddShootInput = {
|
|||||||
label: string;
|
label: string;
|
||||||
modeId?: string;
|
modeId?: string;
|
||||||
target: EditorTarget;
|
target: EditorTarget;
|
||||||
|
/** id сохранённого режима съёмки в БД черновика (если съёмка персистнута). */
|
||||||
|
planModeId?: number;
|
||||||
/** Параметры построенного маршрута (если съёмка строилась с превью). */
|
/** Параметры построенного маршрута (если съёмка строилась с превью). */
|
||||||
revolution?: number;
|
revolution?: number;
|
||||||
rollDeg?: number;
|
rollDeg?: number;
|
||||||
@@ -36,6 +38,8 @@ export type AddDownlinkInput = {
|
|||||||
endMs: number;
|
endMs: number;
|
||||||
label: string;
|
label: string;
|
||||||
station: EditorGroundStation;
|
station: EditorGroundStation;
|
||||||
|
/** id сохранённого режима сброса в БД черновика (если сброс персистнут). */
|
||||||
|
planModeId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
|
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
|
||||||
@@ -151,6 +155,7 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft {
|
|||||||
targetName: input.target.name,
|
targetName: input.target.name,
|
||||||
targetLat: input.target.lat,
|
targetLat: input.target.lat,
|
||||||
targetLon: input.target.lon,
|
targetLon: input.target.lon,
|
||||||
|
planModeId: input.planModeId,
|
||||||
revolution: input.revolution,
|
revolution: input.revolution,
|
||||||
rollDeg: input.rollDeg,
|
rollDeg: input.rollDeg,
|
||||||
captureAngleDeg: input.captureAngleDeg,
|
captureAngleDeg: input.captureAngleDeg,
|
||||||
@@ -171,6 +176,7 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft {
|
|||||||
startMs: input.startMs,
|
startMs: input.startMs,
|
||||||
endMs: input.endMs,
|
endMs: input.endMs,
|
||||||
stationId: input.station.id,
|
stationId: input.station.id,
|
||||||
|
planModeId: input.planModeId,
|
||||||
origin: "added",
|
origin: "added",
|
||||||
isNew: true
|
isNew: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MissionValidation } from "../../api/missionPlanningApi";
|
||||||
|
import { validationToConflicts } from "./editorValidation";
|
||||||
|
import type { EditorWork } from "./model/editorTypes";
|
||||||
|
|
||||||
|
function shoot(id: string, planModeId?: number): EditorWork {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: "shoot",
|
||||||
|
label: "Съёмка",
|
||||||
|
startMs: 0,
|
||||||
|
endMs: 1000,
|
||||||
|
origin: "plan",
|
||||||
|
planModeId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validationToConflicts", () => {
|
||||||
|
it("maps backend conflicts onto editor work ids by planModeId", () => {
|
||||||
|
const works = [shoot("survey-7", 7), shoot("survey-9", 9)];
|
||||||
|
const validation: MissionValidation = {
|
||||||
|
missionId: "m1",
|
||||||
|
conflicts: [
|
||||||
|
{ modeId: 7, rule: "TIME_OVERLAP", severity: "error", message: "Пересечение по времени", relatedModeId: 9 },
|
||||||
|
{ modeId: 7, rule: "TARGET_VISIBILITY", severity: "warning", message: "Вне зоны видимости" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = validationToConflicts(validation, works);
|
||||||
|
|
||||||
|
expect(map["survey-7"]).toEqual(["Пересечение по времени", "Вне зоны видимости"]);
|
||||||
|
expect(map["survey-9"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops conflicts whose modeId has no matching work", () => {
|
||||||
|
const works = [shoot("survey-7", 7)];
|
||||||
|
const validation: MissionValidation = {
|
||||||
|
missionId: "m1",
|
||||||
|
conflicts: [{ modeId: 42, rule: "TIME_OVERLAP", severity: "error", message: "x" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validationToConflicts(validation, works)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty map for the stub (no conflicts)", () => {
|
||||||
|
const works = [shoot("survey-7", 7)];
|
||||||
|
expect(validationToConflicts({ missionId: "m1", conflicts: [] }, works)).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { MissionValidation } from "../../api/missionPlanningApi";
|
||||||
|
import type { EditorConflictMap } from "./editorConflicts";
|
||||||
|
import type { EditorWork } from "./model/editorTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проецирует авторитетные конфликты бэкенда (валидация миссии-черновика, keyed по numeric modeId)
|
||||||
|
* на карту конфликтов редактора (keyed по workId). Включения редактора несут `planModeId` —
|
||||||
|
* идентификатор сохранённого режима в БД; по нему и сопоставляем. Конфликты на режимах, которых
|
||||||
|
* нет среди работ редактора (например, ещё не подгружены), молча отбрасываются.
|
||||||
|
*
|
||||||
|
* MVP: бэк-валидатор — заглушка и возвращает пустой список, поэтому сейчас результат пуст. Маппинг
|
||||||
|
* написан вперёд: когда правила появятся, UI начнёт подсвечивать конкретные включения без доработок.
|
||||||
|
*/
|
||||||
|
export function validationToConflicts(validation: MissionValidation, works: EditorWork[]): EditorConflictMap {
|
||||||
|
const workIdByModeId = new Map<number, string>();
|
||||||
|
for (const work of works) {
|
||||||
|
if (work.planModeId != null) {
|
||||||
|
workIdByModeId.set(work.planModeId, work.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: EditorConflictMap = {};
|
||||||
|
for (const conflict of validation.conflicts) {
|
||||||
|
const workId = workIdByModeId.get(conflict.modeId);
|
||||||
|
if (!workId) continue;
|
||||||
|
result[workId] = [...(result[workId] ?? []), conflict.message];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "")
|
rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "")
|
||||||
},
|
},
|
||||||
|
"/api/pcp-tgu": {
|
||||||
|
target: "http://localhost:7011",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, "")
|
||||||
|
},
|
||||||
"/api/stations": {
|
"/api/stations": {
|
||||||
target: "http://localhost:7009",
|
target: "http://localhost:7009",
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
|
|||||||
Reference in New Issue
Block a user