diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 87862b6..0de8b07 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -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 { + dismissPlanUseCase.dismiss(planId) + return ResponseEntity.noContent().build() + } + + /** «Вернуть на учёт»: снимает `dismissedAt`. 404 — плана нет. */ + @PostMapping("/{planId}/restore") + fun restorePlan(@PathVariable planId: UUID): ResponseEntity { + dismissPlanUseCase.restore(planId) + return ResponseEntity.noContent().build() + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index f7e3fd3..4aebbef 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -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 ) /** diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt index e96a5e6..5e03805 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -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(), diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCase.kt new file mode 100644 index 0000000..43666da --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCase.kt @@ -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 + ) + } +} diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V14__planned_plan_dismissed_at.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V14__planned_plan_dismissed_at.sql new file mode 100644 index 0000000..97e666d --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V14__planned_plan_dismissed_at.sql @@ -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; diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCaseTest.kt new file mode 100644 index 0000000..5660e71 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCaseTest.kt @@ -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() + 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 { 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 { 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 { 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 anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt index 7f92243..5f5e8af 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt @@ -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( diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index 65fd2f9..400076e 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -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 { diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt index 5e47a1e..d4c9d2d 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt @@ -87,6 +87,28 @@ class TguPlanningService(webClientBuilderProvider: ObjectProvider 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() diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt index 0fc5d44..3fdd6df 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt @@ -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,