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:
+20
@@ -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.CreateDraftUseCase
|
||||
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.PlanProjectionService
|
||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||
@@ -32,6 +33,7 @@ class PlanController(
|
||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
||||
private val createDraftUseCase: CreateDraftUseCase,
|
||||
private val deleteDraftUseCase: DeleteDraftUseCase,
|
||||
private val dismissPlanUseCase: DismissPlanUseCase,
|
||||
private val createChainPlanUseCase: CreateChainPlanUseCase,
|
||||
private val planProjectionService: PlanProjectionService
|
||||
) {
|
||||
@@ -118,4 +120,22 @@ class PlanController(
|
||||
deleteDraftUseCase.deleteDraft(draftPlanId)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -45,7 +45,13 @@ data class PlanResponse(
|
||||
* У проекции предшественник не хранится, но цепочку она продолжает — фронт связывает её
|
||||
* соседние звенья по времени, поэтому начало цепочки определяется ТОЛЬКО для persisted.
|
||||
*/
|
||||
val projected: Boolean = false
|
||||
val projected: Boolean = false,
|
||||
|
||||
/**
|
||||
* Время «снятия с учёта» (деактивации) терминально-негативного плана оператором. `null` — план
|
||||
* на учёте. Фронт по этому полю гасит план и исключает его из индикатора проблем КА.
|
||||
*/
|
||||
val dismissedAt: LocalDateTime? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -62,7 +68,8 @@ data class PlanResponse(
|
||||
kppId = plan.kppId,
|
||||
status = plan.status,
|
||||
basePlanId = plan.basePlanId,
|
||||
projected = false
|
||||
projected = false,
|
||||
dismissedAt = plan.dismissedAt
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+8
@@ -47,6 +47,14 @@ class PlannedPlanEntity(
|
||||
@Column(name = "accepted_at")
|
||||
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)
|
||||
var createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
|
||||
|
||||
+77
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
+126
@@ -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() }
|
||||
}
|
||||
+8
@@ -47,6 +47,14 @@ class TguPlanningRestController(
|
||||
@RequestParam(required = false) reason: String?
|
||||
) = 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). */
|
||||
@PostMapping("/plans/{planId}/confirm-layin")
|
||||
fun confirmLayin(
|
||||
|
||||
+3
-1
@@ -30,7 +30,9 @@ data class TguPlanResponse(
|
||||
/** Предшественник в цепочке (`null` — старт без предыстории). Проксируется из pcp-tgu-service. */
|
||||
val basePlanId: UUID? = null,
|
||||
/** `true` — проекция будущего (не persisted). Проксируется из pcp-tgu-service. */
|
||||
val projected: Boolean = false
|
||||
val projected: Boolean = false,
|
||||
/** Время «снятия с учёта» (`null` — план на учёте). Проксируется из pcp-tgu-service. */
|
||||
val dismissedAt: LocalDateTime? = null
|
||||
)
|
||||
|
||||
enum class TguPlanDecision {
|
||||
|
||||
+22
@@ -87,6 +87,28 @@ class TguPlanningService(webClientBuilderProvider: ObjectProvider<WebClient.Buil
|
||||
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?) {
|
||||
runCatching {
|
||||
webClientBuilder.build()
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class TguPlanningServiceTest {
|
||||
exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond(
|
||||
exchange,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user