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()
}
@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(
summary = "Проверить миссию-черновик на конфликты",
description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " +
@@ -936,6 +936,21 @@ class MissionPlaningService(
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;
}
/** Итог расчёта плана съёмки (ответ /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}). Правка включения
@@ -37,6 +37,8 @@ type WorkInspectorProps = {
conflicts: string[];
/** Включение существующего плана — показываем только информацию, без правок. */
readOnly?: boolean;
/** Разрешить удаление даже у read-only включения плана (есть черновик — режим живёт в нём). */
canDelete?: boolean;
onUpdate: (workId: string, patch: EditorWorkPatch) => void;
onDelete: (workId: string) => void;
onDuplicate: (workId: string) => void;
@@ -131,6 +133,7 @@ export function WorkInspector({
surveyModeLabel,
conflicts,
readOnly,
canDelete,
onUpdate,
onDelete,
onDuplicate,
@@ -270,6 +273,14 @@ export function WorkInspector({
</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" />
</div>
);
@@ -6,11 +6,19 @@ type EditorToolbarProps = {
selectedPlan?: TguPlanUi;
platforms: TguPlatformUi[];
checking: boolean;
/** Идёт расчёт/очистка всего плана — блокирует кнопки операций над планом. */
planCalcRunning: boolean;
/** Почему нельзя рассчитывать/чистить план (заложен/стартовал) — гасит кнопки. */
calcBlockedReason?: string;
conflictCount: number;
windowLabel: string;
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo" | "mapPick";
onSetRightMode: (mode: "shoot" | "downlink") => void;
onCheck: () => void;
/** Рассчитать план съёмки и сброса одной операцией. */
onCalculatePlan: () => void;
/** Полностью очистить план (с подтверждением внутри обработчика). */
onClearPlan: () => void;
onZoom: (factor: number) => void;
};
@@ -19,11 +27,15 @@ export function EditorToolbar({
selectedPlan,
platforms,
checking,
planCalcRunning,
calcBlockedReason,
conflictCount,
windowLabel,
rightMode,
onSetRightMode,
onCheck,
onCalculatePlan,
onClearPlan,
onZoom
}: EditorToolbarProps) {
const platformLabel = selectedSpacecraftId
@@ -61,6 +73,27 @@ export function EditorToolbar({
</button>
</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-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 {
buildSurveyRoute,
calculateDrops,
calculateSurveys,
clearPlanModes,
deleteMode,
saveDropRoute,
saveSurveyRoute,
@@ -127,6 +130,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [stationDtos, setStationDtos] = useState<StationDto[]>([]);
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false);
// Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) —
// блокирует повторный запуск и кнопки правок, пока не перечитаем включения.
const [planCalcRunning, setPlanCalcRunning] = useState(false);
const [notice, setNotice] = useState<string>();
const [applyError, setApplyError] = useState<string>();
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
@@ -712,12 +718,71 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}
};
// Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
// (planModeId нет) — только локально.
// Запуск расчёта плана съёмки и сброса как одной операции (вкладка «Редактор»). Адресуем
// в DRAFT (ленивое создание на первую правку), затем перечитываем включения. Пересчёт на бэке
// сохраняет ручные маршруты съёмки (source=MANUAL) — они не затираются. Источник комплексного
// плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана.
const runPlanCalculation = async () => {
if (!selectedPlan || planCalcRunning) return;
setPlanCalcRunning(true);
setApplyError(undefined);
setNotice(undefined);
try {
const draftId = await ensureDraft();
const surveyResult = await calculateSurveys(draftId);
await calculateDrops(draftId);
await refreshDraftModes(draftId);
setNotice(
`Расчёт выполнен: съёмок ${surveyResult.surveyModesCount} (ручных сохранено ${surveyResult.manualModesCount}); сбросы пересчитаны.`
);
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось рассчитать план.");
} finally {
setPlanCalcRunning(false);
}
};
// Полная очистка плана: сносит ВСЕ включения (съёмки, включая ручные, и сбросы) в БД черновика
// и в локальном представлении. Единственный способ убрать ручные маршруты массово (помимо
// ручного удаления по одному). Подтверждение — действие необратимо.
const clearPlan = async () => {
if (!selectedPlan || planCalcRunning) return;
// window здесь затенён состоянием окна таймлайна — берём confirm из globalThis.
if (!globalThis.confirm("Очистить план полностью? Будут удалены все включения, включая построенные вручную. Действие необратимо.")) {
return;
}
setPlanCalcRunning(true);
setApplyError(undefined);
setNotice(undefined);
try {
const draftId = await ensureDraft();
await clearPlanModes(draftId);
await refreshDraftModes(draftId);
changeDraft((current) => ({ ...current, works: [] }));
setSelectedWorkId(undefined);
setRightMode("none");
setNotice("План полностью очищен.");
} catch (error) {
setApplyError(error instanceof Error ? error.message : "Не удалось очистить план.");
} finally {
setPlanCalcRunning(false);
}
};
// Удаление включения (правая панель, с подтверждением): персистнутые режимы (с planModeId)
// удаляются в БД черновика (Ф3, с каскадами drop↔survey), затем убираются из локального
// представления. Plan-origin включения адресуются по planModeId только когда черновик уже
// существует (draftPlanId): тогда включения принадлежат именно ему. Несохранённые (planModeId
// нет) — только локально.
const handleDeleteWork = async (workId: string) => {
const work = draft.works.find((item) => item.id === workId);
if (work?.planModeId != null && draftPlanId) {
const work = displayWorks.find((item) => item.id === workId);
if (!work) return;
const confirmMessage =
work.kind === "downlink"
? "Удалить включение сброса? Действие необратимо."
: "Удалить маршрут съёмки? Действие необратимо.";
if (!globalThis.confirm(confirmMessage)) return;
if (work.planModeId != null && draftPlanId) {
try {
await deleteMode(work.planModeId);
await refreshDraftModes(draftPlanId);
@@ -900,6 +965,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
surveyModeLabel={surveyModeLabel}
conflicts={conflicts[work.id] ?? []}
readOnly={work.origin === "plan"}
canDelete={draftPlanId != null}
onUpdate={onChangeWork}
onDelete={(workId) => { void handleDeleteWork(workId); }}
onDuplicate={(workId) => {
@@ -943,6 +1009,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
selectedPlan={selectedPlan}
platforms={platforms}
checking={checking}
planCalcRunning={planCalcRunning}
calcBlockedReason={inPlaceEditBlockedReason}
conflictCount={conflictCount(conflicts)}
windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode}
@@ -954,6 +1022,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setNotice(undefined);
}}
onCheck={runCheck}
onCalculatePlan={() => { void runPlanCalculation(); }}
onClearPlan={() => { void clearPlan(); }}
onZoom={zoom}
/>