tgu: DELETE /plans/{draftPlanId} — отказ от черновика (бэкенд + кнопка «Отмена» и авто-отмена в редакторе)

This commit is contained in:
Дмитрий Соловьев
2026-06-03 13:59:52 +03:00
parent ec67d9d83d
commit 2a841fc7b3
7 changed files with 236 additions and 4 deletions
@@ -137,7 +137,7 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
- `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку. - `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку.
- `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ. - `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ.
2. `POST /api/plans/{planId}/issue`**боевой** эндпоинт ручной выдачи (поднять из `TestTguController` в `PlanController`), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную». 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`. 4. Цепочка/откат: переиспользуем `base_plan_id`/`chain_version`; восстановление версии = `draft(from=Pk)``issue`.
--- ---
@@ -60,6 +60,24 @@ export async function promoteDraft(draftPlanId: string): Promise<PromoteDraftRes
); );
} }
/**
* Отказ от черновика (pcp-tgu-service, DELETE /api/plans/{draftPlanId}): удаляет DRAFT и его
* клон-миссию, снимая блокировку «один открытый черновик на базу». 404 — нет; 422 — не DRAFT.
*/
export async function abandonDraft(draftPlanId: string): Promise<void> {
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<T>(url: string, body: unknown, fallbackMessage: string): Promise<T> { async function postJson<T>(url: string, body: unknown, fallbackMessage: string): Promise<T> {
let response: Response; let response: Response;
try { try {
@@ -12,6 +12,7 @@ type EditorToolbarProps = {
addedCount: number; addedCount: number;
dirty: boolean; dirty: boolean;
applying: boolean; applying: boolean;
canceling: boolean;
windowLabel: string; windowLabel: string;
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo"; rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
onSetRightMode: (mode: "shoot" | "downlink") => void; onSetRightMode: (mode: "shoot" | "downlink") => void;
@@ -19,6 +20,7 @@ type EditorToolbarProps = {
onRedo: () => void; onRedo: () => void;
onCheck: () => void; onCheck: () => void;
onApply: () => void; onApply: () => void;
onCancel: () => void;
onZoom: (factor: number) => void; onZoom: (factor: number) => void;
onPan: (factor: number) => void; onPan: (factor: number) => void;
}; };
@@ -34,6 +36,7 @@ export function EditorToolbar({
addedCount, addedCount,
dirty, dirty,
applying, applying,
canceling,
windowLabel, windowLabel,
rightMode, rightMode,
onSetRightMode, onSetRightMode,
@@ -41,6 +44,7 @@ export function EditorToolbar({
onRedo, onRedo,
onCheck, onCheck,
onApply, onApply,
onCancel,
onZoom, onZoom,
onPan onPan
}: EditorToolbarProps) { }: EditorToolbarProps) {
@@ -107,7 +111,18 @@ export function EditorToolbar({
{checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"} {checking ? "Проверка..." : conflictCount > 0 ? `Конфликты · ${conflictCount}` : "Проверить"}
</button> </button>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying}> {dirty && (
<button
className="tgu-button tgu-button--reject"
type="button"
onClick={onCancel}
disabled={canceling || applying}
>
{canceling ? "Отмена..." : "Отмена"}
</button>
)}
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={!dirty || applying || canceling}>
{applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`} {applying ? "Применение..." : `Применить${addedCount > 0 ? ` · +${addedCount}` : ""}`}
</button> </button>
</div> </div>
@@ -13,7 +13,7 @@ import {
validateMission, validateMission,
type SurveyRoute type SurveyRoute
} from "../../api/missionPlanningApi"; } from "../../api/missionPlanningApi";
import { createDraft, promoteDraft } from "../../api/tguPlanApi"; import { abandonDraft, 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";
@@ -111,6 +111,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({}); const [serviceConflicts, setServiceConflicts] = useState<EditorConflictMap>({});
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
const [canceling, setCanceling] = 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());
@@ -416,6 +417,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setServiceConflicts({}); setServiceConflicts({});
}, [selectedPlan]); }, [selectedPlan]);
// Зеркало draftPlanId в ref — чтобы при уходе/смене плана отменить незавершённый черновик,
// не оставляя осиротевший DRAFT (он бы заблокировал createDraft по той же базе на 409).
const draftPlanIdRef = useRef<string | undefined>(undefined);
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
useEffect(() => () => {
const stale = draftPlanIdRef.current;
if (stale) {
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
void abandonDraft(stale).catch(() => {});
}
}, [selectedPlan]);
// Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью). // Источник истины для включений — DRAFT, если он уже создан, иначе живой план (read-only превью).
useEffect(() => { useEffect(() => {
const missionId = draftPlanId ?? selectedPlan?.planId; 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, // Удаление включения: персистнутые режимы (с planModeId) удаляются в БД черновика (Ф3,
// с каскадами drop↔survey), затем убираются из локального представления. Несохранённые // с каскадами drop↔survey), затем убираются из локального представления. Несохранённые
// (planModeId нет) — только локально. // (planModeId нет) — только локально.
@@ -745,6 +780,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
addedCount={addedCount} addedCount={addedCount}
dirty={dirty} dirty={dirty}
applying={applying} applying={applying}
canceling={canceling}
windowLabel={formatDuration(window.toMs - window.fromMs)} windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode} rightMode={rightMode}
onSetRightMode={(mode) => { onSetRightMode={(mode) => {
@@ -757,6 +793,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
onRedo={() => setHistory((current) => redo(current))} onRedo={() => setHistory((current) => redo(current))}
onCheck={runCheck} onCheck={runCheck}
onApply={applyDraft} onApply={applyDraft}
onCancel={cancelDraft}
onZoom={zoom} onZoom={zoom}
onPan={pan} onPan={pan}
/> />
@@ -1,6 +1,8 @@
package space.nstart.pcp_tgu_service.controller package space.nstart.pcp_tgu_service.controller
/** Содержит методы API для чтения текущих планов, создания черновика и ручной выдачи плана. */ /** Содержит методы 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.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping 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.PlanResponse
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
import space.nstart.pcp_tgu_service.service.CreateDraftUseCase import space.nstart.pcp_tgu_service.service.CreateDraftUseCase
import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
import space.nstart.pcp_tgu_service.service.PlanQueryService import space.nstart.pcp_tgu_service.service.PlanQueryService
import java.util.UUID import java.util.UUID
@@ -22,7 +25,8 @@ import java.util.UUID
class PlanController( class PlanController(
private val planQueryService: PlanQueryService, private val planQueryService: PlanQueryService,
private val issueDuePlanWorker: IssueDuePlanWorker, private val issueDuePlanWorker: IssueDuePlanWorker,
private val createDraftUseCase: CreateDraftUseCase private val createDraftUseCase: CreateDraftUseCase,
private val deleteDraftUseCase: DeleteDraftUseCase
) { ) {
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
@@ -70,4 +74,14 @@ class PlanController(
@PostMapping("/{draftPlanId}/promote") @PostMapping("/{draftPlanId}/promote")
fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse = fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse =
issueDuePlanWorker.promoteDraftNow(draftPlanId) issueDuePlanWorker.promoteDraftNow(draftPlanId)
/**
* Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию, снимая блокировку «один
* открытый черновик на базу». 404 — плана нет; 422 — план не DRAFT (живые планы не трогаем).
*/
@DeleteMapping("/{draftPlanId}")
fun deleteDraft(@PathVariable draftPlanId: UUID): ResponseEntity<Void> {
deleteDraftUseCase.deleteDraft(draftPlanId)
return ResponseEntity.noContent().build()
}
} }
@@ -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
)
}
}
}
@@ -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<UUID, PlannedPlanEntity>()
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<ResponseStatusException> { 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<ResponseStatusException> { 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 <T> anyNonNull(): T { ArgumentMatchers.any<T>(); return null as T }
private fun anyUuid(): UUID { ArgumentMatchers.any<UUID>(); return UUID.randomUUID() }
}