pcp-tgu: «снять с учёта» терминально-негативный план (dismissed_at)

Оператор деактивирует отменённый/неудавшийся план, чтобы он не считался
проблемой и не красил индикатор КА. Косметико-статистический флаг
dismissed_at: статус плана и состояние цепочки не трогает, обратим.

- planned_plan.dismissed_at (V14) + поле в сущности и PlanResponse;
- DismissPlanUseCase.dismiss/restore с гардом на терминально-негативные
  статусы (REJECTED, START_AMBIGUOUS, LAYIN_FAILED, NOT_ACCEPTED,
  SUPERSEDED), идемпотентно; эндпоинты POST /api/plans/{id}/dismiss|restore;
- BFF (pcp-ui-service): проброс dismissedAt в TguPlanResponse и
  /api/tgu-planning/plans/{id}/dismiss|restore;
- тест DismissPlanUseCaseTest; починен предсуществующий TguPlanningServiceTest
  (мок-JSON не отдавал projected — WebClient падал на null→boolean).
This commit is contained in:
Дмитрий Соловьев
2026-06-04 16:59:13 +03:00
parent fae57e957e
commit e6f1219c9c
10 changed files with 280 additions and 4 deletions
@@ -20,6 +20,7 @@ import space.nstart.pcp_tgu_service.dto.ZrvWindowResponse
import space.nstart.pcp_tgu_service.service.CreateChainPlanUseCase import space.nstart.pcp_tgu_service.service.CreateChainPlanUseCase
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.DeleteDraftUseCase
import space.nstart.pcp_tgu_service.service.DismissPlanUseCase
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
import space.nstart.pcp_tgu_service.service.PlanProjectionService import space.nstart.pcp_tgu_service.service.PlanProjectionService
import space.nstart.pcp_tgu_service.service.PlanQueryService import space.nstart.pcp_tgu_service.service.PlanQueryService
@@ -32,6 +33,7 @@ class PlanController(
private val issueDuePlanWorker: IssueDuePlanWorker, private val issueDuePlanWorker: IssueDuePlanWorker,
private val createDraftUseCase: CreateDraftUseCase, private val createDraftUseCase: CreateDraftUseCase,
private val deleteDraftUseCase: DeleteDraftUseCase, private val deleteDraftUseCase: DeleteDraftUseCase,
private val dismissPlanUseCase: DismissPlanUseCase,
private val createChainPlanUseCase: CreateChainPlanUseCase, private val createChainPlanUseCase: CreateChainPlanUseCase,
private val planProjectionService: PlanProjectionService private val planProjectionService: PlanProjectionService
) { ) {
@@ -118,4 +120,22 @@ class PlanController(
deleteDraftUseCase.deleteDraft(draftPlanId) deleteDraftUseCase.deleteDraft(draftPlanId)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
/**
* «Снять с учёта» терминально-негативный план: проставляет `dismissedAt`, чтобы он перестал
* считаться проблемой и красить индикатор КА. Статус и цепочку не трогает.
* 404 — плана нет; 422 — статус не терминально-негативный.
*/
@PostMapping("/{planId}/dismiss")
fun dismissPlan(@PathVariable planId: UUID): ResponseEntity<Void> {
dismissPlanUseCase.dismiss(planId)
return ResponseEntity.noContent().build()
}
/** «Вернуть на учёт»: снимает `dismissedAt`. 404 — плана нет. */
@PostMapping("/{planId}/restore")
fun restorePlan(@PathVariable planId: UUID): ResponseEntity<Void> {
dismissPlanUseCase.restore(planId)
return ResponseEntity.noContent().build()
}
} }
@@ -45,7 +45,13 @@ data class PlanResponse(
* У проекции предшественник не хранится, но цепочку она продолжает — фронт связывает её * У проекции предшественник не хранится, но цепочку она продолжает — фронт связывает её
* соседние звенья по времени, поэтому начало цепочки определяется ТОЛЬКО для persisted. * соседние звенья по времени, поэтому начало цепочки определяется ТОЛЬКО для persisted.
*/ */
val projected: Boolean = false val projected: Boolean = false,
/**
* Время «снятия с учёта» (деактивации) терминально-негативного плана оператором. `null` — план
* на учёте. Фронт по этому полю гасит план и исключает его из индикатора проблем КА.
*/
val dismissedAt: LocalDateTime? = null
) { ) {
companion object { companion object {
@@ -62,7 +68,8 @@ data class PlanResponse(
kppId = plan.kppId, kppId = plan.kppId,
status = plan.status, status = plan.status,
basePlanId = plan.basePlanId, basePlanId = plan.basePlanId,
projected = false projected = false,
dismissedAt = plan.dismissedAt
) )
/** /**
@@ -47,6 +47,14 @@ class PlannedPlanEntity(
@Column(name = "accepted_at") @Column(name = "accepted_at")
var acceptedAt: LocalDateTime? = null, var acceptedAt: LocalDateTime? = null,
/**
* «Снято с учёта»: время деактивации терминально-негативного плана оператором. NULL — план на
* учёте. Косметико-статистический флаг ([space.nstart.pcp_tgu_service.service.DismissPlanUseCase]):
* не меняет статус и состояние цепочки, обратим.
*/
@Column(name = "dismissed_at")
var dismissedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(), var createdAt: LocalDateTime = LocalDateTime.now(),
@@ -0,0 +1,77 @@
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.repository.PlannedPlanRepository
import java.time.LocalDateTime
import java.util.UUID
/**
* «Снять с учёта» / «Вернуть на учёт» терминально-негативный план.
*
* Оператор деактивирует отменённый/неудавшийся план, чтобы он перестал считаться проблемой и
* красить индикатор КА. Это чисто косметико-статистический флаг (`dismissed_at`): статус плана
* и состояние цепочки ([SpacecraftPlanningStateEntity]) НЕ трогаются — план остаётся в истории
* со своим терминальным статусом, цепочка не возобновляется и не паузится. Операция обратима
* ([restore]).
*
* Снимать с учёта разрешено только терминально-негативные статусы ([DISMISSIBLE_STATUSES]). Живые
* выдачи, успешный LAID_IN, DRAFT и виртуальный PLANNED этот путь не трогает (422) — у них «снятие
* с учёта» не имеет смысла.
*/
@Service
class DismissPlanUseCase(
private val plannedPlanRepository: PlannedPlanRepository
) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional
fun dismiss(planId: UUID) {
val plan = plannedPlanRepository.findById(planId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $planId not found") }
if (plan.status !in DISMISSIBLE_STATUSES) {
throw ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY,
"План $planId в статусе ${plan.status} нельзя снять с учёта — только терминально-негативные " +
"($DISMISSIBLE_STATUSES)"
)
}
if (plan.dismissedAt != null) {
log.info("План {} уже снят с учёта ({}) — идемпотентно", planId, plan.dismissedAt)
return
}
plan.dismissedAt = LocalDateTime.now()
plan.updatedAt = LocalDateTime.now()
plannedPlanRepository.save(plan)
log.info("План {} (status={}) снят с учёта", planId, plan.status)
}
@Transactional
fun restore(planId: UUID) {
val plan = plannedPlanRepository.findById(planId)
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $planId not found") }
if (plan.dismissedAt == null) {
log.info("План {} уже на учёте — идемпотентно", planId)
return
}
plan.dismissedAt = null
plan.updatedAt = LocalDateTime.now()
plannedPlanRepository.save(plan)
log.info("План {} (status={}) возвращён на учёт", planId, plan.status)
}
private companion object {
/** Терминально-негативные статусы, которые оператор может снять с учёта. */
val DISMISSIBLE_STATUSES = setOf(
PlannedPlanStatus.REJECTED,
PlannedPlanStatus.START_AMBIGUOUS,
PlannedPlanStatus.LAYIN_FAILED,
PlannedPlanStatus.NOT_ACCEPTED,
PlannedPlanStatus.SUPERSEDED
)
}
}
@@ -0,0 +1,6 @@
-- «Снято с учёта»: оператор деактивирует терминально-негативный план (REJECTED, START_AMBIGUOUS,
-- LAYIN_FAILED, NOT_ACCEPTED, SUPERSEDED), чтобы он не считался проблемой и не красил индикатор КА.
-- Чисто косметико-статистический флаг: статус плана и состояние цепочки не трогает, обратим
-- (restore = dismissed_at -> NULL). NULL — план на учёте (как раньше).
ALTER TABLE planned_plan
ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMP NULL;
@@ -0,0 +1,126 @@
package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
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.mock
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.repository.PlannedPlanRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
class DismissPlanUseCaseTest {
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 -> (inv.arguments[0] as PlannedPlanEntity).also { store[it.planId] = it } }
.`when`(repo).save(anyNonNull())
}
private val useCase = DismissPlanUseCase(repo)
@Test
fun `dismiss stamps dismissedAt on a REJECTED plan without changing status`() {
val plan = plan(PlannedPlanStatus.REJECTED)
store[plan.planId] = plan
useCase.dismiss(plan.planId)
assertNotNull(store[plan.planId]?.dismissedAt)
assertEquals(PlannedPlanStatus.REJECTED, store[plan.planId]?.status)
}
@Test
fun `dismiss is allowed for every terminal-negative status`() {
for (status in listOf(
PlannedPlanStatus.REJECTED,
PlannedPlanStatus.START_AMBIGUOUS,
PlannedPlanStatus.LAYIN_FAILED,
PlannedPlanStatus.NOT_ACCEPTED,
PlannedPlanStatus.SUPERSEDED
)) {
val plan = plan(status)
store[plan.planId] = plan
useCase.dismiss(plan.planId)
assertNotNull(store[plan.planId]?.dismissedAt, "expected $status to be dismissible")
}
}
@Test
fun `dismiss refuses a live or successful plan with 422`() {
for (status in listOf(
PlannedPlanStatus.DRAFT,
PlannedPlanStatus.ISSUING,
PlannedPlanStatus.WAITING_DECISION,
PlannedPlanStatus.AWAITING_LAYIN,
PlannedPlanStatus.LAID_IN
)) {
val plan = plan(status)
store[plan.planId] = plan
val ex = assertThrows<ResponseStatusException> { useCase.dismiss(plan.planId) }
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode, "expected $status to be refused")
assertNull(store[plan.planId]?.dismissedAt)
}
}
@Test
fun `dismiss of an unknown plan yields 404`() {
val ex = assertThrows<ResponseStatusException> { useCase.dismiss(UUID.randomUUID()) }
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
@Test
fun `dismiss is idempotent — second call does not re-save`() {
val plan = plan(PlannedPlanStatus.REJECTED)
store[plan.planId] = plan
useCase.dismiss(plan.planId)
val firstStamp = store[plan.planId]?.dismissedAt
useCase.dismiss(plan.planId)
// повторный вызов не трогает запись (save вызван ровно один раз)
verify(repo, org.mockito.Mockito.times(1)).save(anyNonNull())
assertEquals(firstStamp, store[plan.planId]?.dismissedAt)
}
@Test
fun `restore clears dismissedAt`() {
val plan = plan(PlannedPlanStatus.REJECTED).also { it.dismissedAt = LocalDateTime.now() }
store[plan.planId] = plan
useCase.restore(plan.planId)
assertNull(store[plan.planId]?.dismissedAt)
assertEquals(PlannedPlanStatus.REJECTED, store[plan.planId]?.status)
}
@Test
fun `restore of an unknown plan yields 404`() {
val ex = assertThrows<ResponseStatusException> { useCase.restore(UUID.randomUUID()) }
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
private fun plan(status: PlannedPlanStatus): PlannedPlanEntity =
PlannedPlanEntity(
planId = UUID.randomUUID(),
spacecraftId = "25544",
startTime = LocalDateTime.now().plusHours(3),
endTime = LocalDateTime.now().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() }
}
@@ -47,6 +47,14 @@ class TguPlanningRestController(
@RequestParam(required = false) reason: String? @RequestParam(required = false) reason: String?
) = tguPlanningService.sendDecision(planId, decision, reason) ) = tguPlanningService.sendDecision(planId, decision, reason)
/** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */
@PostMapping("/plans/{planId}/dismiss")
fun dismissPlan(@PathVariable planId: UUID) = tguPlanningService.dismissPlan(planId)
/** «Вернуть на учёт» план. */
@PostMapping("/plans/{planId}/restore")
fun restorePlan(@PathVariable planId: UUID) = tguPlanningService.restorePlan(planId)
/** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */ /** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */
@PostMapping("/plans/{planId}/confirm-layin") @PostMapping("/plans/{planId}/confirm-layin")
fun confirmLayin( fun confirmLayin(
@@ -30,7 +30,9 @@ data class TguPlanResponse(
/** Предшественник в цепочке (`null` — старт без предыстории). Проксируется из pcp-tgu-service. */ /** Предшественник в цепочке (`null` — старт без предыстории). Проксируется из pcp-tgu-service. */
val basePlanId: UUID? = null, val basePlanId: UUID? = null,
/** `true` — проекция будущего (не persisted). Проксируется из pcp-tgu-service. */ /** `true` — проекция будущего (не persisted). Проксируется из pcp-tgu-service. */
val projected: Boolean = false val projected: Boolean = false,
/** Время «снятия с учёта» (`null` — план на учёте). Проксируется из pcp-tgu-service. */
val dismissedAt: LocalDateTime? = null
) )
enum class TguPlanDecision { enum class TguPlanDecision {
@@ -87,6 +87,28 @@ class TguPlanningService(webClientBuilderProvider: ObjectProvider<WebClient.Buil
throw error throw error
} }
/** «Снять с учёта» терминально-негативный план (pcp-tgu-service, реальный API, не тест-контроллер). */
fun dismissPlan(planId: UUID) {
webClientBuilder.build()
.post()
.uri(buildUri("/api/plans/{planId}/dismiss") { builder -> builder.buildAndExpand(planId) })
.retrieve()
.onStatus({ status -> status.isError }, ::mapReadError)
.bodyToMono(Void::class.java)
.block()
}
/** «Вернуть на учёт» план (pcp-tgu-service). */
fun restorePlan(planId: UUID) {
webClientBuilder.build()
.post()
.uri(buildUri("/api/plans/{planId}/restore") { builder -> builder.buildAndExpand(planId) })
.retrieve()
.onStatus({ status -> status.isError }, ::mapReadError)
.bodyToMono(Void::class.java)
.block()
}
fun confirmLayin(planId: UUID, reason: String?) { fun confirmLayin(planId: UUID, reason: String?) {
runCatching { runCatching {
webClientBuilder.build() webClientBuilder.build()
@@ -116,7 +116,7 @@ class TguPlanningServiceTest {
exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond( exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond(
exchange, exchange,
200, 200,
"""[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00Z","endTime":"2026-05-29T10:15:00Z","kppId":"KPP-1","status":"WAITING_DECISION"}]""" """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00Z","endTime":"2026-05-29T10:15:00Z","kppId":"KPP-1","status":"WAITING_DECISION","basePlanId":null,"projected":false,"dismissedAt":null}]"""
) )
exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond( exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond(
exchange, exchange,