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
@@ -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<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() }
}