tgu: DELETE /plans/{draftPlanId} — отказ от черновика (бэкенд + кнопка «Отмена» и авто-отмена в редакторе)
This commit is contained in:
+15
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -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() }
|
||||
}
|
||||
Reference in New Issue
Block a user