diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 47bb06c..19c822f 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -137,7 +137,7 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку. - `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ. 2. `POST /api/plans/{planId}/issue` — **боевой** эндпоинт ручной выдачи (поднять из `TestTguController` в `PlanController`), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную». -3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии). +3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии). ✅ СДЕЛАНО — [DeleteDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt) (404 нет / 422 не DRAFT / 204 успех; удаление клон-миссии best-effort, строка удаляется первой) + [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) `DELETE /api/plans/{draftPlanId}`; 4 unit-теста в `DeleteDraftUseCaseTest`. Фронт: `abandonDraft` + кнопка «Отмена» в редакторе + авто-отмена незавершённого черновика при смене плана/уходе (best-effort, не оставляет осиротевший DRAFT). 4. Цепочка/откат: переиспользуем `base_plan_id`/`chain_version`; восстановление версии = `draft(from=Pk)` → `issue`. --- diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts index f28ba27..7c512b2 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -60,6 +60,24 @@ export async function promoteDraft(draftPlanId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}`; + 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); + } +} + async function postJson(url: string, body: unknown, fallbackMessage: string): Promise { let response: Response; try { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx index 9feb5b1..3debbf1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx @@ -12,6 +12,7 @@ type EditorToolbarProps = { addedCount: number; dirty: boolean; applying: boolean; + canceling: boolean; windowLabel: string; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; onSetRightMode: (mode: "shoot" | "downlink") => void; @@ -19,6 +20,7 @@ type EditorToolbarProps = { onRedo: () => void; onCheck: () => void; onApply: () => void; + onCancel: () => void; onZoom: (factor: number) => void; onPan: (factor: number) => void; }; @@ -34,6 +36,7 @@ export function EditorToolbar({ addedCount, dirty, applying, + canceling, windowLabel, rightMode, onSetRightMode, @@ -41,6 +44,7 @@ export function EditorToolbar({ onRedo, onCheck, onApply, + onCancel, onZoom, onPan }: EditorToolbarProps) { @@ -107,7 +111,18 @@ export function EditorToolbar({ {checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"} - + )} + + diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 83b0b26..a6bfe9d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -13,7 +13,7 @@ import { validateMission, type SurveyRoute } from "../../api/missionPlanningApi"; -import { createDraft, promoteDraft } from "../../api/tguPlanApi"; +import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi"; import { parseUtc } from "../../utils/utcTime"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -111,6 +111,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const [serviceConflicts, setServiceConflicts] = useState({}); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); + const [canceling, setCanceling] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -416,6 +417,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setServiceConflicts({}); }, [selectedPlan]); + // Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик, + // не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409). + const draftPlanIdRef = useRef(undefined); + useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]); + useEffect(() => () => { + const stale = draftPlanIdRef.current; + if (stale) { + // best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён). + void abandonDraft(stale).catch(() => {}); + } + }, [selectedPlan]); + // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). useEffect(() => { const missionId = draftPlanId ?? selectedPlan?.planId; @@ -655,6 +668,28 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; + // «Отмена» = отказ от черновика (Ф6): удаляет DRAFT и клон-миссию на сервере, возвращает + // редактор к живому плану (read-only) и сбрасывает локальные правки. + const cancelDraft = async () => { + if (!draftPlanId) return; + setCanceling(true); + setApplyError(undefined); + setNotice(undefined); + try { + await abandonDraft(draftPlanId); + setDraftPlanId(undefined); + setServiceConflicts({}); + setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); + setSelectedWorkId(undefined); + setRightMode("none"); + setNotice("Черновик отменён — правки отброшены."); + } catch (error) { + setApplyError(error instanceof Error ? error.message : "Не удалось отменить черновик."); + } finally { + setCanceling(false); + } + }; + // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3, // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые // (planModeId нет) — только локально. @@ -745,6 +780,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, addedCount={addedCount} dirty={dirty} applying={applying} + canceling={canceling} windowLabel={formatDuration(window.toMs - window.fromMs)} rightMode={rightMode} onSetRightMode={(mode) => { @@ -757,6 +793,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onRedo={() => setHistory((current) => redo(current))} onCheck={runCheck} onApply={applyDraft} + onCancel={cancelDraft} onZoom={zoom} onPan={pan} /> diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 322d3e8..48e4ac6 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -1,6 +1,8 @@ package space.nstart.pcp_tgu_service.controller /** Содержит методы API для чтения текущих планов, создания черновика и ручной выдачи плана. */ +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -13,6 +15,7 @@ import space.nstart.pcp_tgu_service.dto.CreateDraftResponse import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.service.CreateDraftUseCase +import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker import space.nstart.pcp_tgu_service.service.PlanQueryService import java.util.UUID @@ -22,7 +25,8 @@ import java.util.UUID class PlanController( private val planQueryService: PlanQueryService, private val issueDuePlanWorker: IssueDuePlanWorker, - private val createDraftUseCase: CreateDraftUseCase + private val createDraftUseCase: CreateDraftUseCase, + private val deleteDraftUseCase: DeleteDraftUseCase ) { /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @@ -70,4 +74,14 @@ class PlanController( @PostMapping("/{draftPlanId}/promote") fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse = issueDuePlanWorker.promoteDraftNow(draftPlanId) + + /** + * Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию, снимая блокировку «один + * открытый черновик на базу». 404 — плана нет; 422 — план не DRAFT (живые планы не трогаем). + */ + @DeleteMapping("/{draftPlanId}") + fun deleteDraft(@PathVariable draftPlanId: UUID): ResponseEntity { + deleteDraftUseCase.deleteDraft(draftPlanId) + return ResponseEntity.noContent().build() + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt new file mode 100644 index 0000000..81dba61 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt @@ -0,0 +1,57 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.util.UUID + +/** + * Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию. + * + * Снимает блокировку «один открытый черновик на базу» ([CreateDraftUseCase]) и подчищает + * осиротевший клон в mission-planing, если оператор начал правку, но не применил её. + * + * Порядок: сначала удаляем DRAFT-строку (локально, транзакционно), затем best-effort удаляем + * клон-миссию. При сбое удаления миссии остаётся лишь осиротевший клон (безвреден, GC позже), но + * блокирующей DRAFT-строки уже нет — оператор может сразу создать новый черновик. Обратный порядок + * рисковал бы DRAFT-строкой, ссылающейся на удалённую миссию (вечный 409 + 404 на правках). + * + * Удалять разрешено только статус DRAFT — живые/выданные планы этот путь не трогает (422). + */ +@Service +class DeleteDraftUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val missionPlaningClient: MissionPlaningClient +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional + fun deleteDraft(draftPlanId: UUID) { + val plan = plannedPlanRepository.findById(draftPlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $draftPlanId not found") } + if (plan.status != PlannedPlanStatus.DRAFT) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $draftPlanId не является черновиком (status=${plan.status}) — удалить можно только DRAFT" + ) + } + plannedPlanRepository.delete(plan) + log.info("Черновик {} удалён (отказ от правок)", draftPlanId) + + // Клон миссии: planId == missionId. Best-effort — сбой удаления клона не откатывает удаление + // DRAFT-строки (блокирующей строки уже нет; осиротевший клон безвреден, GC позже). + try { + missionPlaningClient.deleteMission(draftPlanId) + } catch (ex: Exception) { + log.error( + "Черновик {} удалён, но клон миссии не удалось удалить — остался осиротевший клон", + draftPlanId, ex + ) + } + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt new file mode 100644 index 0000000..166893b --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt @@ -0,0 +1,91 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +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.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class DeleteDraftUseCaseTest { + + private val store = linkedMapOf() + private val repo = mock(PlannedPlanRepository::class.java).also { repo -> + `when`(repo.findById(anyUuid())).thenAnswer { inv -> Optional.ofNullable(store[inv.arguments[0]]) } + doAnswer { inv -> store.remove((inv.arguments[0] as PlannedPlanEntity).planId); null } + .`when`(repo).delete(anyNonNull()) + } + private val client = mock(MissionPlaningClient::class.java) + private val useCase = DeleteDraftUseCase(repo, client) + + @Test + fun `deletes the DRAFT row and its cloned mission`() { + val draft = plan(PlannedPlanStatus.DRAFT) + store[draft.planId] = draft + + useCase.deleteDraft(draft.planId) + + assertFalse(store.containsKey(draft.planId)) + verify(client).deleteMission(draft.planId) + } + + @Test + fun `unknown plan yields 404`() { + val ex = assertThrows { useCase.deleteDraft(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + verify(client, never()).deleteMission(anyUuid()) + } + + @Test + fun `refuses to delete a non-DRAFT plan with 422 and touches nothing`() { + val live = plan(PlannedPlanStatus.AWAITING_LAYIN) + store[live.planId] = live + + val ex = assertThrows { useCase.deleteDraft(live.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(store.containsKey(live.planId)) + verify(client, never()).deleteMission(anyUuid()) + } + + @Test + fun `a failed mission delete still removes the DRAFT row (best-effort)`() { + val draft = plan(PlannedPlanStatus.DRAFT) + store[draft.planId] = draft + doThrow(RuntimeException("mission-planing down")).`when`(client).deleteMission(anyUuid()) + + useCase.deleteDraft(draft.planId) + + assertFalse(store.containsKey(draft.planId)) + } + + private fun plan(status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = "25544", + startTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(3), + endTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(4), + kppId = "KPP-1", + status = status + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +}