pcp-tgu: редактор — расчёт плана съёмки+сброса одной операцией, очистка плана, удаление включений

Вкладка «Редактор»:
- Кнопка «Рассчитать план» — запускает surveys/calculate → drops/calculate
  одной операцией на черновике (ensureDraft), затем перечитывает включения.
  Источник комплексного плана (snapshot этапа 4) выбирает бэкенд по интервалу.
  Вызовы идут напрямую в pcp-mission-planing-service (/api/pcp-mission).
  Ручные маршруты съёмки (source=MANUAL) сохраняются при пересчёте.
- Кнопка «Очистить план» — новый эндпоинт DELETE /api/missions/{id}/modes
  (clearPlan) сносит ВСЕ включения, включая ручные, с бронями и связями.
- Удаление маршрута съёмки / сброса в правой панели с подтверждением:
  WorkInspector.canDelete показывает кнопку удаления и для рассчитанных
  (read-only) включений, когда есть черновик; удаление через deleteMode.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 21:03:44 +03:00
parent 9f1756a9f9
commit bd2bc9d583
6 changed files with 226 additions and 6 deletions
@@ -252,6 +252,27 @@ class MissionController(
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@Operation(
summary = "Полностью очистить план",
description = "Удаляет ВСЕ включения плана миссии: съёмки (включая построенные вручную, source=MANUAL) " +
"и сбросы, вместе с бронями слотов и связями съёмка↔сброс. В отличие от пересчёта плана, " +
"ручные маршруты тоже удаляются. Используется кнопкой «Очистить план» в редакторе."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "План очищен"),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@DeleteMapping("/{mission_id}/modes")
fun clearPlan(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID
): ResponseEntity<Void> {
missionPlaningService.clearPlan(id)
return ResponseEntity.noContent().build()
}
@Operation( @Operation(
summary = "Проверить миссию-черновик на конфликты", summary = "Проверить миссию-черновик на конфликты",
description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " + description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " +
@@ -936,6 +936,21 @@ class MissionPlaningService(
logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size) logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size)
} }
/**
* Полная очистка плана: удаляет ВСЕ включения миссии — съёмки (включая ручные, source=MANUAL)
* и сбросы — вместе с бронями слотов и связями съёмка↔сброс. В отличие от пересчёта плана
* ([loadSlots]/[calculateDrops]), ручные маршруты тоже удаляются. Сбросы удаляются первыми,
* чтобы снять связи до удаления съёмок и не плодить проверку осиротевших сбросов.
*/
@Transactional
fun clearPlan(missionId: UUID) {
missionRepository.findById(missionId).orElseThrow { notFound(missionId) }
val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)
modes.filterIsInstance<DropModeEntity>().forEach { deleteDropMode(it) }
modes.filterIsInstance<SurveyModeEntity>().forEach { deleteSurveyMode(it) }
logger.info("План миссии {} полностью очищен: удалено включений={}", missionId, modes.size)
}
/** /**
* Валидация миссии-черновика на конфликты/наложения включений. * Валидация миссии-черновика на конфликты/наложения включений.
* *
@@ -138,6 +138,76 @@ export async function saveDropRoute(planId: string, params: SaveDropRouteParams)
return JSON.parse(text) as DropMode; return JSON.parse(text) as DropMode;
} }
/** Итог расчёта плана съёмки (ответ /surveys/calculate). Времена — локальные. */
export type SurveyCalculationResult = {
missionId: string;
completed: boolean;
comPlanSnapshotId: number | null;
surveyModesCount: number;
manualModesCount: number;
complanModesCount: number;
slotsModesCount: number;
mixedModesCount: number;
bookedSlotIdsCount: number;
};
/**
* Запуск расчёта плана съёмки для плана-черновика
* (pcp-mission-planing-service, POST /api/missions/{planId}/surveys/calculate).
* Источник комплексного плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана.
* Пересчёт сохраняет ручные маршруты (source=MANUAL), затирая только пересчитываемые.
*/
export async function calculateSurveys(planId: string): Promise<SurveyCalculationResult> {
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/surveys/calculate`;
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 SurveyCalculationResult;
}
/**
* Запуск расчёта плана сбросов для плана-черновика по уже рассчитанным/добавленным съёмкам
* (pcp-mission-planing-service, POST /api/missions/{planId}/drops/calculate). Возвращает 204.
*/
export async function calculateDrops(planId: string): Promise<void> {
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/drops/calculate`;
let response: Response;
try {
response = await fetch(url, { method: "POST", 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);
}
}
/**
* Полная очистка плана: удаляет все включения (съёмки, включая ручные, и сбросы) плана-черновика
* (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes). Возвращает 204.
*/
export async function clearPlanModes(planId: string): Promise<void> {
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes`;
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);
}
}
/** /**
* Перестроение контура существующего режима съёмки по новым параметрам окна/крена * Перестроение контура существующего режима съёмки по новым параметрам окна/крена
* (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения * (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения
@@ -37,6 +37,8 @@ type WorkInspectorProps = {
conflicts: string[]; conflicts: string[];
/** Включение существующего плана — показываем только информацию, без правок. */ /** Включение существующего плана — показываем только информацию, без правок. */
readOnly?: boolean; readOnly?: boolean;
/** Разрешить удаление даже у read-only включения плана (есть черновик — режим живёт в нём). */
canDelete?: boolean;
onUpdate: (workId: string, patch: EditorWorkPatch) => void; onUpdate: (workId: string, patch: EditorWorkPatch) => void;
onDelete: (workId: string) => void; onDelete: (workId: string) => void;
onDuplicate: (workId: string) => void; onDuplicate: (workId: string) => void;
@@ -131,6 +133,7 @@ export function WorkInspector({
surveyModeLabel, surveyModeLabel,
conflicts, conflicts,
readOnly, readOnly,
canDelete,
onUpdate, onUpdate,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -270,6 +273,14 @@ export function WorkInspector({
</div> </div>
)} )}
{readOnly && canDelete && (
<div className="tgu-editor-panel__actions">
<button className="tgu-button tgu-button--reject" type="button" onClick={() => onDelete(work.id)}>
{work.kind === "downlink" ? "Удалить сброс" : "Удалить маршрут съёмки"}
</button>
</div>
)}
<span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" /> <span className="tgu-editor-panel__color" style={{ background: color }} aria-hidden="true" />
</div> </div>
); );
@@ -6,11 +6,19 @@ type EditorToolbarProps = {
selectedPlan?: TguPlanUi; selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[]; platforms: TguPlatformUi[];
checking: boolean; checking: boolean;
/** Идёт расчёт/очистка всего плана — блокирует кнопки операций над планом. */
planCalcRunning: boolean;
/** Почему нельзя рассчитывать/чистить план (заложен/стартовал) — гасит кнопки. */
calcBlockedReason?: string;
conflictCount: number; conflictCount: number;
windowLabel: string; windowLabel: string;
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
onSetRightMode: (mode: "shoot" | "downlink") => void; onSetRightMode: (mode: "shoot" | "downlink") => void;
onCheck: () => void; onCheck: () => void;
/** Рассчитать план съёмки и сброса одной операцией. */
onCalculatePlan: () => void;
/** Полностью очистить план (с подтверждением внутри обработчика). */
onClearPlan: () => void;
onZoom: (factor: number) => void; onZoom: (factor: number) => void;
}; };
@@ -19,11 +27,15 @@ export function EditorToolbar({
selectedPlan, selectedPlan,
platforms, platforms,
checking, checking,
planCalcRunning,
calcBlockedReason,
conflictCount, conflictCount,
windowLabel, windowLabel,
rightMode, rightMode,
onSetRightMode, onSetRightMode,
onCheck, onCheck,
onCalculatePlan,
onClearPlan,
onZoom onZoom
}: EditorToolbarProps) { }: EditorToolbarProps) {
const platformLabel = selectedSpacecraftId const platformLabel = selectedSpacecraftId
@@ -61,6 +73,27 @@ export function EditorToolbar({
</button> </button>
</div> </div>
<div className="tgu-editor-toolbar__group">
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={onCalculatePlan}
disabled={!selectedPlan || planCalcRunning || Boolean(calcBlockedReason)}
title={calcBlockedReason ?? "Рассчитать план съёмки и сброса по комплексному плану (ручные маршруты сохраняются)"}
>
{planCalcRunning ? "Расчёт..." : "Рассчитать план"}
</button>
<button
className="tgu-button tgu-button--reject"
type="button"
onClick={onClearPlan}
disabled={!selectedPlan || planCalcRunning || Boolean(calcBlockedReason)}
title={calcBlockedReason ?? "Удалить все включения плана, включая построенные вручную"}
>
Очистить план
</button>
</div>
<div className="tgu-editor-toolbar__spacer" /> <div className="tgu-editor-toolbar__spacer" />
<div className="tgu-editor-window-control" aria-label="Масштаб окна" title="Колесо мыши над таймлайном — тоже масштаб"> <div className="tgu-editor-window-control" aria-label="Масштаб окна" title="Колесо мыши над таймлайном — тоже масштаб">
@@ -7,6 +7,9 @@ import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { import {
buildSurveyRoute, buildSurveyRoute,
calculateDrops,
calculateSurveys,
clearPlanModes,
deleteMode, deleteMode,
saveDropRoute, saveDropRoute,
saveSurveyRoute, saveSurveyRoute,
@@ -127,6 +130,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [stationDtos, setStationDtos] = useState<StationDto[]>([]); const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({}); const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
// Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) —
// блокирует повторный запуск и кнопки правок, пока не перечитаем включения.
const [planCalcRunning, setPlanCalcRunning] = useState(false);
const [notice, setNotice] = useState<string>(); const [notice, setNotice] = useState<string>();
const [applyError, setApplyError] = useState<string>(); const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set()); const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
@@ -712,12 +718,71 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
} }
}; };
// Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, // Запуск расчёта плана съёмки и сброса как одной операции (вкладка «Редактор»). Адресуем
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые // в DRAFT (ленивое создание на первую правку), затем перечитываем включения. Пересчёт на бэке
// (planModeId нет) — только локально. // сохраняет ручные маршруты съёмки (source=MANUAL) — они не затираются. Источник комплексного
// плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана.
const runPlanCalculation = async () => {
if (!selectedPlan || planCalcRunning) return;
setPlanCalcRunning(true);
setApplyError(undefined);
setNotice(undefined);
try {
const draftId = await ensureDraft();
const surveyResult = await calculateSurveys(draftId);
await calculateDrops(draftId);
await refreshDraftModes(draftId);
setNotice(
`Расчёт выполнен: съёмок ${surveyResult.surveyModesCount} (ручных сохранено ${surveyResult.manualModesCount}); сбросы пересчитаны.`
);
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось рассчитать план.");
} finally {
setPlanCalcRunning(false);
}
};
// Полная очистка плана: сносит ВСЕ включения (съёмки, включая ручные, и сбросы) в БД черновика
// и в локальном представлении. Единственный способ убрать ручные маршруты массово (помимо
// ручного удаления по одному). Подтверждение — действие необратимо.
const clearPlan = async () => {
if (!selectedPlan || planCalcRunning) return;
// window здесь затенён состоянием окна таймлайна — берём confirm из globalThis.
if (!globalThis.confirm("Очистить план полностью? Будут удалены все включения, включая построенные вручную. Действие необратимо.")) {
return;
}
setPlanCalcRunning(true);
setApplyError(undefined);
setNotice(undefined);
try {
const draftId = await ensureDraft();
await clearPlanModes(draftId);
await refreshDraftModes(draftId);
changeDraft((current) => ({ ...current, works: [] }));
setSelectedWorkId(undefined);
setRightMode("none");
setNotice("План полностью очищен.");
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось очистить план.");
} finally {
setPlanCalcRunning(false);
}
};
// Удаление включения (правая панель, с подтверждением): персистнутые режимы (с planModeId)
// удаляются в БД черновика (Ф3, с каскадами drop↔survey), затем убираются из локального
// представления. Plan-origin включения адресуются по planModeId только когда черновик уже
// существует (draftPlanId): тогда включения принадлежат именно ему. Несохранённые (planModeId
// нет) — только локально.
const handleDeleteWork = async (workId: string) => { const handleDeleteWork = async (workId: string) => {
const work = draft.works.find((item) => item.id === workId); const work = displayWorks.find((item) => item.id === workId);
if (work?.planModeId != null && draftPlanId) { if (!work) return;
const confirmMessage =
work.kind === "downlink"
? "Удалить включение сброса? Действие необратимо."
: "Удалить маршрут съёмки? Действие необратимо.";
if (!globalThis.confirm(confirmMessage)) return;
if (work.planModeId != null && draftPlanId) {
try { try {
await deleteMode(work.planModeId); await deleteMode(work.planModeId);
await refreshDraftModes(draftPlanId); await refreshDraftModes(draftPlanId);
@@ -900,6 +965,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
surveyModeLabel={surveyModeLabel} surveyModeLabel={surveyModeLabel}
conflicts={conflicts[work.id] ?? []} conflicts={conflicts[work.id] ?? []}
readOnly={work.origin === "plan"} readOnly={work.origin === "plan"}
canDelete={draftPlanId != null}
onUpdate={onChangeWork} onUpdate={onChangeWork}
onDelete={(workId) => { void handleDeleteWork(workId); }} onDelete={(workId) => { void handleDeleteWork(workId); }}
onDuplicate={(workId) => { onDuplicate={(workId) => {
@@ -943,6 +1009,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
selectedPlan={selectedPlan} selectedPlan={selectedPlan}
platforms={platforms} platforms={platforms}
checking={checking} checking={checking}
planCalcRunning={planCalcRunning}
calcBlockedReason={inPlaceEditBlockedReason}
conflictCount={conflictCount(conflicts)} conflictCount={conflictCount(conflicts)}
windowLabel={formatDuration(window.toMs - window.fromMs)} windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode} rightMode={rightMode}
@@ -954,6 +1022,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setNotice(undefined); setNotice(undefined);
}} }}
onCheck={runCheck} onCheck={runCheck}
onCalculatePlan={() => { void runPlanCalculation(); }}
onClearPlan={() => { void clearPlan(); }}
onZoom={zoom} onZoom={zoom}
/> />