diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 6076925..637edf2 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -13,7 +13,8 @@ spring: camunda: client: mode: self-managed - grpc-address: http://camunda.k8s.265.nstart.local:30901 + # grpc-address: http://camunda.k8s.265.nstart.local:30901 + grpc-address: http://192.168.60.201:26500 auth: method: none prefer-rest-over-grpc: false @@ -33,16 +34,22 @@ planning: in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 + min-reissue-lead-minutes: 5 + worker-lock-ttl-seconds: 300 topics: - spacecraft-points-update: pcp.tgu.kpp-update.v1 + visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 + satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + +tgu: + test-controller: + enabled: false classifier: platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records platforms-cache-ttl: 15m allowed-platform-statuses: - OPERATIONAL - - STANDBY --- spring: diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt index 6db9ea5..61d807b 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/TguApplication.kt @@ -8,7 +8,12 @@ import org.springframework.boot.runApplication @SpringBootApplication -@Deployment(resources = ["classpath:BPMN/createSatelliteMission.bpmn"]) +@Deployment( + resources = [ + "classpath:BPMN/createSatelliteMission.bpmn", + "classpath:BPMN/updateAcceptedPlanSlots.bpmn" + ] +) @ConfigurationPropertiesScan class TguApplication diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index dcd9a3f..45a22d7 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -30,5 +30,11 @@ data class PlanningProperties( val externalUpdateFixedDelayMs: Long, /** Интервал в миллисекундах между проверками, пора ли автозапускать Camunda-процесс по плану. */ - val notificationCheckFixedDelayMs: Long + val notificationCheckFixedDelayMs: Long, + + /** Минимальный запас времени для повторной выдачи после REJECTED. */ + val minReissueLeadMinutes: Long, + + /** TTL lease'а worker job claim в секундах. */ + val workerLockTtlSeconds: Long ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt index f272c06..f6d38db 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt @@ -5,5 +5,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "topics") data class TopicsProperties( - val spacecraftPointsUpdate: String + /** Topic-сигнал о необходимости перечитать окна видимости у баллистики. */ + val visibilityWindowsChanged: String, + + /** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */ + val satellitePlanDecision: String ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/ExternalPointsController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/ExternalPointsController.kt deleted file mode 100644 index a7a06cd..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/ExternalPointsController.kt +++ /dev/null @@ -1,46 +0,0 @@ -package space.nstart.pcp_tgu_service.controller - -/** Содержит диагностические методы API для чтения окон видимости из внешнего API. */ -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.format.annotation.DateTimeFormat -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers -import java.time.LocalDateTime - -/** - * REST-контроллер для ручной проверки данных внешнего API по КА. - */ -@RestController -@RequestMapping("/api/external-points") -@Tag(name = "External Points", description = "Проверка данных окон видимости из внешнего API") -class ExternalPointsController( - private val externalPointsClient: ExternalPointsClient -) { - - /** - * Возвращает окна видимости по одному КА за указанный интервал. - */ - /** Загружает окна видимости для одного КА в указанном интервале. */ - @GetMapping("/{satelliteId}") - @Operation(summary = "Получить окна видимости по одному космическому аппарату") - fun getSpacecraftPoints( - @PathVariable satelliteId: Long, - @RequestParam("time_start") - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - timeStart: LocalDateTime, - @RequestParam("time_stop") - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - timeStop: LocalDateTime - ): Mono = - Mono.fromCallable { - externalPointsClient.fetchSpacecraftPoints(satelliteId, timeStart, timeStop) - }.subscribeOn(Schedulers.boundedElastic()) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanConfirmationController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanConfirmationController.kt deleted file mode 100644 index 88bfc8c..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanConfirmationController.kt +++ /dev/null @@ -1,44 +0,0 @@ -package space.nstart.pcp_tgu_service.controller - -/** Содержит методы API для просмотра и фиксации подтверждения плана. */ -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers -import space.nstart.pcp_tgu_service.dto.PlanConfirmationRequest -import space.nstart.pcp_tgu_service.dto.TrackedPlanResponse -import space.nstart.pcp_tgu_service.service.PlanConfirmationService -import space.nstart.pcp_tgu_service.service.TrackedPlanService -import java.util.UUID - -@RestController -@RequestMapping("/api/plan-confirmations") -@Tag(name = "Plan Confirmations", description = "Подтверждение и просмотр статуса planId") -class PlanConfirmationController( - private val planConfirmationService: PlanConfirmationService, - private val trackedPlanService: TrackedPlanService -) { - - /** Возвращает состояние отслеживаемого плана по его идентификатору. */ - @GetMapping("/{planId}") - @Operation(summary = "Получить статус плана по planId") - fun getPlan(@PathVariable planId: UUID): Mono = - Mono.fromCallable { - TrackedPlanResponse.from(trackedPlanService.getByPlanId(planId)) - }.subscribeOn(Schedulers.boundedElastic()) - - /** Подтверждает план и только фиксирует этот факт в БД. */ - @PostMapping - @Operation(summary = "Подтвердить план по planId и сохранить подтверждение в БД") - fun confirmPlan(@Valid @RequestBody request: PlanConfirmationRequest): Mono = - Mono.fromCallable { - TrackedPlanResponse.from(planConfirmationService.confirm(request.planId)) - }.subscribeOn(Schedulers.boundedElastic()) -} 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 c0ab9f8..e199783 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 @@ -21,9 +21,7 @@ class PlanController( @GetMapping fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono> = Mono.fromCallable { - planQueryService.getAllPlans(historyDays).map { planView -> - PlanResponse.from(planView.plan, planView.status) - } + planQueryService.getAllPlans(historyDays).map(PlanResponse::from) }.subscribeOn(Schedulers.boundedElastic()) /** Возвращает цепочку планов для одного КА. */ @@ -33,8 +31,6 @@ class PlanController( @RequestParam(defaultValue = "7") historyDays: Long ): Mono> = Mono.fromCallable { - planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map { planView -> - PlanResponse.from(planView.plan, planView.status) - } + planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from) }.subscribeOn(Schedulers.boundedElastic()) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/SatelliteMissionController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/SatelliteMissionController.kt deleted file mode 100644 index a41cf47..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/SatelliteMissionController.kt +++ /dev/null @@ -1,35 +0,0 @@ -package space.nstart.pcp_tgu_service.controller - -/** Содержит ручные endpoint'ы для запуска BPMN-процесса спутниковой миссии. */ -import space.nstart.pcp_tgu_service.dto.ProcessStartResponse -import space.nstart.pcp_tgu_service.service.SatelliteMissionProcessService -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.tags.Tag -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers - -/** - * REST-контроллер для ручного запуска BPMN-процесса спутниковой миссии. - */ -@RestController -@RequestMapping("/api/satellite-missions") -@Tag(name = "Satellite Missions", description = "Ручной запуск BPMN-процесса спутниковой миссии") -class SatelliteMissionController( - private val satelliteMissionProcessService: SatelliteMissionProcessService -) { - - /** - * Запускает процесс createSatelliteMission для аппарата по noradId. - */ - /** Запускает процесс миссии для аппарата, заданного через `noradId`. */ - @PostMapping("/{noradId}") - @Operation(summary = "Запустить процесс createSatelliteMission") - fun startMission(@PathVariable noradId: Long): Mono = - Mono.fromCallable { - satelliteMissionProcessService.startCreateSatelliteMissionManually(noradId) - }.subscribeOn(Schedulers.boundedElastic()) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt new file mode 100644 index 0000000..981d761 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt @@ -0,0 +1,95 @@ +package space.nstart.pcp_tgu_service.controller + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanResponse +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase +import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker +import space.nstart.pcp_tgu_service.service.PlanQueryService +import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService +import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase +import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService +import java.time.LocalDateTime +import java.util.UUID + +/** + * Test-only controller for manual TGU operations. Disabled by default. + */ +@ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true") +@RestController +@RequestMapping("/test/tgu") +class TestTguController( + private val visibilityRefreshJobService: VisibilityRefreshJobService, + private val planRecalculationJobService: PlanRecalculationJobService, + private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, + private val issueDuePlanWorker: IssueDuePlanWorker, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase, + private val externalPointsClient: ExternalPointsClient, + private val planQueryService: PlanQueryService +) { + /** Ставит refresh job для КА. */ + @PostMapping("/spacecraft/{spacecraftId}/refresh-visibility") + fun refreshVisibility( + @PathVariable spacecraftId: String, + @RequestParam(required = false) noradId: Long? + ): Mono = + Mono.fromRunnable { + visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") + }.subscribeOn(Schedulers.boundedElastic()) + + /** Ставит и сразу выполняет rebuild persisted schedule для КА. */ + @PostMapping("/spacecraft/{spacecraftId}/rebuild-plans") + fun rebuildPlans(@PathVariable spacecraftId: String): Mono = + Mono.fromRunnable { + planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER") + rebuildPlanScheduleUseCase.rebuild(spacecraftId) + }.subscribeOn(Schedulers.boundedElastic()) + + /** Принудительно выдаёт конкретный persisted plan через новую attempt. */ + @PostMapping("/plans/{planId}/issue") + fun issuePlan(@PathVariable planId: UUID): Mono = + Mono.fromCallable { + issueDuePlanWorker.issuePlanNow(planId) + }.subscribeOn(Schedulers.boundedElastic()) + + /** Инжектит decision event через тот же use case, что Kafka consumer. */ + @PostMapping("/plan-decisions") + fun handleDecision(@RequestBody message: PlanDecisionMessage): Mono = + Mono.fromRunnable { + handlePlanDecisionUseCase.handle(message) + }.subscribeOn(Schedulers.boundedElastic()) + + /** Загружает raw payload окон видимости из баллистики для диагностики. */ + @GetMapping("/external-points/{noradId}") + fun getExternalPoints( + @PathVariable noradId: Long, + @RequestParam("time_start") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + timeStart: LocalDateTime, + @RequestParam("time_stop") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + timeStop: LocalDateTime + ): Mono = + Mono.fromCallable { + externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) + }.subscribeOn(Schedulers.boundedElastic()) + + /** Возвращает persisted plan для проверки результата ручных операций. */ + @GetMapping("/plans/{planId}") + fun getPlan(@PathVariable planId: UUID): Mono = + Mono.fromCallable { + PlanResponse.from(planQueryService.getByPlanId(planId)) + }.subscribeOn(Schedulers.boundedElastic()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/CalculatedPlan.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/CalculatedPlan.kt new file mode 100644 index 0000000..297cb2c --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/CalculatedPlan.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp_tgu_service.domain + +import java.time.LocalDateTime + +/** + * Результат расчёта бизнес-плана без persistence-идентификатора. + * + * Один и тот же набор business fields должен маппиться на стабильный planId + * только при upsert в таблицу planned_plan. + */ +data class CalculatedPlan( + /** Идентификатор космического аппарата. */ + val spacecraftId: String, + + /** Время начала плана. */ + val startTime: LocalDateTime, + + /** Время окончания плана. */ + val endTime: LocalDateTime, + + /** Идентификатор стартового пункта закладки. */ + val kppId: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/Plan.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/Plan.kt deleted file mode 100644 index 5860b7e..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/Plan.kt +++ /dev/null @@ -1,28 +0,0 @@ -package space.nstart.pcp_tgu_service.domain - -import java.time.LocalDateTime -import java.util.UUID - -/** - * План космического аппарата. - */ -data class Plan( - - /** Идентификатор космического аппарата */ - val spacecraftId: String, - - /** Время начала плана */ - val startTime: LocalDateTime, - - /** Время окончания плана */ - val endTime: LocalDateTime, - - /** Идентификатор стартового пункта закладки */ - val kppId: String, - - /** UUID-идентификатор плана */ - val planId: UUID = UUID.randomUUID(), - - /** Было ли уже отправлено уведомление о предстоящем старте */ - val notified: Boolean = false -) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlanDecision.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlanDecision.kt new file mode 100644 index 0000000..ce0941e --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlanDecision.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp_tgu_service.domain + +/** + * Решение внешней системы по конкретной попытке выдачи плана. + */ +enum class PlanDecision { + ACCEPTED, + REJECTED +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt index b54b184..449f60d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/CreateSatelliteMissionVariables.kt @@ -4,9 +4,21 @@ package space.nstart.pcp_tgu_service.dto import java.time.LocalDateTime data class CreateSatelliteMissionVariables( + /** Идентификатор КА внутри TGU. */ val spacecraftId: String, + + /** Stable persisted planId бизнес-плана. */ val satellitePlanId: String, + + /** Идентификатор конкретной попытки выдачи плана. */ + val satellitePlanAttemptId: String, + + /** Время начала выдаваемого плана. */ val planStartTime: LocalDateTime, + + /** Время окончания выдаваемого плана. */ val planEndTime: LocalDateTime, + + /** Идентификатор стартового пункта закладки. */ val kppId: String ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanDecisionMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanDecisionMessage.kt new file mode 100644 index 0000000..52294a1 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanDecisionMessage.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp_tgu_service.dto + +import space.nstart.pcp_tgu_service.domain.PlanDecision +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие решения по конкретной попытке выдачи плана. + */ +data class PlanDecisionMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Идентификатор КА внутри TGU. */ + val spacecraftId: String, + + /** Stable persisted planId бизнес-плана. */ + val planId: UUID, + + /** Идентификатор конкретной попытки выдачи, по которой принято решение. */ + val attemptId: UUID, + + /** ACCEPTED или REJECTED. */ + val decision: PlanDecision, + + /** Время принятия решения внешней системой. */ + val decisionTime: LocalDateTime, + + /** Причина отказа или дополнительный комментарий. */ + val reason: String? +) 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 127126e..e705e51 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 @@ -1,7 +1,7 @@ package space.nstart.pcp_tgu_service.dto -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.time.LocalDateTime import java.util.UUID @@ -25,8 +25,8 @@ data class PlanResponse( /** Идентификатор пункта закладки */ val kppId: String, - /** Статус плана в жизненном цикле выдачи. */ - val status: TrackedPlanStatus + /** Статус persisted плана в жизненном цикле выдачи. */ + val status: PlannedPlanStatus ) { companion object { @@ -34,14 +34,14 @@ data class PlanResponse( /** * Преобразует доменную сущность плана в DTO ответа. */ - fun from(plan: Plan, status: TrackedPlanStatus): PlanResponse = + fun from(plan: PlannedPlanEntity): PlanResponse = PlanResponse( planId = plan.planId, spacecraftId = plan.spacecraftId, startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId, - status = status + status = plan.status ) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt index 962b870..72593c0 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/ProcessStartResponse.kt @@ -8,5 +8,6 @@ data class ProcessStartResponse( val processDefinitionKey: Long, val bpmnProcessId: String, val version: Int, - val planId: UUID + val planId: UUID, + val attemptId: UUID ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/TrackedPlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/TrackedPlanResponse.kt deleted file mode 100644 index d22b131..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/TrackedPlanResponse.kt +++ /dev/null @@ -1,33 +0,0 @@ -package space.nstart.pcp_tgu_service.dto - -/** REST DTO с сохранённым состоянием исполнения отслеживаемого плана. */ -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import java.time.LocalDateTime -import java.util.UUID - -data class TrackedPlanResponse( - val planId: UUID, - val spacecraftId: String, - val startTime: LocalDateTime, - val endTime: LocalDateTime, - val kppId: String, - val processInstanceKey: Long?, - val status: TrackedPlanStatus, - val confirmedAt: LocalDateTime? -) { - companion object { - /** Преобразует сущность отслеживаемого плана в DTO ответа API. */ - fun from(entity: TrackedPlanEntity): TrackedPlanResponse = - TrackedPlanResponse( - planId = entity.planId, - spacecraftId = entity.spacecraftId, - startTime = entity.startTime, - endTime = entity.endTime, - kppId = entity.kppId, - processInstanceKey = entity.processInstanceKey, - status = entity.status, - confirmedAt = entity.confirmedAt - ) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/VisibilityWindowsChangedMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/VisibilityWindowsChangedMessage.kt new file mode 100644 index 0000000..8f2a3d5 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/VisibilityWindowsChangedMessage.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-сигнал о том, что окна видимости КА нужно перечитать у баллистики. + */ +data class VisibilityWindowsChangedMessage( + /** Идентификатор события для трассировки producer-side события. */ + val eventId: UUID?, + + /** Идентификатор КА внутри TGU. */ + val spacecraftId: String, + + /** NORAD ID, если producer уже знает его. */ + val noradId: Long?, + + /** Время изменения окон на стороне источника события. */ + val changedAt: LocalDateTime? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt new file mode 100644 index 0000000..66d1cf5 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobEntity.kt @@ -0,0 +1,49 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * Persisted job на запуск короткого BPMN-процесса обновления слотов accepted plan. + */ +@Entity +@Table(name = "accepted_plan_slots_update_job") +class AcceptedPlanSlotsUpdateJobEntity( + @Id + @Column(name = "plan_id", nullable = false, updatable = false) + var planId: UUID, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: AcceptedPlanSlotsUpdateJobStatus = AcceptedPlanSlotsUpdateJobStatus.PENDING, + + @Column(name = "process_instance_key") + var processInstanceKey: Long? = null, + + @Column(name = "locked_by") + var lockedBy: String? = null, + + @Column(name = "locked_until") + var lockedUntil: LocalDateTime? = null, + + @Column(name = "attempts", nullable = false) + var attempts: Int = 0, + + @Column(name = "last_error", columnDefinition = "TEXT") + var lastError: String? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(planId = UUID.randomUUID()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobStatus.kt new file mode 100644 index 0000000..0bbebfd --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/AcceptedPlanSlotsUpdateJobStatus.kt @@ -0,0 +1,12 @@ +package space.nstart.pcp_tgu_service.entity + +/** + * Жизненный цикл job, которая запускает короткий BPMN updateAcceptedPlanSlots. + */ +enum class AcceptedPlanSlotsUpdateJobStatus { + PENDING, + RUNNING, + DONE, + FAILED, + AMBIGUOUS +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/JobStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/JobStatus.kt new file mode 100644 index 0000000..826c671 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/JobStatus.kt @@ -0,0 +1,11 @@ +package space.nstart.pcp_tgu_service.entity + +/** + * Жизненный цикл фоновой job, которая безопасно claim'ится несколькими pod через БД. + */ +enum class JobStatus { + PENDING, + RUNNING, + DONE, + FAILED +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt new file mode 100644 index 0000000..26cea9b --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptEntity.kt @@ -0,0 +1,55 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * Одна попытка выдачи persisted plan через один BPMN process instance. + */ +@Entity +@Table(name = "plan_issue_attempt") +class PlanIssueAttemptEntity( + @Id + @Column(name = "attempt_id", nullable = false, updatable = false) + var attemptId: UUID, + + @Column(name = "plan_id", nullable = false) + var planId: UUID, + + @Column(name = "attempt_no", nullable = false) + var attemptNo: Int, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: PlanIssueAttemptStatus = PlanIssueAttemptStatus.STARTING, + + @Column(name = "process_instance_key") + var processInstanceKey: Long? = null, + + @Column(name = "decision_event_id") + var decisionEventId: UUID? = null, + + @Column(name = "decision_reason", columnDefinition = "TEXT") + var decisionReason: String? = null, + + @Column(name = "started_at") + var startedAt: LocalDateTime? = null, + + @Column(name = "finished_at") + var finishedAt: LocalDateTime? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(attemptId = UUID.randomUUID(), planId = UUID.randomUUID(), attemptNo = 0) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptStatus.kt new file mode 100644 index 0000000..b115faa --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanIssueAttemptStatus.kt @@ -0,0 +1,13 @@ +package space.nstart.pcp_tgu_service.entity + +/** + * Жизненный цикл одной попытки выдачи persisted plan через BPMN. + */ +enum class PlanIssueAttemptStatus { + STARTING, + STARTED, + ACCEPTED, + REJECTED, + FAILED, + AMBIGUOUS +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt new file mode 100644 index 0000000..49b2918 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt @@ -0,0 +1,48 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +/** + * Job на перестроение persisted расписания планов одного КА. + */ +@Entity +@Table(name = "plan_recalculation_job") +class PlanRecalculationJobEntity( + @Id + @Column(name = "spacecraft_id", nullable = false, updatable = false) + var spacecraftId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: JobStatus = JobStatus.PENDING, + + @Column(name = "reason") + var reason: String? = null, + + @Column(name = "locked_by") + var lockedBy: String? = null, + + @Column(name = "locked_until") + var lockedUntil: LocalDateTime? = null, + + @Column(name = "attempts", nullable = false) + var attempts: Int = 0, + + @Column(name = "rerun_requested", nullable = false) + var rerunRequested: Boolean = false, + + @Column(name = "requested_at", nullable = false) + var requestedAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(spacecraftId = "") +} 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 new file mode 100644 index 0000000..43259aa --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -0,0 +1,67 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * Persisted business-plan с устойчивым planId по unique business key. + */ +@Entity +@Table(name = "planned_plan") +class PlannedPlanEntity( + @Id + @Column(name = "plan_id", nullable = false, updatable = false) + var planId: UUID, + + @Column(name = "spacecraft_id", nullable = false) + var spacecraftId: String, + + @Column(name = "start_time", nullable = false) + var startTime: LocalDateTime, + + @Column(name = "end_time", nullable = false) + var endTime: LocalDateTime, + + @Column(name = "kpp_id", nullable = false) + var kppId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: PlannedPlanStatus = PlannedPlanStatus.PLANNED, + + @Column(name = "issue_not_before") + var issueNotBefore: LocalDateTime? = null, + + @Column(name = "base_plan_id") + var basePlanId: UUID? = null, + + @Column(name = "chain_version", nullable = false) + var chainVersion: Long = 0, + + @Column(name = "accepted_at") + var acceptedAt: LocalDateTime? = null, + + @Column(name = "superseded_by_plan_id") + var supersededByPlanId: UUID? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this( + planId = UUID.randomUUID(), + spacecraftId = "", + startTime = LocalDateTime.MIN, + endTime = LocalDateTime.MIN, + kppId = "" + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt new file mode 100644 index 0000000..4c2b784 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp_tgu_service.entity + +/** + * Жизненный цикл persisted business-plan в расписании TGU. + */ +enum class PlannedPlanStatus { + PLANNED, + ISSUING, + WAITING_DECISION, + ACCEPTED, + REJECTED, + SUPERSEDED, + EXPIRED, + START_AMBIGUOUS +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt new file mode 100644 index 0000000..b594c13 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/ProcessedPlanDecisionEventEntity.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime +import java.util.UUID + +/** + * Dedup-запись Kafka decision event по eventId. + */ +@Entity +@Table(name = "processed_plan_decision_event") +class ProcessedPlanDecisionEventEntity( + @Id + @Column(name = "event_id", nullable = false, updatable = false) + var eventId: UUID, + + @Column(name = "received_at", nullable = false) + var receivedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(eventId = UUID.randomUUID()) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt index 0f0e602..ced8a80 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -18,6 +18,15 @@ class SpacecraftPlanningStateEntity( @Column(name = "current_base_plan_id") var currentBasePlanId: UUID? = null, + @Column(name = "active_plan_id") + var activePlanId: UUID? = null, + + @Column(name = "active_attempt_id") + var activeAttemptId: UUID? = null, + + @Column(name = "chain_version", nullable = false) + var chainVersion: Long = 0, + @Column(name = "updated_at", nullable = false) var updatedAt: LocalDateTime = LocalDateTime.now() ) { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt new file mode 100644 index 0000000..bd8cf2a --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftSnapshotEntity.kt @@ -0,0 +1,54 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +/** + * Persisted snapshot КА из НСИ, используемый как вход для refresh jobs. + */ +@Entity +@Table(name = "spacecraft_snapshot") +class SpacecraftSnapshotEntity( + @Id + @Column(name = "spacecraft_id", nullable = false, updatable = false) + var spacecraftId: String, + + @Column(name = "norad_id", nullable = false) + var noradId: Long, + + @Column(name = "nsi_id") + var nsiId: String? = null, + + @Column(name = "business_key") + var businessKey: String? = null, + + @Column(name = "name") + var name: String? = null, + + @Column(name = "status") + var status: String? = null, + + @Column(name = "mission") + var mission: String? = null, + + @Column(name = "valid_from") + var validFrom: LocalDateTime? = null, + + @Column(name = "valid_to") + var validTo: LocalDateTime? = null, + + @Column(name = "active", nullable = false) + var active: Boolean = true, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(spacecraftId = "", noradId = 0) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt new file mode 100644 index 0000000..9aaa543 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftVisibilitySnapshotEntity.kt @@ -0,0 +1,50 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +/** + * Полный raw snapshot окон видимости КА из баллистики. + * + * payloadJson является source of truth; derived поля нужны только для диагностики. + */ +@Entity +@Table(name = "spacecraft_visibility_snapshot") +class SpacecraftVisibilitySnapshotEntity( + @Id + @Column(name = "spacecraft_id", nullable = false, updatable = false) + var spacecraftId: String, + + @Column(name = "norad_id", nullable = false) + var noradId: Long, + + @Column(name = "payload_json", nullable = false, columnDefinition = "TEXT") + var payloadJson: String, + + @Column(name = "payload_hash", nullable = false) + var payloadHash: String, + + @Column(name = "fetched_at", nullable = false) + var fetchedAt: LocalDateTime, + + @Column(name = "visibility_from") + var visibilityFrom: LocalDateTime? = null, + + @Column(name = "visibility_to") + var visibilityTo: LocalDateTime? = null, + + @Column(name = "windows_count") + var windowsCount: Int? = null, + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(spacecraftId = "", noradId = 0, payloadJson = "", payloadHash = "", fetchedAt = LocalDateTime.MIN) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt new file mode 100644 index 0000000..272b2c5 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/VisibilityRefreshJobEntity.kt @@ -0,0 +1,51 @@ +package space.nstart.pcp_tgu_service.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +/** + * Job на перечитывание окон видимости одного КА у баллистики. + */ +@Entity +@Table(name = "visibility_refresh_job") +class VisibilityRefreshJobEntity( + @Id + @Column(name = "spacecraft_id", nullable = false, updatable = false) + var spacecraftId: String, + + @Column(name = "norad_id") + var noradId: Long? = null, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: JobStatus = JobStatus.PENDING, + + @Column(name = "reason") + var reason: String? = null, + + @Column(name = "locked_by") + var lockedBy: String? = null, + + @Column(name = "locked_until") + var lockedUntil: LocalDateTime? = null, + + @Column(name = "attempts", nullable = false) + var attempts: Int = 0, + + @Column(name = "rerun_requested", nullable = false) + var rerunRequested: Boolean = false, + + @Column(name = "requested_at", nullable = false) + var requestedAt: LocalDateTime = LocalDateTime.now(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() +) { + /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ + constructor() : this(spacecraftId = "") +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt index 9a32255..2e89f28 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt @@ -1,23 +1,17 @@ package space.nstart.pcp_tgu_service.integration.api -/** Вызывает внешнее API видимости и преобразует его ответ в доменные модели. */ +/** Вызывает внешнее API видимости и возвращает raw payload без потери неизвестных полей. */ import space.nstart.pcp_tgu_service.config.ExternalApiProperties import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.InsertionPoint -import space.nstart.pcp_tgu_service.domain.ObservationWindow import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto import space.nstart.pcp_tgu_service.service.PlatformService import org.slf4j.LoggerFactory import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClientException import org.springframework.web.reactive.function.client.WebClientResponseException -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeParseException import java.time.temporal.ChronoUnit /** @@ -27,11 +21,11 @@ import java.time.temporal.ChronoUnit class ExternalPointsClient( externalApiProperties: ExternalApiProperties, private val platformService: PlatformService, - private val planningProperties: PlanningProperties + private val planningProperties: PlanningProperties, + private val visibilityPayloadParser: VisibilityPayloadParser ) { private val log = LoggerFactory.getLogger(javaClass) - private val zoneId: ZoneId = ZoneId.systemDefault() private val webClient: WebClient = WebClient.builder() .baseUrl(externalApiProperties.baseUrl) @@ -83,8 +77,19 @@ class ExternalPointsClient( satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime - ): SpacecraftPoints { - val response = webClient.get() + ): SpacecraftPoints = + visibilityPayloadParser.parse( + spacecraftId = satelliteId.toString(), + payloadJson = fetchRawVisibilityPayload(satelliteId, timeStart, timeStop) + ) + + /** Загружает полный raw body окон видимости для сохранения как source of truth. */ + fun fetchRawVisibilityPayload( + satelliteId: Long, + timeStart: LocalDateTime, + timeStop: LocalDateTime + ): String = + webClient.get() .uri { uriBuilder -> uriBuilder .path("/api/satellites/{satelliteId}/rva") @@ -93,48 +98,9 @@ class ExternalPointsClient( .build(satelliteId) } .retrieve() - .bodyToMono(Array::class.java) + .bodyToMono(String::class.java) .block() - ?.toList() - .orEmpty() - - return mapToDomain(satelliteId, response) - } - - /** Группирует внешние записи RVA по станциям и переводит их во внутреннюю модель планирования. */ - private fun mapToDomain( - satelliteId: Long, - response: List - ): SpacecraftPoints { - val points = response - .groupBy { it.stationId } - .map { (stationId, items) -> - InsertionPoint( - pointId = stationId.toString(), - observationWindows = items.map { item -> - ObservationWindow( - startTime = toLocalDateTime(item.onStart.time), - endTime = toLocalDateTime(item.onStop.time), - elevationAngle = item.onMaximum.elevation - ) - }.sortedBy { it.startTime } - ) - } - .sortedBy { it.pointId } - - return SpacecraftPoints( - spacecraftId = satelliteId.toString(), - points = points - ) - } - - /** Разбирает строку времени из внешнего API в локальную для сервиса модель даты и времени. */ - private fun toLocalDateTime(value: String): LocalDateTime = - try { - Instant.parse(value).atZone(zoneId).toLocalDateTime() - } catch (_: DateTimeParseException) { - LocalDateTime.parse(value) - } + ?: throw IllegalStateException("External visibility response body is empty for satelliteId=$satelliteId") private fun logExternalApiError( platform: PlatformCatalogItem, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt new file mode 100644 index 0000000..1a46d9c --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt @@ -0,0 +1,52 @@ +package space.nstart.pcp_tgu_service.integration.api + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.domain.InsertionPoint +import space.nstart.pcp_tgu_service.domain.ObservationWindow +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeParseException + +/** + * Парсит raw payload баллистики в доменную модель расчёта без доступа к БД. + */ +@Component +class VisibilityPayloadParser( + private val objectMapper: ObjectMapper +) { + private val zoneId: ZoneId = ZoneId.systemDefault() + + /** Группирует RVA records по станциям и сортирует окна для алгоритма планирования. */ + fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints { + val response = objectMapper.readValue(payloadJson, Array::class.java).toList() + val points = response + .groupBy { item -> item.stationId } + .map { (stationId, items) -> + InsertionPoint( + pointId = stationId.toString(), + observationWindows = items.map { item -> + ObservationWindow( + startTime = toLocalDateTime(item.onStart.time), + endTime = toLocalDateTime(item.onStop.time), + elevationAngle = item.onMaximum.elevation + ) + }.sortedBy { window -> window.startTime } + ) + } + .sortedBy { point -> point.pointId } + + return SpacecraftPoints(spacecraftId = spacecraftId, points = points) + } + + /** Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. */ + private fun toLocalDateTime(value: String): LocalDateTime = + try { + Instant.parse(value).atZone(zoneId).toLocalDateTime() + } catch (_: DateTimeParseException) { + LocalDateTime.parse(value) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanDecisionConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanDecisionConsumer.kt new file mode 100644 index 0000000..338e33a --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanDecisionConsumer.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase + +/** + * Kafka consumer decision events; вся бизнес-логика вынесена в use case. + */ +@Component +class PlanDecisionConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + /** Делегирует ACCEPTED/REJECTED обработку по attemptId. */ + @KafkaListener( + topics = ["#{__listener.topicsProperties.satellitePlanDecision}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanDecisionMessage) { + handlePlanDecisionUseCase.handle(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/SpacecraftPointsKafkaConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/SpacecraftPointsKafkaConsumer.kt deleted file mode 100644 index 297d530..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/SpacecraftPointsKafkaConsumer.kt +++ /dev/null @@ -1,47 +0,0 @@ -package space.nstart.pcp_tgu_service.integration.kafka - -/** Принимает обновления окон видимости КА из Kafka и передаёт их в планирование. */ -import space.nstart.pcp_tgu_service.config.TopicsProperties -import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage -import space.nstart.pcp_tgu_service.service.SpacecraftPointsService -import org.slf4j.LoggerFactory -import org.springframework.kafka.annotation.KafkaListener -import org.springframework.stereotype.Component - -/** - * Kafka consumer для обработки обновлений пунктов наблюдения. - * - * Каждое сообщение содержит данные по одному космическому аппарату. - */ -@Component -class SpacecraftPointsKafkaConsumer( - /** Имена Kafka-топиков из конфигурации. */ - val topicsProperties: TopicsProperties, - - /** Сервис обработки входных данных */ - private val spacecraftPointsService: SpacecraftPointsService -) { - - /** Логгер */ - private val log = LoggerFactory.getLogger(javaClass) - - /** - * Обрабатывает сообщение из Kafka. - * - * @param message обновление пунктов по одному КА. - */ - /** Обрабатывает одно Kafka-сообщение с окнами видимости для КА. */ - @KafkaListener( - topics = ["#{__listener.topicsProperties.spacecraftPointsUpdate}"], - groupId = "\${spring.kafka.consumer.group-id}" - ) - fun consume(message: SpacecraftPointsMessage) { - - log.info( - "Received spacecraft points update from Kafka. spacecraftId={}", - message.spacecraftId - ) - - spacecraftPointsService.saveFromMessage(message) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/VisibilityWindowsChangedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/VisibilityWindowsChangedConsumer.kt new file mode 100644 index 0000000..44c0a35 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/VisibilityWindowsChangedConsumer.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.VisibilityWindowsChangedMessage +import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService + +/** + * Kafka consumer, который только ставит refresh job и не выполняет расчёт или external I/O. + */ +@Component +class VisibilityWindowsChangedConsumer( + val topicsProperties: TopicsProperties, + private val visibilityRefreshJobService: VisibilityRefreshJobService +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Обрабатывает сигнал изменения окон видимости как запрос на перечитывание у баллистики. */ + @KafkaListener( + topics = ["#{__listener.topicsProperties.visibilityWindowsChanged}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: VisibilityWindowsChangedMessage) { + visibilityRefreshJobService.enqueue( + spacecraftId = message.spacecraftId, + noradId = message.noradId, + reason = "KAFKA_VISIBILITY_CHANGED" + ) + + log.info( + "Visibility refresh job enqueued from Kafka. spacecraftId={}, eventId={}", + message.spacecraftId, + message.eventId + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/AcceptedPlanSlotsUpdateJobRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/AcceptedPlanSlotsUpdateJobRepository.kt new file mode 100644 index 0000000..024706a --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/AcceptedPlanSlotsUpdateJobRepository.kt @@ -0,0 +1,63 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobEntity +import java.time.LocalDateTime +import java.util.UUID + +/** Доступ к jobs запуска BPMN updateAcceptedPlanSlots. */ +interface AcceptedPlanSlotsUpdateJobRepository : JpaRepository { + /** Атомарно создаёт job один раз на planId. */ + @Modifying + @Query( + value = """ + INSERT INTO accepted_plan_slots_update_job( + plan_id, + status, + created_at, + updated_at + ) + VALUES ( + :planId, + 'PENDING', + :now, + :now + ) + ON CONFLICT (plan_id) DO NOTHING + """, + nativeQuery = true + ) + fun insertPendingIfAbsent( + @Param("planId") planId: UUID, + @Param("now") now: LocalDateTime + ): Int + + /** + * Claim'ит jobs через row lock. + * + * RUNNING job можно подобрать только после истечения lease и только если processInstanceKey ещё не сохранён. + */ + @Query( + value = """ + SELECT * + FROM accepted_plan_slots_update_job + WHERE status = 'PENDING' + OR ( + status = 'RUNNING' + AND locked_until < :now + AND process_instance_key IS NULL + ) + ORDER BY created_at + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + fun findClaimableForUpdate( + @Param("now") now: LocalDateTime, + @Param("limit") limit: Int + ): List +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanIssueAttemptRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanIssueAttemptRepository.kt new file mode 100644 index 0000000..b59730c --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanIssueAttemptRepository.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import java.util.UUID + +/** Доступ к попыткам выдачи persisted plan. */ +interface PlanIssueAttemptRepository : JpaRepository { + /** Возвращает последний номер попытки для planId. */ + fun findTopByPlanIdOrderByAttemptNoDesc(planId: UUID): PlanIssueAttemptEntity? + + /** Блокирует попытку выдачи для обработки decision event. */ + @Query( + value = """ + SELECT * + FROM plan_issue_attempt + WHERE attempt_id = :attemptId + FOR UPDATE + """, + nativeQuery = true + ) + fun lockByAttemptId(@Param("attemptId") attemptId: UUID): PlanIssueAttemptEntity? +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt new file mode 100644 index 0000000..8f237e6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt @@ -0,0 +1,85 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity +import java.time.LocalDateTime + +/** Доступ к jobs перестроения расписания. */ +interface PlanRecalculationJobRepository : JpaRepository { + /** Атомарно ставит или переоткрывает coalesced recalculation job по spacecraftId. */ + @Modifying + @Query( + value = """ + INSERT INTO plan_recalculation_job( + spacecraft_id, + status, + reason, + locked_by, + locked_until, + rerun_requested, + requested_at, + updated_at + ) + VALUES ( + :spacecraftId, + 'PENDING', + :reason, + NULL, + NULL, + FALSE, + :now, + :now + ) + ON CONFLICT (spacecraft_id) DO UPDATE SET + status = CASE + WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status + ELSE 'PENDING' + END, + reason = EXCLUDED.reason, + locked_by = CASE + WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by + ELSE NULL + END, + locked_until = CASE + WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until + ELSE NULL + END, + rerun_requested = CASE + WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE + ELSE FALSE + END, + requested_at = EXCLUDED.requested_at, + updated_at = EXCLUDED.updated_at + """, + nativeQuery = true + ) + fun upsertPending( + @Param("spacecraftId") spacecraftId: String, + @Param("reason") reason: String, + @Param("now") now: LocalDateTime + ): Int + + /** Claim pending или просроченные running jobs через row locks для горизонтального scale-out. */ + @Query( + value = """ + SELECT * + FROM plan_recalculation_job + WHERE status = 'PENDING' + OR ( + status = 'RUNNING' + AND locked_until < :now + ) + ORDER BY requested_at + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + fun findPendingForUpdate( + @Param("now") now: LocalDateTime, + @Param("limit") limit: Int + ): List +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRepository.kt deleted file mode 100644 index 9baf7b3..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRepository.kt +++ /dev/null @@ -1,77 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -import space.nstart.pcp_tgu_service.domain.Plan -import org.springframework.stereotype.Repository -import java.util.concurrent.ConcurrentHashMap - -/** - * In-memory репозиторий планов. - * - * Хранит рассчитанные планы по каждому космическому аппарату. - */ -@Repository -class PlanRepository { - - /** - * Внутреннее потокобезопасное хранилище. - * - * Ключ — spacecraftId. - * Значение — список планов по этому КА. - */ - private val storage: MutableMap> = ConcurrentHashMap() - - /** - * Полностью заменяет список планов по одному КА. - * - * Используется после пересчёта планов. - * - * @param spacecraftId идентификатор космического аппарата. - * @param plans новый список планов. - */ - fun replacePlans(spacecraftId: String, plans: List) { - storage[spacecraftId] = plans - .sortedBy { plan -> plan.startTime } - .toMutableList() - } - - /** - * Возвращает все планы по всем КА. - * - * @return общий отсортированный список планов. - */ - fun findAll(): List = - storage.values - .flatten() - .sortedBy { plan -> plan.startTime } - - /** - * Возвращает список планов по одному КА. - * - * @param spacecraftId идентификатор космического аппарата. - * @return список планов указанного КА. - */ - fun findBySpacecraftId(spacecraftId: String): List = - storage[spacecraftId] - ?.sortedBy { plan -> plan.startTime } - ?: emptyList() - - /** - * Обновляет конкретный план. - * - * План ищется по spacecraftId и startTime. - * - * @param spacecraftId идентификатор космического аппарата. - * @param updatedPlan обновлённый план. - */ - fun updatePlan(spacecraftId: String, updatedPlan: Plan) { - val plans: MutableList = storage[spacecraftId] ?: return - - val index: Int = plans.indexOfFirst { existingPlan -> - existingPlan.startTime == updatedPlan.startTime - } - - if (index >= 0) { - plans[index] = updatedPlan - } - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt new file mode 100644 index 0000000..c2a284f --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -0,0 +1,143 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.time.LocalDateTime +import java.util.UUID + +/** Доступ к persisted расписанию business-plan. */ +interface PlannedPlanRepository : JpaRepository { + /** Ищет persisted plan по стабильному business key. */ + fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId( + spacecraftId: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String + ): PlannedPlanEntity? + + /** Возвращает планы КА в порядке старта. */ + fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List + + /** Возвращает все планы в порядке старта. */ + fun findAllByOrderByStartTime(): List + + /** Возвращает будущие planned-планы КА. */ + fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + spacecraftId: String, + status: PlannedPlanStatus, + startTime: LocalDateTime + ): List + + /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ + fun findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + spacecraftId: String, + status: PlannedPlanStatus, + startTime: LocalDateTime + ): PlannedPlanEntity? + + /** Атомарно создаёт или обновляет PLANNED plan по business key, сохраняя stable planId. */ + @Query( + value = """ + WITH upsert AS ( + INSERT INTO planned_plan( + plan_id, + spacecraft_id, + start_time, + end_time, + kpp_id, + status, + issue_not_before, + base_plan_id, + chain_version, + created_at, + updated_at + ) + VALUES ( + :planId, + :spacecraftId, + :startTime, + :endTime, + :kppId, + 'PLANNED', + :issueNotBefore, + :basePlanId, + :chainVersion, + :now, + :now + ) + ON CONFLICT (spacecraft_id, start_time, end_time, kpp_id) DO UPDATE SET + status = CASE + WHEN planned_plan.status IN ('SUPERSEDED', 'EXPIRED') THEN 'PLANNED' + ELSE planned_plan.status + END, + issue_not_before = CASE + WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.issue_not_before + ELSE planned_plan.issue_not_before + END, + base_plan_id = CASE + WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.base_plan_id + ELSE planned_plan.base_plan_id + END, + chain_version = CASE + WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.chain_version + ELSE planned_plan.chain_version + END, + updated_at = CASE + WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.updated_at + ELSE planned_plan.updated_at + END + RETURNING * + ) + SELECT * + FROM upsert + """, + nativeQuery = true + ) + fun upsertPlannedByBusinessKey( + @Param("planId") planId: UUID, + @Param("spacecraftId") spacecraftId: String, + @Param("startTime") startTime: LocalDateTime, + @Param("endTime") endTime: LocalDateTime, + @Param("kppId") kppId: String, + @Param("issueNotBefore") issueNotBefore: LocalDateTime, + @Param("basePlanId") basePlanId: UUID?, + @Param("chainVersion") chainVersion: Long, + @Param("now") now: LocalDateTime + ): PlannedPlanEntity + + /** Блокирует plan для обработки decision event или issue state transition. */ + @Query( + value = """ + SELECT * + FROM planned_plan + WHERE plan_id = :planId + FOR UPDATE + """, + nativeQuery = true + ) + fun lockByPlanId(@Param("planId") planId: UUID): PlannedPlanEntity? + + /** Claim due plans через PostgreSQL row locks, чтобы несколько pod не выдали один plan дважды. */ + @Query( + value = """ + SELECT * + FROM planned_plan + WHERE status = 'PLANNED' + AND (issue_not_before IS NULL OR issue_not_before <= :now) + AND start_time <= :issueBefore + AND start_time > :now + ORDER BY start_time + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + fun findDueForUpdate( + @Param("now") now: LocalDateTime, + @Param("issueBefore") issueBefore: LocalDateTime, + @Param("limit") limit: Int + ): List +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/ProcessedPlanDecisionEventRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/ProcessedPlanDecisionEventRepository.kt new file mode 100644 index 0000000..d2d98b6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/ProcessedPlanDecisionEventRepository.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.ProcessedPlanDecisionEventEntity +import java.time.LocalDateTime +import java.util.UUID + +/** Dedup-хранилище обработанных Kafka decision events. */ +interface ProcessedPlanDecisionEventRepository : JpaRepository { + /** Атомарно вставляет eventId, игнорируя повторную доставку того же Kafka event. */ + @Modifying + @Query( + value = """ + INSERT INTO processed_plan_decision_event(event_id, received_at) + VALUES (:eventId, :receivedAt) + ON CONFLICT (event_id) DO NOTHING + """, + nativeQuery = true + ) + fun insertIfAbsent( + @Param("eventId") eventId: UUID, + @Param("receivedAt") receivedAt: LocalDateTime + ): Int +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt index bf34647..887300b 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPlanningStateJpaRepository.kt @@ -3,5 +3,19 @@ package space.nstart.pcp_tgu_service.repository /** Предоставляет доступ к БД для сохранённого состояния планирования КА. */ import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param -interface SpacecraftPlanningStateJpaRepository : JpaRepository +interface SpacecraftPlanningStateJpaRepository : JpaRepository { + /** Блокирует строку состояния КА для изменения active/base указателей. */ + @Query( + value = """ + SELECT * + FROM spacecraft_planning_state + WHERE spacecraft_id = :spacecraftId + FOR UPDATE + """, + nativeQuery = true + ) + fun lockBySpacecraftId(@Param("spacecraftId") spacecraftId: String): SpacecraftPlanningStateEntity? +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPointsRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPointsRepository.kt deleted file mode 100644 index 4819dc0..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftPointsRepository.kt +++ /dev/null @@ -1,48 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import org.springframework.stereotype.Repository -import java.util.concurrent.ConcurrentHashMap - -/** - * In-memory репозиторий входных данных по космическим аппаратам. - * - * Хранит список доступных пунктов наблюдения для каждого КА. - */ -@Repository -class SpacecraftPointsRepository { - - /** - * Внутреннее потокобезопасное хранилище. - * - * Ключ — spacecraftId. - * Значение — все входные данные по конкретному КА. - */ - private val storage: MutableMap = ConcurrentHashMap() - - /** - * Сохраняет или обновляет данные по космическому аппарату. - * - * @param spacecraftPoints данные по одному КА. - */ - fun save(spacecraftPoints: SpacecraftPoints) { - storage[spacecraftPoints.spacecraftId] = spacecraftPoints - } - - /** - * Возвращает данные по одному КА. - * - * @param spacecraftId идентификатор космического аппарата. - * @return данные по КА или null, если их нет. - */ - fun findBySpacecraftId(spacecraftId: String): SpacecraftPoints? = - storage[spacecraftId] - - /** - * Возвращает данные по всем космическим аппаратам. - * - * @return список всех записей. - */ - fun findAll(): List = - storage.values.toList() -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftSnapshotRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftSnapshotRepository.kt new file mode 100644 index 0000000..528a2f3 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftSnapshotRepository.kt @@ -0,0 +1,101 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import java.time.LocalDateTime + +/** Доступ к persisted snapshot КА из НСИ. */ +interface SpacecraftSnapshotRepository : JpaRepository { + /** Возвращает только активные КА для рабочих фоновых процессов. */ + fun findAllByActiveTrue(): List + + /** Атомарно создаёт или обновляет snapshot КА из НСИ по spacecraftId. */ + @Modifying + @Query( + value = """ + INSERT INTO spacecraft_snapshot( + spacecraft_id, + norad_id, + nsi_id, + business_key, + name, + status, + mission, + valid_from, + valid_to, + active, + created_at, + updated_at + ) + VALUES ( + :spacecraftId, + :noradId, + :nsiId, + :businessKey, + :name, + :status, + :mission, + :validFrom, + :validTo, + TRUE, + :now, + :now + ) + ON CONFLICT (spacecraft_id) DO UPDATE SET + norad_id = EXCLUDED.norad_id, + nsi_id = EXCLUDED.nsi_id, + business_key = EXCLUDED.business_key, + name = EXCLUDED.name, + status = EXCLUDED.status, + mission = EXCLUDED.mission, + valid_from = EXCLUDED.valid_from, + valid_to = EXCLUDED.valid_to, + active = TRUE, + updated_at = EXCLUDED.updated_at + """, + nativeQuery = true + ) + fun upsertFromNsi( + @Param("spacecraftId") spacecraftId: String, + @Param("noradId") noradId: Long, + @Param("nsiId") nsiId: String?, + @Param("businessKey") businessKey: String?, + @Param("name") name: String?, + @Param("status") status: String?, + @Param("mission") mission: String?, + @Param("validFrom") validFrom: LocalDateTime?, + @Param("validTo") validTo: LocalDateTime?, + @Param("now") now: LocalDateTime + ): Int + + /** Деактивирует active snapshots, которых больше нет в allowed set НСИ. */ + @Modifying + @Query( + """ + UPDATE SpacecraftSnapshotEntity snapshot + SET snapshot.active = false, + snapshot.updatedAt = :now + WHERE snapshot.active = true + AND snapshot.spacecraftId NOT IN :spacecraftIds + """ + ) + fun deactivateMissing( + @Param("spacecraftIds") spacecraftIds: Collection, + @Param("now") now: LocalDateTime + ): Int + + /** Деактивирует все active snapshots, когда НСИ вернул пустой allowed set. */ + @Modifying + @Query( + """ + UPDATE SpacecraftSnapshotEntity snapshot + SET snapshot.active = false, + snapshot.updatedAt = :now + WHERE snapshot.active = true + """ + ) + fun deactivateAllActive(@Param("now") now: LocalDateTime): Int +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftVisibilitySnapshotRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftVisibilitySnapshotRepository.kt new file mode 100644 index 0000000..66609e8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/SpacecraftVisibilitySnapshotRepository.kt @@ -0,0 +1,7 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity + +/** Доступ к raw snapshot окон видимости КА. */ +interface SpacecraftVisibilitySnapshotRepository : JpaRepository diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/VisibilityRefreshJobRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/VisibilityRefreshJobRepository.kt new file mode 100644 index 0000000..5ce3fd5 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/VisibilityRefreshJobRepository.kt @@ -0,0 +1,89 @@ +package space.nstart.pcp_tgu_service.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import space.nstart.pcp_tgu_service.entity.VisibilityRefreshJobEntity +import java.time.LocalDateTime + +/** Доступ к refresh jobs окон видимости. */ +interface VisibilityRefreshJobRepository : JpaRepository { + /** Атомарно ставит или переоткрывает coalesced refresh job по spacecraftId. */ + @Modifying + @Query( + value = """ + INSERT INTO visibility_refresh_job( + spacecraft_id, + norad_id, + status, + reason, + locked_by, + locked_until, + rerun_requested, + requested_at, + updated_at + ) + VALUES ( + :spacecraftId, + :noradId, + 'PENDING', + :reason, + NULL, + NULL, + FALSE, + :now, + :now + ) + ON CONFLICT (spacecraft_id) DO UPDATE SET + norad_id = COALESCE(EXCLUDED.norad_id, visibility_refresh_job.norad_id), + status = CASE + WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.status + ELSE 'PENDING' + END, + reason = EXCLUDED.reason, + locked_by = CASE + WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_by + ELSE NULL + END, + locked_until = CASE + WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_until + ELSE NULL + END, + rerun_requested = CASE + WHEN visibility_refresh_job.status = 'RUNNING' THEN TRUE + ELSE FALSE + END, + requested_at = EXCLUDED.requested_at, + updated_at = EXCLUDED.updated_at + """, + nativeQuery = true + ) + fun upsertPending( + @Param("spacecraftId") spacecraftId: String, + @Param("noradId") noradId: Long?, + @Param("reason") reason: String, + @Param("now") now: LocalDateTime + ): Int + + /** Claim pending или просроченные running jobs через row locks для горизонтального scale-out. */ + @Query( + value = """ + SELECT * + FROM visibility_refresh_job + WHERE status = 'PENDING' + OR ( + status = 'RUNNING' + AND locked_until < :now + ) + ORDER BY requested_at + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + fun findPendingForUpdate( + @Param("now") now: LocalDateTime, + @Param("limit") limit: Int + ): List +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt new file mode 100644 index 0000000..63652f3 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateJobService.kt @@ -0,0 +1,21 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * Создаёт persisted job обновления слотов для accepted plan. + */ +@Service +class AcceptedPlanSlotsUpdateJobService( + private val acceptedPlanSlotsUpdateJobRepository: AcceptedPlanSlotsUpdateJobRepository +) { + /** Idempotent enqueue: один planId получает максимум одну job. */ + @Transactional + fun enqueue(planId: UUID) { + acceptedPlanSlotsUpdateJobRepository.insertPendingIfAbsent(planId, LocalDateTime.now()) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateProcessStarter.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateProcessStarter.kt new file mode 100644 index 0000000..4d126ce --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateProcessStarter.kt @@ -0,0 +1,39 @@ +package space.nstart.pcp_tgu_service.service + +import io.camunda.client.CamundaClient +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.UUID + +/** + * Adapter старта короткого BPMN-процесса updateAcceptedPlanSlots. + */ +@Service +class AcceptedPlanSlotsUpdateProcessStarter( + private val camundaClient: CamundaClient +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Стартует process с единственной нужной worker'у переменной satellitePlanId. */ + fun start(planId: UUID): Long { + val processInstance = camundaClient + .newCreateInstanceCommand() + .bpmnProcessId(UPDATE_ACCEPTED_PLAN_SLOTS_PROCESS_ID) + .latestVersion() + .variables(mapOf("satellitePlanId" to planId.toString())) + .send() + .join() + + log.info( + "Started accepted plan slots update process. processInstanceKey={}, planId={}", + processInstance.processInstanceKey, + planId + ) + + return processInstance.processInstanceKey + } + + companion object { + const val UPDATE_ACCEPTED_PLAN_SLOTS_PROCESS_ID = "updateAcceptedPlanSlots" + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt new file mode 100644 index 0000000..d5a8078 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateWorker.kt @@ -0,0 +1,101 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobEntity +import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus +import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository +import java.net.InetAddress +import java.time.LocalDateTime +import java.util.UUID + +/** + * Scale-out safe worker, который запускает updateAcceptedPlanSlots для accepted plan. + */ +@Service +class AcceptedPlanSlotsUpdateWorker( + private val planningProperties: PlanningProperties, + private val acceptedPlanSlotsUpdateJobRepository: AcceptedPlanSlotsUpdateJobRepository, + private val acceptedPlanSlotsUpdateProcessStarter: AcceptedPlanSlotsUpdateProcessStarter, + private val transactionTemplate: TransactionTemplate +) { + private val log = LoggerFactory.getLogger(javaClass) + private val workerId = "${InetAddress.getLocalHost().hostName}-${UUID.randomUUID()}" + + /** Периодически claim'ит jobs и стартует короткий BPMN вне DB transaction. */ + @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") + fun processPendingJobs() { + val jobs = claimJobs(limit = 10) + jobs.forEach { job -> processJob(job.planId) } + } + + /** Claim видим для тестов; в production защищён FOR UPDATE SKIP LOCKED. */ + fun claimJobs(limit: Int): List = + transactionTemplate.execute { + val now = LocalDateTime.now() + val jobs = acceptedPlanSlotsUpdateJobRepository.findClaimableForUpdate(now, limit) + jobs.forEach { job -> + job.status = AcceptedPlanSlotsUpdateJobStatus.RUNNING + job.lockedBy = workerId + job.lockedUntil = now.plusSeconds(planningProperties.workerLockTtlSeconds) + job.attempts += 1 + job.updatedAt = now + job.lastError = null + } + acceptedPlanSlotsUpdateJobRepository.saveAll(jobs) + jobs + }.orEmpty() + + private fun processJob(planId: UUID) { + val processInstanceKey = try { + val processInstanceKey = acceptedPlanSlotsUpdateProcessStarter.start(planId) + processInstanceKey + } catch (ex: Exception) { + log.warn("Accepted plan slots update process start failed. planId={}", planId, ex) + markAmbiguous(planId, null, ex) + return + } + + try { + markDone(planId, processInstanceKey) + } catch (ex: Exception) { + log.warn("Accepted plan slots update job could not be marked DONE after process start. planId={}", planId, ex) + markAmbiguous(planId, processInstanceKey, ex) + } + } + + private fun markDone(planId: UUID, processInstanceKey: Long) { + transactionTemplate.executeWithoutResult { + val now = LocalDateTime.now() + val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult + job.status = AcceptedPlanSlotsUpdateJobStatus.DONE + job.processInstanceKey = processInstanceKey + job.lockedBy = null + job.lockedUntil = null + job.lastError = null + job.updatedAt = now + acceptedPlanSlotsUpdateJobRepository.save(job) + } + } + + private fun markAmbiguous(planId: UUID, processInstanceKey: Long?, ex: Exception) { + transactionTemplate.executeWithoutResult { + val now = LocalDateTime.now() + val job = acceptedPlanSlotsUpdateJobRepository.findById(planId).orElse(null) ?: return@executeWithoutResult + job.status = AcceptedPlanSlotsUpdateJobStatus.AMBIGUOUS + job.processInstanceKey = processInstanceKey ?: job.processInstanceKey + job.lockedBy = null + job.lockedUntil = null + job.lastError = ex.message?.take(MAX_ERROR_LENGTH) ?: ex.javaClass.name + job.updatedAt = now + acceptedPlanSlotsUpdateJobRepository.save(job) + } + } + + private companion object { + const val MAX_ERROR_LENGTH = 2_000 + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/BpmnPlanIssueProcessStarter.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/BpmnPlanIssueProcessStarter.kt new file mode 100644 index 0000000..cf5a8cb --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/BpmnPlanIssueProcessStarter.kt @@ -0,0 +1,60 @@ +package space.nstart.pcp_tgu_service.service + +import io.camunda.client.CamundaClient +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity + +/** + * Adapter, который стартует BPMN process instance для уже созданной attempt. + */ +@Service +class BpmnPlanIssueProcessStarter( + private val camundaClient: CamundaClient +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Стартует createSatelliteMission и возвращает ключ process instance. */ + fun startProcess(plan: PlannedPlanEntity, attempt: PlanIssueAttemptEntity): ProcessStartResponse { + val variables = CreateSatelliteMissionVariables( + spacecraftId = plan.spacecraftId, + satellitePlanId = plan.planId.toString(), + satellitePlanAttemptId = attempt.attemptId.toString(), + planStartTime = plan.startTime, + planEndTime = plan.endTime, + kppId = plan.kppId + ) + + val processInstance = camundaClient + .newCreateInstanceCommand() + .bpmnProcessId(CREATE_SATELLITE_MISSION_PROCESS_ID) + .latestVersion() + .variables(variables) + .send() + .join() + + log.info( + "Started BPMN plan issue process. processInstanceKey={}, spacecraftId={}, planId={}, attemptId={}", + processInstance.processInstanceKey, + plan.spacecraftId, + plan.planId, + attempt.attemptId + ) + + return ProcessStartResponse( + processInstanceKey = processInstance.processInstanceKey, + processDefinitionKey = processInstance.processDefinitionKey, + bpmnProcessId = processInstance.bpmnProcessId, + version = processInstance.version, + planId = plan.planId, + attemptId = attempt.attemptId + ) + } + + private companion object { + const val CREATE_SATELLITE_MISSION_PROCESS_ID = "createSatelliteMission" + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ExternalPointsUpdateScheduler.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ExternalPointsUpdateScheduler.kt deleted file mode 100644 index 53c6262..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ExternalPointsUpdateScheduler.kt +++ /dev/null @@ -1,76 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Периодически обновляет данные о видимости КА из внешнего API. */ -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient -import jakarta.annotation.PostConstruct -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Service - -/** - * Планировщик обновления данных из внешнего API. - * - * После старта приложения выполняет первичную загрузку, - * а затем периодически обновляет данные по всем КА. - */ -@Service -class ExternalPointsUpdateScheduler( - /** Конфигурация планирования */ - private val planningProperties: PlanningProperties, - - /** Клиент внешнего API */ - private val externalPointsClient: ExternalPointsClient, - - /** Сервис сохранения входных данных и пересчёта планов */ - private val spacecraftPointsService: SpacecraftPointsService -) { - - /** Логгер */ - private val log = LoggerFactory.getLogger(javaClass) - - /** - * Первичная загрузка данных после старта приложения. - */ - /** Выполняет первую загрузку при старте, если периодические обновления включены. */ - @PostConstruct - fun initLoad() { - if (!planningProperties.externalUpdateEnabled) { - log.info("External update is disabled. Initial load skipped.") - return - } - - try { - refreshFromExternalApi() - } catch (ex: Exception) { - log.error("Initial external refresh failed. Application startup will continue.", ex) - } - } - - /** - * Периодическое обновление данных из внешнего API. - */ - /** Выполняет периодическое обновление согласно настройкам планировщика. */ - @Scheduled(fixedDelayString = "\${planning.external-update-fixed-delay-ms}") - fun scheduledRefresh() { - if (!planningProperties.externalUpdateEnabled) { - return - } - - refreshFromExternalApi() - } - - /** - * Загружает данные из внешнего API и сохраняет их в систему. - */ - /** Загружает свежие окна видимости и передаёт их в конвейер планирования. */ - fun refreshFromExternalApi() { - val spacecraftPointsList = externalPointsClient.fetchAllSpacecraftPoints() - - spacecraftPointsList.forEach { spacecraftPoints -> - spacecraftPointsService.saveAndRecalculate(spacecraftPoints) - } - - log.info("External spacecraft points refresh completed. count={}", spacecraftPointsList.size) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt new file mode 100644 index 0000000..b61cea8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt @@ -0,0 +1,172 @@ +package space.nstart.pcp_tgu_service.service + +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.domain.PlanDecision +import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime + +/** + * Обрабатывает decision events идемпотентно по eventId и строго по attemptId. + */ +@Service +class HandlePlanDecisionUseCase( + private val processedPlanDecisionEventRepository: ProcessedPlanDecisionEventRepository, + private val planIssueAttemptRepository: PlanIssueAttemptRepository, + private val plannedPlanRepository: PlannedPlanRepository, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val planRecalculationJobService: PlanRecalculationJobService, + private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService, + private val planningProperties: space.nstart.pcp_tgu_service.config.PlanningProperties +) { + /** Применяет решение; повтор того же eventId является успешным no-op. */ + @Transactional + fun handle(message: PlanDecisionMessage) { + val inserted = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) + if (inserted == 0) { + return + } + + val attempt = planIssueAttemptRepository.lockByAttemptId(message.attemptId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Attempt ${message.attemptId} was not found") + if (attempt.planId != message.planId) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Decision attemptId=${message.attemptId} does not belong to planId=${message.planId}" + ) + } + + val plan = plannedPlanRepository.lockByPlanId(message.planId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found") + if (plan.spacecraftId != message.spacecraftId) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Decision spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}" + ) + } + + val state = getOrCreateLockedState(message.spacecraftId) + if (attempt.status.isTerminal()) { + return + } + if (state.activeAttemptId != attempt.attemptId) { + recordDecisionOnAttempt(message, attempt) + planIssueAttemptRepository.save(attempt) + return + } + + when (message.decision) { + PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan, state) + PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state) + } + } + + private fun recordDecisionOnAttempt( + message: PlanDecisionMessage, + attempt: PlanIssueAttemptEntity + ) { + val now = LocalDateTime.now() + attempt.status = when (message.decision) { + PlanDecision.ACCEPTED -> PlanIssueAttemptStatus.ACCEPTED + PlanDecision.REJECTED -> PlanIssueAttemptStatus.REJECTED + } + attempt.finishedAt = message.decisionTime + attempt.decisionEventId = message.eventId + attempt.decisionReason = message.reason + attempt.updatedAt = now + } + + private fun acceptPlan( + message: PlanDecisionMessage, + attempt: PlanIssueAttemptEntity, + plan: PlannedPlanEntity, + state: SpacecraftPlanningStateEntity + ) { + val now = LocalDateTime.now() + recordDecisionOnAttempt(message, attempt) + + plan.status = PlannedPlanStatus.ACCEPTED + plan.acceptedAt = message.decisionTime + plan.updatedAt = now + + state.currentBasePlanId = plan.planId + state.activePlanId = null + state.activeAttemptId = null + state.chainVersion += 1 + state.updatedAt = now + + planIssueAttemptRepository.save(attempt) + plannedPlanRepository.save(plan) + spacecraftPlanningStateJpaRepository.save(state) + planRecalculationJobService.enqueue(message.spacecraftId, "PLAN_ACCEPTED") + acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) + } + + private fun rejectPlan( + message: PlanDecisionMessage, + attempt: PlanIssueAttemptEntity, + plan: PlannedPlanEntity, + state: SpacecraftPlanningStateEntity + ) { + val now = LocalDateTime.now() + recordDecisionOnAttempt(message, attempt) + + state.activePlanId = null + state.activeAttemptId = null + state.updatedAt = now + + if (now < plan.startTime.minusMinutes(planningProperties.minReissueLeadMinutes)) { + plan.status = PlannedPlanStatus.PLANNED + plan.issueNotBefore = now + plan.updatedAt = now + } else { + plan.status = PlannedPlanStatus.EXPIRED + plan.updatedAt = now + promoteNextPlanOrEnqueueRebuild(plan.spacecraftId, now) + } + + planIssueAttemptRepository.save(attempt) + plannedPlanRepository.save(plan) + spacecraftPlanningStateJpaRepository.save(state) + } + + /** + * Decision use case только меняет state. Новую попытку позже создаст IssueDuePlanWorker. + */ + private fun promoteNextPlanOrEnqueueRebuild(spacecraftId: String, now: LocalDateTime) { + val nextPlan = plannedPlanRepository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + spacecraftId = spacecraftId, + status = PlannedPlanStatus.PLANNED, + startTime = now + ) + + if (nextPlan == null) { + planRecalculationJobService.enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + } else { + nextPlan.issueNotBefore = now + nextPlan.updatedAt = now + plannedPlanRepository.save(nextPlan) + } + } + + private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { + spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) + ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) + return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId) + ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create") + } + + private fun PlanIssueAttemptStatus.isTerminal(): Boolean = + this != PlanIssueAttemptStatus.STARTING && this != PlanIssueAttemptStatus.STARTED +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt new file mode 100644 index 0000000..8f89117 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -0,0 +1,162 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * Выдаёт due plans из БД через попытки и короткий BPMN process per attempt. + */ +@Service +class IssueDuePlanWorker( + private val planningProperties: PlanningProperties, + private val plannedPlanRepository: PlannedPlanRepository, + private val planIssueAttemptRepository: PlanIssueAttemptRepository, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter, + private val transactionTemplate: TransactionTemplate +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Периодически claim'ит due plans и стартует BPMN вне DB transaction. */ + @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") + fun issueDuePlans() { + val prepared = transactionTemplate.execute { prepareDuePlans(limit = 10) }.orEmpty() + prepared.forEach { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + } + + /** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */ + fun issuePlanNow(planId: UUID): ProcessStartResponse { + val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found or cannot be issued") + return startPreparedAttempt(prepared) + } + + private fun prepareDuePlans(limit: Int): List { + val now = LocalDateTime.now() + val issueBefore = now.plusMinutes(planningProperties.notificationBeforeStartMinutes) + return plannedPlanRepository.findDueForUpdate(now, issueBefore, limit) + .mapNotNull { plan -> preparePlan(plan, now) } + } + + private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { + val now = LocalDateTime.now() + val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null + if (plan.status !in setOf(PlannedPlanStatus.PLANNED, PlannedPlanStatus.REJECTED)) { + return null + } + return preparePlan(plan, now) + } + + private fun preparePlan(plan: PlannedPlanEntity, now: LocalDateTime): PreparedAttempt? { + val state = getOrCreateLockedState(plan.spacecraftId) + if (state.activeAttemptId != null) { + return null + } + + val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)?.attemptNo ?: 0) + 1 + val attempt = planIssueAttemptRepository.save( + PlanIssueAttemptEntity( + attemptId = UUID.randomUUID(), + planId = plan.planId, + attemptNo = attemptNo, + status = PlanIssueAttemptStatus.STARTING, + createdAt = now, + updatedAt = now + ) + ) + + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse { + val plan = plannedPlanRepository.findById(preparedAttempt.planId) + .orElseThrow { IllegalStateException("Prepared plan ${preparedAttempt.planId} disappeared") } + val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId) + .orElseThrow { IllegalStateException("Prepared attempt ${preparedAttempt.attemptId} disappeared") } + + return try { + val response = bpmnPlanIssueProcessStarter.startProcess(plan, attempt) + markStarted(response) + response + } catch (ex: Exception) { + log.warn("BPMN start failed after attempt was prepared. planId={}, attemptId={}", plan.planId, attempt.attemptId, ex) + markAmbiguous(preparedAttempt) + throw ex + } + } + + private fun markStarted(response: ProcessStartResponse) { + transactionTemplate.executeWithoutResult { + val now = LocalDateTime.now() + val attempt = planIssueAttemptRepository.findById(response.attemptId).orElse(null) ?: return@executeWithoutResult + val plan = plannedPlanRepository.findById(response.planId).orElse(null) ?: return@executeWithoutResult + + if (attempt.processInstanceKey == null) { + attempt.processInstanceKey = response.processInstanceKey + } + if (attempt.startedAt == null) { + attempt.startedAt = now + } + if (attempt.status == PlanIssueAttemptStatus.STARTING && plan.status == PlannedPlanStatus.ISSUING) { + attempt.status = PlanIssueAttemptStatus.STARTED + plan.status = PlannedPlanStatus.WAITING_DECISION + plan.updatedAt = now + } + attempt.updatedAt = now + + planIssueAttemptRepository.save(attempt) + plannedPlanRepository.save(plan) + } + } + + private fun markAmbiguous(preparedAttempt: PreparedAttempt) { + transactionTemplate.executeWithoutResult { + val now = LocalDateTime.now() + val attempt = planIssueAttemptRepository.findById(preparedAttempt.attemptId).orElse(null) ?: return@executeWithoutResult + val plan = plannedPlanRepository.findById(preparedAttempt.planId).orElse(null) ?: return@executeWithoutResult + attempt.status = PlanIssueAttemptStatus.AMBIGUOUS + attempt.updatedAt = now + plan.status = PlannedPlanStatus.START_AMBIGUOUS + plan.updatedAt = now + planIssueAttemptRepository.save(attempt) + plannedPlanRepository.save(plan) + } + } + + private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { + spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) + ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) + return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId) + ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create") + } + + private data class PreparedAttempt( + val planId: UUID, + val attemptId: UUID + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/NsiSyncScheduler.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/NsiSyncScheduler.kt new file mode 100644 index 0000000..4b6ff8a --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/NsiSyncScheduler.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp_tgu_service.service + +import jakarta.annotation.PostConstruct +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import space.nstart.pcp_tgu_service.config.PlanningProperties + +/** + * Периодически синхронизирует список КА из НСИ; сами окна читает отдельный worker. + */ +@Service +class NsiSyncScheduler( + private val planningProperties: PlanningProperties, + private val syncSpacecraftFromNsiUseCase: SyncSpacecraftFromNsiUseCase +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Выполняет initial sync, если внешний update включён. */ + @PostConstruct + fun initSync() { + if (!planningProperties.externalUpdateEnabled) { + log.info("External update is disabled. Initial NSI sync skipped.") + return + } + + runCatching { syncSpacecraftFromNsiUseCase.syncAllowedSpacecraft() } + .onFailure { ex -> log.error("Initial NSI sync failed. Application startup will continue.", ex) } + } + + /** Периодически ставит refresh jobs для active spacecraft из НСИ. */ + @Scheduled(fixedDelayString = "\${planning.external-update-fixed-delay-ms}") + fun scheduledSync() { + if (planningProperties.externalUpdateEnabled) { + syncSpacecraftFromNsiUseCase.syncAllowedSpacecraft() + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index 5a181bc..d086947 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -2,9 +2,9 @@ package space.nstart.pcp_tgu_service.service /** Рассчитывает цепочки планов по окнам видимости КА с учётом целевой длительности и дельты. */ import org.springframework.stereotype.Service +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.InsertionPoint -import space.nstart.pcp_tgu_service.domain.Plan import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import java.time.Duration import java.time.LocalDateTime @@ -18,7 +18,7 @@ class PlanCalculationService( private val overlapShiftByWindows: Int = 1 /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ - fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { + fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) @@ -36,7 +36,7 @@ class PlanCalculationService( * следующий план начинается с нахлестом на одну ЗРВ. * Если сопоставление не удалось, используется безопасный fallback без нахлеста. */ - fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: Plan): List { + fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) /** Индекс кандидата, с которого начинался предыдущий план. */ @@ -83,7 +83,7 @@ class PlanCalculationService( * Если ближайшая не попадает в целевое окно, всё равно выбирается * лучший доступный конец, наиболее близкий к целевой длительности. */ - fun calculateNearestPlanFrom(spacecraftPoints: SpacecraftPoints, fromTime: LocalDateTime): Plan? { + fun calculateNearestPlanFrom(spacecraftPoints: SpacecraftPoints, fromTime: LocalDateTime): CalculatedPlan? { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) /** Индекс ближайшей закладки, которая начинается не раньше указанного времени. */ @@ -99,7 +99,7 @@ class PlanCalculationService( startIndex = nearestStartIndex ) ?: return null - return candidates[nearestStartIndex].toPlan( + return candidates[nearestStartIndex].toCalculatedPlan( spacecraftId = spacecraftPoints.spacecraftId, endTime = candidates[endIndex].endTime ) @@ -110,13 +110,13 @@ class PlanCalculationService( spacecraftId: String, candidates: List, initialStartIndex: Int - ): List { + ): List { if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) { return emptyList() } /** Результирующая цепочка планов. */ - val resultPlans: MutableList = mutableListOf() + val resultPlans: MutableList = mutableListOf() /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */ var currentStartIndex: Int = initialStartIndex @@ -129,7 +129,7 @@ class PlanCalculationService( startIndex = currentStartIndex ) ?: break - resultPlans += currentCandidate.toPlan( + resultPlans += currentCandidate.toCalculatedPlan( spacecraftId = spacecraftId, endTime = candidates[endIndex].endTime ) @@ -275,9 +275,9 @@ class PlanCalculationService( /** Время окончания окна наблюдения. */ val endTime: LocalDateTime ) { - /** Преобразует кандидата в доменный план с заданным временем окончания. */ - fun toPlan(spacecraftId: String, endTime: LocalDateTime): Plan = - Plan( + /** Преобразует кандидата в расчётный план с заданным временем окончания. */ + fun toCalculatedPlan(spacecraftId: String, endTime: LocalDateTime): CalculatedPlan = + CalculatedPlan( spacecraftId = spacecraftId, startTime = startTime, endTime = endTime, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationService.kt deleted file mode 100644 index e793bee..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationService.kt +++ /dev/null @@ -1,16 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Фиксирует подтверждение плана только в tracked-хранилище. */ -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.util.UUID - -@Service -class PlanConfirmationService( - private val trackedPlanService: TrackedPlanService -) { - - /** Подтверждает план и сохраняет факт подтверждения в БД без дополнительной обработки. */ - @Transactional - fun confirm(planId: UUID) = trackedPlanService.confirm(planId) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProcessScheduler.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProcessScheduler.kt deleted file mode 100644 index bb43a21..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProcessScheduler.kt +++ /dev/null @@ -1,58 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Периодически запускает BPMN-процессы для планов, приближающихся к старту. */ -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.repository.PlanRepository -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Service -import java.time.LocalDateTime - -@Service -class PlanProcessScheduler( - private val planningProperties: PlanningProperties, - private val planRepository: PlanRepository, - private val trackedPlanService: TrackedPlanService, - private val satelliteMissionProcessService: SatelliteMissionProcessService -) { - - private val log = LoggerFactory.getLogger(javaClass) - - /** Просматривает ближайшие планы и запускает процессы для тех, что вошли в окно уведомления. */ - @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") - fun startProcessesForUpcomingPlans() { - val now = LocalDateTime.now() - val thresholdMinutes = planningProperties.notificationBeforeStartMinutes - - planRepository.findAll() - .filter { plan -> shouldStartProcess(plan, now, thresholdMinutes) } - .forEach { plan -> - try { - satelliteMissionProcessService.startCreateSatelliteMission(plan) - } catch (ex: Exception) { - log.warn( - "Failed to auto-start process for planId={}, spacecraftId={}", - plan.planId, - plan.spacecraftId, - ex - ) - } - } - } - - /** Проверяет, нужно ли в текущий момент ещё запускать процесс для плана. */ - private fun shouldStartProcess(plan: Plan, now: LocalDateTime, thresholdMinutes: Long): Boolean { - if (trackedPlanService.findConfirmedPlan(plan) != null) { - return false - } - - val trackedPlan = trackedPlanService.findByPlanId(plan.planId) - if (trackedPlan?.processInstanceKey != null) { - return false - } - - val startWindow = plan.startTime.minusMinutes(thresholdMinutes) - return now >= startWindow && now < plan.startTime - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index 505f6be..8ae5027 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -1,112 +1,40 @@ package space.nstart.pcp_tgu_service.service -/** Формирует представление планов для API, объединяя историю из tracked-хранилища и хвост из памяти. */ 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.domain.Plan -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import space.nstart.pcp_tgu_service.repository.PlanRepository +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import java.time.LocalDateTime import java.util.UUID +/** + * Read-only query service для persisted planned_plan. + */ @Service class PlanQueryService( - private val planRepository: PlanRepository, - private val trackedPlanService: TrackedPlanService + private val plannedPlanRepository: PlannedPlanRepository ) { - - /** Возвращает планы по всем КА, известным репозиторию планов в памяти. */ - fun getAllPlans(historyDays: Long): List = - ( - planRepository.findAll().map { plan -> plan.spacecraftId } + - trackedPlanService.getAllTrackedSpacecraftIds() - ) - .distinct() - .flatMap { spacecraftId -> getPlansBySpacecraftId(spacecraftId, historyDays) } - .sortedBy { plan -> plan.plan.startTime } - - /** Возвращает подтверждённую историю и будущие планы для указанного КА. */ - fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { - /** Текущий момент времени для отсечения прошлого и будущего. */ - val now: LocalDateTime = LocalDateTime.now() - /** Нормализованное количество дней истории, которое не может быть отрицательным. */ - val normalizedHistoryDays: Long = historyDays.coerceAtLeast(0) - val historySince = now.minusDays(normalizedHistoryDays) - /** Tracked-планы, которые уже участвуют в пользовательской выдаче. */ - val trackedPlansForView: List = trackedPlanService.getTrackedPlansBySpacecraftId(spacecraftId) - .filter { trackedPlan -> shouldIncludeInView(trackedPlan, now, historySince) } - .map { trackedPlan -> PlanView(plan = trackedPlan.toPlan(), status = trackedPlan.toViewStatus()) } - /** Набор tracked planId, чтобы не дублировать их in-memory планами. */ - val trackedPlanIds: Set = trackedPlansForView.map { planView -> planView.plan.planId }.toSet() - /** Будущие планы из памяти, которые ещё не вошли в tracked-хранилище. */ - val futureNewPlans: List = planRepository.findBySpacecraftId(spacecraftId) - .filter { plan -> plan.planId !in trackedPlanIds && plan.endTime >= now } - .map { plan -> PlanView(plan = plan, status = TrackedPlanStatus.NEW) } - - return (trackedPlansForView + futureNewPlans).sortedBy { planView -> planView.plan.startTime } + /** Возвращает все persisted планы с отсечением глубокой истории. */ + @Transactional(readOnly = true) + fun getAllPlans(historyDays: Long): List { + val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + return plannedPlanRepository.findAllByOrderByStartTime() + .filter { plan -> plan.endTime >= historySince } } - /** Возвращает ближайший будущий план для указанного КА или выбрасывает исключение, если его нет. */ - fun getNearestFuturePlan(spacecraftId: String, now: LocalDateTime = LocalDateTime.now()): Plan = - planRepository.findBySpacecraftId(spacecraftId) - .filter { plan -> plan.startTime >= now } - .minByOrNull { plan -> plan.startTime } - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "Nearest future plan for spacecraftId=$spacecraftId was not found" - ) - - /** - * Пытается переиспользовать уже существующий planId для плана с теми же бизнес-полями. - * - * Это защищает ручной запрос от размножения дубликатов, - * если один и тот же план запрашивают повторно. - */ - fun reuseExistingPlan(plan: Plan): Plan { - /** План из in-memory хранилища с теми же бизнес-полями, если он уже был рассчитан ранее. */ - val inMemoryPlan: Plan? = planRepository.findBySpacecraftId(plan.spacecraftId) - .firstOrNull { existingPlan -> - existingPlan.startTime == plan.startTime && - existingPlan.endTime == plan.endTime && - existingPlan.kppId == plan.kppId - } - - if (inMemoryPlan != null) { - return inMemoryPlan - } - - /** Последний tracked-план с теми же бизнес-полями, если такой уже создавался ранее. */ - val trackedPlan: Plan? = trackedPlanService.findLatestPlanByBusinessFields(plan) - return trackedPlan ?: plan + /** Возвращает persisted планы одного КА с отсечением глубокой истории. */ + @Transactional(readOnly = true) + fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { + val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + return plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) + .filter { plan -> plan.endTime >= historySince } } - data class PlanView( - val plan: Plan, - val status: TrackedPlanStatus - ) - - private fun shouldIncludeInView( - trackedPlan: TrackedPlanEntity, - now: LocalDateTime, - historySince: LocalDateTime - ): Boolean = - when (trackedPlan.status) { - TrackedPlanStatus.NEW -> trackedPlan.endTime >= now - TrackedPlanStatus.CONFIRMED -> trackedPlan.confirmedAt?.let { it >= historySince } ?: false - TrackedPlanStatus.WAITING_CONFIRMATION, - TrackedPlanStatus.SUPERSEDED -> trackedPlan.endTime >= now - } - - private fun TrackedPlanEntity.toViewStatus(): TrackedPlanStatus = status - - private fun TrackedPlanEntity.toPlan(): Plan = - Plan( - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId, - planId = planId - ) + /** Возвращает persisted plan по ID для test-only/debug API. */ + @Transactional(readOnly = true) + fun getByPlanId(planId: UUID): PlannedPlanEntity = + plannedPlanRepository.findById(planId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt new file mode 100644 index 0000000..1a59b39 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository +import java.time.LocalDateTime + +/** + * Coalesce-сервис для plan_recalculation_job по spacecraftId. + */ +@Service +class PlanRecalculationJobService( + private val planRecalculationJobRepository: PlanRecalculationJobRepository +) { + /** Ставит или переоткрывает job перестроения расписания без дублей. */ + @Transactional + fun enqueue(spacecraftId: String, reason: String) { + planRecalculationJobRepository.upsertPending(spacecraftId, reason, LocalDateTime.now()) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt new file mode 100644 index 0000000..a5879e2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt @@ -0,0 +1,85 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.entity.JobStatus +import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity +import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository +import java.net.InetAddress +import java.time.LocalDateTime + +/** + * Claim'ит jobs перестроения расписания и вызывает RebuildPlanScheduleUseCase. + */ +@Service +class PlanRecalculationWorker( + private val planningProperties: PlanningProperties, + private val planRecalculationJobRepository: PlanRecalculationJobRepository, + private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, + private val transactionTemplate: TransactionTemplate +) { + private val log = LoggerFactory.getLogger(javaClass) + private val workerId = "${InetAddress.getLocalHost().hostName}-${java.util.UUID.randomUUID()}" + + /** Периодически обрабатывает pending recalculation jobs. */ + @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") + fun processPendingJobs() { + val jobs = claimJobs(limit = 10) + jobs.forEach { job -> processJob(job.spacecraftId) } + } + + private fun claimJobs(limit: Int): List = + transactionTemplate.execute { + val now = LocalDateTime.now() + val jobs = planRecalculationJobRepository.findPendingForUpdate(now, limit) + jobs.forEach { job -> + job.status = JobStatus.RUNNING + job.lockedBy = workerId + job.lockedUntil = now.plusSeconds(planningProperties.workerLockTtlSeconds) + job.attempts += 1 + job.updatedAt = now + } + planRecalculationJobRepository.saveAll(jobs) + jobs + }.orEmpty() + + private fun processJob(spacecraftId: String) { + try { + rebuildPlanScheduleUseCase.rebuild(spacecraftId) + markDone(spacecraftId) + } catch (ex: Exception) { + log.warn("Plan recalculation job failed. spacecraftId={}", spacecraftId, ex) + releaseOrFail(spacecraftId) + } + } + + private fun markDone(spacecraftId: String) { + transactionTemplate.executeWithoutResult { + val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult + job.status = if (job.rerunRequested) JobStatus.PENDING else JobStatus.DONE + job.rerunRequested = false + job.lockedBy = null + job.lockedUntil = null + job.updatedAt = LocalDateTime.now() + planRecalculationJobRepository.save(job) + } + } + + private fun releaseOrFail(spacecraftId: String) { + transactionTemplate.executeWithoutResult { + val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult + job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING + job.lockedBy = null + job.lockedUntil = null + job.updatedAt = LocalDateTime.now() + planRecalculationJobRepository.save(job) + } + } + + private companion object { + const val MAX_ATTEMPTS = 3 + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorker.kt deleted file mode 100644 index 0f13711..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorker.kt +++ /dev/null @@ -1,32 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import io.camunda.client.annotation.JobWorker -import io.camunda.client.api.response.ActivatedJob -import io.camunda.client.api.worker.JobClient -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class PlanStatusWorker( - private val planConfirmationService: PlanConfirmationService -) { - - private val log = LoggerFactory.getLogger(javaClass) - - @JobWorker(type = "updatePlanStatus", autoComplete = false) - fun updatePlanStatus(client: JobClient, job: ActivatedJob) { - val planId = job.variablesAsMap["satellitePlanId"]?.toString() - ?.takeIf { it.isNotBlank() } - ?.let(UUID::fromString) - ?: throw IllegalArgumentException("Camunda job variable satellitePlanId is missing or blank") - - planConfirmationService.confirm(planId) - - client.newCompleteCommand(job.key) - .send() - .join() - - log.info("Tracked plan marked as confirmed. planId={}, jobKey={}", planId, job.key) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt new file mode 100644 index 0000000..dc6bfa0 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt @@ -0,0 +1,113 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * Перестраивает persisted расписание планов из последнего raw visibility snapshot. + */ +@Service +class RebuildPlanScheduleUseCase( + private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val plannedPlanRepository: PlannedPlanRepository, + private val visibilityPayloadParser: VisibilityPayloadParser, + private val planCalculationService: PlanCalculationService, + private val planningProperties: PlanningProperties +) { + /** Rebuild по одному КА: upsert сохраняет стабильный planId для business key. */ + @Transactional + fun rebuild(spacecraftId: String) { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId) + .orElseThrow { IllegalStateException("Visibility snapshot for spacecraftId=$spacecraftId was not found") } + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + val state = getOrCreateState(spacecraftId) + val basePlan = state.currentBasePlanId?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + + val calculatedPlans = if (basePlan == null) { + planCalculationService.calculatePlans(points) + } else { + planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) + } + + val now = LocalDateTime.now() + val chainVersion = state.chainVersion + 1 + val savedPlans = calculatedPlans.map { calculatedPlan -> + upsertPlan(calculatedPlan, state.currentBasePlanId, chainVersion, now) + } + + supersedeObsoleteFuturePlans( + spacecraftId = spacecraftId, + calculatedBusinessKeys = savedPlans.map { plan -> plan.businessKey() }.toSet(), + now = now + ) + state.chainVersion = chainVersion + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + } + + private fun getOrCreateState(spacecraftId: String): SpacecraftPlanningStateEntity { + spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) + ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) + return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId) + ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create") + } + + private fun upsertPlan( + calculatedPlan: CalculatedPlan, + basePlanId: UUID?, + chainVersion: Long, + now: LocalDateTime + ): PlannedPlanEntity = + plannedPlanRepository.upsertPlannedByBusinessKey( + planId = UUID.randomUUID(), + spacecraftId = calculatedPlan.spacecraftId, + startTime = calculatedPlan.startTime, + endTime = calculatedPlan.endTime, + kppId = calculatedPlan.kppId, + issueNotBefore = calculatedPlan.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes), + basePlanId = basePlanId, + chainVersion = chainVersion, + now = now + ) + + private fun supersedeObsoleteFuturePlans( + spacecraftId: String, + calculatedBusinessKeys: Set, + now: LocalDateTime + ) { + plannedPlanRepository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + spacecraftId = spacecraftId, + status = PlannedPlanStatus.PLANNED, + startTime = now + ) + .filter { plan -> plan.businessKey() !in calculatedBusinessKeys } + .forEach { plan -> + plan.status = PlannedPlanStatus.SUPERSEDED + plan.updatedAt = now + plannedPlanRepository.save(plan) + } + } + + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = + CalculatedPlan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId + ) + + private fun PlannedPlanEntity.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt deleted file mode 100644 index 88ade1f..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt +++ /dev/null @@ -1,174 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Запускает и отслеживает Camunda-процесс, который формирует и отправляет спутниковый план. */ -import io.camunda.client.CamundaClient -import org.slf4j.LoggerFactory -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.web.server.ResponseStatusException -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables -import space.nstart.pcp_tgu_service.dto.ProcessStartResponse -import java.time.LocalDateTime -import java.util.UUID - -@Service -class SatelliteMissionProcessService( - private val camundaClient: CamundaClient, - private val platformService: PlatformService, - private val spacecraftPointsService: SpacecraftPointsService, - private val planCalculationService: PlanCalculationService, - private val planQueryService: PlanQueryService, - private val trackedPlanService: TrackedPlanService -) { - - private val log = LoggerFactory.getLogger(javaClass) - - /** - * Ручной запрос плана. - * - * План строится строго от ближайшей закладки, начиная от текущего момента, - * с подбором конца только в пределах целевой длительности и настроенной дельты. - */ - fun startCreateSatelliteMission(noradId: Long): ProcessStartResponse { - return startCreateSatelliteMission(noradId, StartMode.AUTO) - } - - /** Ручной запрос плана с принудительной перевыдачей нового planId. */ - fun startCreateSatelliteMissionManually(noradId: Long): ProcessStartResponse { - return startCreateSatelliteMission(noradId, StartMode.MANUAL) - } - - private fun startCreateSatelliteMission(noradId: Long, startMode: StartMode): ProcessStartResponse { - ensurePlatformExists(noradId) - - /** Идентификатор КА в строковом виде, используемый внутри сервиса. */ - val spacecraftId: String = noradId.toString() - /** Текущее время, относительно которого ищется ближайшая закладка. */ - val now: LocalDateTime = LocalDateTime.now() - /** Сохраненные окна наблюдения для указанного КА. */ - val spacecraftPoints = spacecraftPointsService.getSpacecraftPointsBySpacecraftId(spacecraftId) - /** План, построенный строго от ближайшей закладки. */ - val calculatedPlan: Plan = planCalculationService.calculateNearestPlanFrom(spacecraftPoints, now) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "Plan from nearest insertion point for spacecraftId=$spacecraftId was not found" - ) - /** План с переиспользованным planId, если такой бизнес-план уже существует в памяти или в tracked-хранилище. */ - val requestedPlan: Plan = when (startMode) { - StartMode.AUTO -> planQueryService.reuseExistingPlan(calculatedPlan) - StartMode.MANUAL -> calculatedPlan.copy(planId = UUID.randomUUID()) - } - - return startCreateSatelliteMission(requestedPlan, startMode) - } - - /** Запускает процесс для указанного плана и сохраняет ключ созданного экземпляра процесса. */ - fun startCreateSatelliteMission(plan: Plan): ProcessStartResponse { - return startCreateSatelliteMission(plan, StartMode.AUTO) - } - - private fun startCreateSatelliteMission(plan: Plan, startMode: StartMode): ProcessStartResponse { - /** Идентификатор КА, для которого запускается процесс. */ - val spacecraftId: String = plan.spacecraftId - /** NORAD-идентификатор, который требуется для проверки существования платформы. */ - val noradId: Long = spacecraftId.toLongOrNull() - ?: throw ResponseStatusException( - HttpStatus.BAD_REQUEST, - "spacecraftId=$spacecraftId cannot be converted to noradId" - ) - - ensurePlatformExists(noradId) - - /** Полный набор точек наблюдения, передаваемый в процесс создания миссии. */ - val message = spacecraftPointsService.getMessageBySpacecraftId(spacecraftId) - - when (startMode) { - StartMode.AUTO -> { - // Повторная отправка допустима, пока план не подтвержден. - // Блокируем только запуск процесса для уже подтвержденного плана. - trackedPlanService.findConfirmedPlan(plan)?.let { confirmedPlan -> - throw ResponseStatusException( - HttpStatus.CONFLICT, - "Plan with planId=${confirmedPlan.planId} is already confirmed and cannot be started again for spacecraftId=$spacecraftId" - ) - } - } - StartMode.MANUAL -> { - trackedPlanService.findLatestTrackedPlanByBusinessFields(plan) - ?.takeIf { trackedPlan -> trackedPlan.status != space.nstart.pcp_tgu_service.entity.TrackedPlanStatus.SUPERSEDED } - ?.let { existingPlan -> - trackedPlanService.markSuperseded(existingPlan.planId, plan.planId) - } - } - } - - trackedPlanService.register( - plan = plan, - source = when (startMode) { - StartMode.AUTO -> space.nstart.pcp_tgu_service.entity.TrackedPlanSource.AUTO_CHAIN - StartMode.MANUAL -> space.nstart.pcp_tgu_service.entity.TrackedPlanSource.MANUAL - } - ) - - /** Набор process variables для BPMN-процесса. */ - val processVariables: CreateSatelliteMissionVariables = CreateSatelliteMissionVariables( - spacecraftId = message.spacecraftId, - satellitePlanId = plan.planId.toString(), - planStartTime = plan.startTime, - planEndTime = plan.endTime, - kppId = plan.kppId - ) - - /** Событие созданного экземпляра процесса, возвращаемое Camunda. */ - val processInstance = camundaClient - .newCreateInstanceCommand() - .bpmnProcessId(CREATE_SATELLITE_MISSION_PROCESS_ID) - .latestVersion() - .variables(processVariables) - .send() - .join() - - trackedPlanService.attachProcessInstance(plan.planId, processInstance.processInstanceKey) - - log.info( - "Started BPMN process. bpmnProcessId={}, processInstanceKey={}, spacecraftId={}, planId={}", - processInstance.bpmnProcessId, - processInstance.processInstanceKey, - message.spacecraftId, - plan.planId - ) - - return ProcessStartResponse( - processInstanceKey = processInstance.processInstanceKey, - processDefinitionKey = processInstance.processDefinitionKey, - bpmnProcessId = processInstance.bpmnProcessId, - version = processInstance.version, - planId = plan.planId - ) - } - - /** Проверяет, что КА присутствует во внешнем классификаторе платформ. */ - private fun ensurePlatformExists(noradId: Long) { - /** Признак того, что платформа с указанным NORAD-идентификатором существует во внешнем классификаторе. */ - val platformExists: Boolean = platformService.loadAllowedPlatforms() - .any { platform -> platform.noradId == noradId } - - if (!platformExists) { - throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "Platform with noradId=$noradId was not found" - ) - } - } - - /** Содержит BPMN-идентификаторы, используемые этим сервисом. */ - private companion object { - const val CREATE_SATELLITE_MISSION_PROCESS_ID = "createSatelliteMission" - } - - private enum class StartMode { - AUTO, - MANUAL - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPlanningStateService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPlanningStateService.kt deleted file mode 100644 index 1ea0c2d..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPlanningStateService.kt +++ /dev/null @@ -1,32 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Управляет сохранённым состоянием планирования для каждого КА. */ -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.LocalDateTime -import java.util.UUID - -@Service -class SpacecraftPlanningStateService( - private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository -) { - - /** Возвращает идентификатор текущего подтверждённого базового плана для КА, если он есть. */ - @Transactional(readOnly = true) - fun getCurrentBasePlanId(spacecraftId: String): UUID? = - spacecraftPlanningStateJpaRepository.findById(spacecraftId) - .orElse(null) - ?.currentBasePlanId - - /** Сохраняет идентификатор текущего подтверждённого базового плана для КА. */ - @Transactional - fun setCurrentBasePlanId(spacecraftId: String, planId: UUID) { - val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId) - .orElse(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) - state.currentBasePlanId = planId - state.updatedAt = LocalDateTime.now() - spacecraftPlanningStateJpaRepository.save(state) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsService.kt deleted file mode 100644 index c7cacb9..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsService.kt +++ /dev/null @@ -1,136 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Хранит данные видимости КА и перестраивает по ним текущую цепочку планов. */ -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.web.server.ResponseStatusException -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.InsertionPoint -import space.nstart.pcp_tgu_service.domain.ObservationWindow -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.dto.ObservationWindowMessage -import space.nstart.pcp_tgu_service.dto.PointMessage -import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage -import space.nstart.pcp_tgu_service.repository.PlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository -import java.time.LocalDateTime - -@Service -class SpacecraftPointsService( - private val spacecraftPointsRepository: SpacecraftPointsRepository, - private val planRepository: PlanRepository, - private val planCalculationService: PlanCalculationService, - private val trackedPlanService: TrackedPlanService, - private val spacecraftPlanningStateService: SpacecraftPlanningStateService, - private val planningProperties: PlanningProperties -) { - - /** Сохраняет точки КА и сразу пересчитывает цепочку планов в памяти. */ - fun saveAndRecalculate(spacecraftPoints: SpacecraftPoints) { - spacecraftPointsRepository.save(spacecraftPoints) - rebuildActualPlans(spacecraftPoints.spacecraftId, spacecraftPoints) - } - - /** Перестраивает цепочку в памяти по уже сохранённым точкам указанного КА. */ - fun rebuildActualPlans(spacecraftId: String) { - /** Уже сохраненные точки наблюдения для указанного КА. */ - val spacecraftPoints: SpacecraftPoints = getSpacecraftPointsBySpacecraftId(spacecraftId) - rebuildActualPlans(spacecraftId, spacecraftPoints) - } - - /** Преобразует Kafka-сообщение в доменную модель, сохраняет его и перестраивает цепочку планов. */ - fun saveFromMessage(message: SpacecraftPointsMessage) { - /** Доменные данные по окнам наблюдения, собранные из входного Kafka-сообщения. */ - val domainModel: SpacecraftPoints = SpacecraftPoints( - spacecraftId = message.spacecraftId, - points = message.points.map { pointMessage -> - InsertionPoint( - pointId = pointMessage.pointId, - observationWindows = pointMessage.observationWindows.map { windowMessage -> - ObservationWindow( - startTime = windowMessage.startTime, - endTime = windowMessage.endTime, - elevationAngle = windowMessage.elevationAngle - ) - } - ) - } - ) - - saveAndRecalculate(domainModel) - } - - /** Возвращает сохранённые точки КА или выбрасывает `404 NOT_FOUND`, если их нет. */ - fun getSpacecraftPointsBySpacecraftId(spacecraftId: String): SpacecraftPoints = - spacecraftPointsRepository.findBySpacecraftId(spacecraftId) - ?: throw ResponseStatusException( - HttpStatus.NOT_FOUND, - "Spacecraft points for spacecraftId=$spacecraftId were not found" - ) - - /** Преобразует сохранённые точки КА обратно в DTO-формат для Kafka. */ - fun getMessageBySpacecraftId(spacecraftId: String): SpacecraftPointsMessage { - /** Сохраненные точки наблюдения для указанного КА. */ - val spacecraftPoints: SpacecraftPoints = getSpacecraftPointsBySpacecraftId(spacecraftId) - - return SpacecraftPointsMessage( - spacecraftId = spacecraftPoints.spacecraftId, - points = spacecraftPoints.points.map { point -> - PointMessage( - pointId = point.pointId, - observationWindows = point.observationWindows.map { window -> - ObservationWindowMessage( - startTime = window.startTime, - endTime = window.endTime, - elevationAngle = window.elevationAngle - ) - } - ) - } - ) - } - - /** Перестраивает актуальные планы, объединяя подтверждённую историю с пересчитанным будущим хвостом. */ - private fun rebuildActualPlans(spacecraftId: String, spacecraftPoints: SpacecraftPoints) { - /** Текущий момент времени для отсечения исторической части. */ - val now: LocalDateTime = LocalDateTime.now() - /** Подтвержденные планы, которые еще нужно показывать как историю. */ - val confirmedHistory: List = trackedPlanService.getConfirmedPlansBySpacecraftIdSince( - spacecraftId = spacecraftId, - since = now.minusDays(planningProperties.inMemoryHistoryDays) - ) - /** Базовый план из legacy-состояния, если он ранее был зафиксирован в БД. */ - val basePlan: Plan? = spacecraftPlanningStateService.getCurrentBasePlanId(spacecraftId) - ?.let(trackedPlanService::findByPlanId) - ?.let { trackedPlan -> - Plan( - spacecraftId = trackedPlan.spacecraftId, - startTime = trackedPlan.startTime, - endTime = trackedPlan.endTime, - kppId = trackedPlan.kppId, - planId = trackedPlan.planId - ) - } - - /** Пересчитанный хвост будущих планов. */ - val futurePlans: List = if (basePlan == null) { - planCalculationService.calculatePlans(spacecraftPoints) - } else { - planCalculationService.calculatePlansAfter(spacecraftPoints, basePlan) - } - - /** Набор бизнес-ключей подтвержденных планов, чтобы не дублировать их в хвосте. */ - val confirmedKeys: Set = confirmedHistory.map { confirmedPlan -> confirmedPlan.toBusinessKey() }.toSet() - /** Итоговая объединенная цепочка для хранения в памяти. */ - val mergedPlans: List = confirmedHistory + futurePlans.filter { plan -> - plan.toBusinessKey() !in confirmedKeys - } - - planRepository.replacePlans(spacecraftId, mergedPlans) - } - - /** Строит стабильный бизнес-ключ для подавления дублей после пересчёта. */ - private fun Plan.toBusinessKey(): String = - "$spacecraftId|$startTime|$endTime|$kppId" -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt new file mode 100644 index 0000000..2836a6e --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt @@ -0,0 +1,91 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.security.MessageDigest +import java.time.LocalDateTime + +/** + * Сохраняет raw visibility payload и ставит recalculation только при изменении hash. + */ +@Service +class SpacecraftVisibilitySnapshotService( + private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, + private val visibilityPayloadParser: VisibilityPayloadParser, + private val planRecalculationJobService: PlanRecalculationJobService +) { + /** + * Сохраняет snapshot. Recalculation job создаётся только для нового или изменившегося payload. + */ + @Transactional + fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) { + val payloadHash = sha256(payloadJson) + val existing = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + val metadata = runCatching { extractMetadata(spacecraftId, payloadJson) }.getOrNull() + val now = LocalDateTime.now() + + if (existing == null) { + spacecraftVisibilitySnapshotRepository.save( + SpacecraftVisibilitySnapshotEntity( + spacecraftId = spacecraftId, + noradId = noradId, + payloadJson = payloadJson, + payloadHash = payloadHash, + fetchedAt = fetchedAt, + visibilityFrom = metadata?.visibilityFrom, + visibilityTo = metadata?.visibilityTo, + windowsCount = metadata?.windowsCount, + createdAt = now, + updatedAt = now + ) + ) + planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") + return + } + + val changed = existing.payloadHash != payloadHash + existing.noradId = noradId + existing.fetchedAt = fetchedAt + existing.updatedAt = now + + if (changed) { + existing.payloadJson = payloadJson + existing.payloadHash = payloadHash + existing.visibilityFrom = metadata?.visibilityFrom + existing.visibilityTo = metadata?.visibilityTo + existing.windowsCount = metadata?.windowsCount + } + + spacecraftVisibilitySnapshotRepository.save(existing) + + if (changed) { + planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") + } + } + + private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata { + val windows = visibilityPayloadParser.parse(spacecraftId, payloadJson) + .points + .flatMap { point -> point.observationWindows } + + return VisibilityMetadata( + visibilityFrom = windows.minOfOrNull { window -> window.startTime }, + visibilityTo = windows.maxOfOrNull { window -> window.endTime }, + windowsCount = windows.size + ) + } + + private fun sha256(value: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return bytes.joinToString(separator = "") { byte -> "%02x".format(byte) } + } + + private data class VisibilityMetadata( + val visibilityFrom: LocalDateTime?, + val visibilityTo: LocalDateTime?, + val windowsCount: Int + ) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt new file mode 100644 index 0000000..10f7d9a --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt @@ -0,0 +1,56 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.time.ZoneId + +/** + * Синхронизирует разрешённые КА из НСИ в БД и ставит refresh jobs. + */ +@Service +class SyncSpacecraftFromNsiUseCase( + private val platformService: PlatformService, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val visibilityRefreshJobService: VisibilityRefreshJobService, + private val transactionTemplate: TransactionTemplate +) { + /** Загружает allowed platforms из НСИ, сохраняет snapshot и coalesce'ит refresh jobs. */ + fun syncAllowedSpacecraft() { + val allowedPlatforms = platformService.loadAllowedPlatforms() + transactionTemplate.executeWithoutResult { + persistAllowedSpacecraft(allowedPlatforms) + } + } + + private fun persistAllowedSpacecraft(allowedPlatforms: List) { + val now = LocalDateTime.now() + val allowedSpacecraftIds = allowedPlatforms.mapNotNull { platform -> platform.noradId?.toString() } + + allowedPlatforms.forEach { platform -> + val noradId = platform.noradId ?: return@forEach + val spacecraftId = noradId.toString() + spacecraftSnapshotRepository.upsertFromNsi( + spacecraftId = spacecraftId, + noradId = noradId, + nsiId = platform.nsiId, + businessKey = platform.businessKey, + name = platform.name, + status = platform.status, + mission = platform.mission, + validFrom = platform.validFrom?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), + validTo = platform.validTo?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), + now = now + ) + visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC") + } + + if (allowedSpacecraftIds.isEmpty()) { + spacecraftSnapshotRepository.deactivateAllActive(now) + } else { + spacecraftSnapshotRepository.deactivateMissing(allowedSpacecraftIds, now) + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TrackedPlanService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TrackedPlanService.kt deleted file mode 100644 index bc9b23a..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TrackedPlanService.kt +++ /dev/null @@ -1,179 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -/** Сохраняет и обновляет состояние жизненного цикла отслеживаемых планов в БД. */ -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanSource -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.server.ResponseStatusException -import java.time.LocalDateTime -import java.util.UUID - -@Service -class TrackedPlanService( - private val trackedPlanJpaRepository: TrackedPlanJpaRepository -) { - - /** Регистрирует план в хранилище отслеживания, если он ещё не был зарегистрирован. */ - @Transactional - fun register( - plan: Plan, - source: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN, - basePlanId: UUID? = null - ): TrackedPlanEntity = - trackedPlanJpaRepository.findById(plan.planId).orElseGet { - trackedPlanJpaRepository.save( - TrackedPlanEntity( - planId = plan.planId, - spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, - kppId = plan.kppId, - planSource = source, - basePlanId = basePlanId - ) - ) - } - - /** Привязывает ключ запущенного экземпляра процесса к отслеживаемому плану. */ - @Transactional - fun attachProcessInstance(planId: UUID, processInstanceKey: Long): TrackedPlanEntity { - val trackedPlan = getByPlanId(planId) - trackedPlan.processInstanceKey = processInstanceKey - return trackedPlanJpaRepository.save(trackedPlan) - } - - /** Помечает отслеживаемый план как подтверждённый и сохраняет время подтверждения. */ - @Transactional - fun confirm(planId: UUID): TrackedPlanEntity { - val trackedPlan = getByPlanId(planId) - if (trackedPlan.status == TrackedPlanStatus.CONFIRMED) { - return trackedPlan - } - - trackedPlan.status = TrackedPlanStatus.CONFIRMED - trackedPlan.confirmedAt = LocalDateTime.now() - return trackedPlanJpaRepository.save(trackedPlan) - } - - /** Помечает остальные ожидающие планы КА как вытесненные подтверждённым планом. */ - @Transactional - fun markWaitingPlansSuperseded(spacecraftId: String, supersededByPlanId: UUID) { - val waitingPlans = trackedPlanJpaRepository.findAllBySpacecraftIdAndStatus( - spacecraftId = spacecraftId, - status = TrackedPlanStatus.WAITING_CONFIRMATION - ).filter { trackedPlan -> trackedPlan.planId != supersededByPlanId } - - waitingPlans.forEach { trackedPlan -> - trackedPlan.status = TrackedPlanStatus.SUPERSEDED - trackedPlan.supersededByPlanId = supersededByPlanId - } - - trackedPlanJpaRepository.saveAll(waitingPlans) - } - - /** Ищет подтверждённый отслеживаемый план, совпадающий с переданным планом по бизнес-полям. */ - @Transactional(readOnly = true) - fun findConfirmedPlan(plan: Plan): TrackedPlanEntity? = - trackedPlanJpaRepository.findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus( - spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, - kppId = plan.kppId, - status = TrackedPlanStatus.CONFIRMED - ) - - /** Возвращает подтверждённые планы КА, начиная с указанного момента времени. */ - @Transactional(readOnly = true) - fun getConfirmedPlansBySpacecraftIdSince(spacecraftId: String, since: LocalDateTime): List = - trackedPlanJpaRepository.findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime( - spacecraftId = spacecraftId, - status = TrackedPlanStatus.CONFIRMED, - confirmedAt = since - ).map { it.toPlan() } - - /** Возвращает последний tracked-план с теми же бизнес-полями, если он уже существует. */ - @Transactional(readOnly = true) - fun findLatestPlanByBusinessFields(plan: Plan): Plan? = - trackedPlanJpaRepository.findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( - spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, - kppId = plan.kppId - )?.toPlan() - - /** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */ - @Transactional(readOnly = true) - fun findLatestTrackedPlanByBusinessFields(plan: Plan): TrackedPlanEntity? = - trackedPlanJpaRepository.findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( - spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, - kppId = plan.kppId - ) - - /** Возвращает все tracked-планы указанного КА. */ - @Transactional(readOnly = true) - fun getTrackedPlansBySpacecraftId(spacecraftId: String): List = - trackedPlanJpaRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) - - /** Возвращает идентификаторы КА, для которых есть tracked-планы. */ - @Transactional(readOnly = true) - fun getAllTrackedSpacecraftIds(): List = - trackedPlanJpaRepository.findAll() - .map { trackedPlan -> trackedPlan.spacecraftId } - .distinct() - - /** Помечает план как заменённый более новым планом. */ - @Transactional - fun markSuperseded(planId: UUID, supersededByPlanId: UUID): TrackedPlanEntity { - val trackedPlan = getByPlanId(planId) - if (trackedPlan.status == TrackedPlanStatus.SUPERSEDED && - trackedPlan.supersededByPlanId == supersededByPlanId - ) { - return trackedPlan - } - - trackedPlan.status = TrackedPlanStatus.SUPERSEDED - trackedPlan.supersededByPlanId = supersededByPlanId - return trackedPlanJpaRepository.save(trackedPlan) - } - - /** Возвращает последний подтверждённый план КА, если он существует. */ - @Transactional(readOnly = true) - fun getLatestConfirmedPlan(spacecraftId: String): Plan? = - trackedPlanJpaRepository.findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc( - spacecraftId = spacecraftId, - status = TrackedPlanStatus.CONFIRMED - )?.toPlan() - - /** Возвращает отслеживаемый план по идентификатору или `null`, если он отсутствует. */ - @Transactional(readOnly = true) - fun findByPlanId(planId: UUID): TrackedPlanEntity? = - trackedPlanJpaRepository.findById(planId).orElse(null) - - /** Возвращает отслеживаемый план по идентификатору или выбрасывает ошибку `404 NOT_FOUND`. */ - @Transactional(readOnly = true) - fun getByPlanId(planId: UUID): TrackedPlanEntity = - trackedPlanJpaRepository.findById(planId) - .orElseThrow { - ResponseStatusException( - HttpStatus.NOT_FOUND, - "Tracked plan with planId=$planId was not found" - ) - } - - /** Преобразует persistence-сущность отслеживаемого плана в доменную модель `Plan`. */ - private fun TrackedPlanEntity.toPlan(): Plan = - Plan( - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId, - planId = planId - ) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt new file mode 100644 index 0000000..79dd8fc --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshJobService.kt @@ -0,0 +1,20 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository +import java.time.LocalDateTime + +/** + * Coalesce-сервис для visibility_refresh_job по spacecraftId. + */ +@Service +class VisibilityRefreshJobService( + private val visibilityRefreshJobRepository: VisibilityRefreshJobRepository +) { + /** Ставит или переоткрывает refresh job без дублей. */ + @Transactional + fun enqueue(spacecraftId: String, noradId: Long?, reason: String) { + visibilityRefreshJobRepository.upsertPending(spacecraftId, noradId, reason, LocalDateTime.now()) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt new file mode 100644 index 0000000..8c62d17 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/VisibilityRefreshWorker.kt @@ -0,0 +1,113 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.entity.JobStatus +import space.nstart.pcp_tgu_service.entity.VisibilityRefreshJobEntity +import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository +import java.net.InetAddress +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +/** + * Claim'ит visibility_refresh_job, читает raw payload у баллистики и сохраняет snapshot. + */ +@Service +class VisibilityRefreshWorker( + private val planningProperties: PlanningProperties, + private val visibilityRefreshJobRepository: VisibilityRefreshJobRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val externalPointsClient: ExternalPointsClient, + private val spacecraftVisibilitySnapshotService: SpacecraftVisibilitySnapshotService, + private val transactionTemplate: TransactionTemplate +) { + private val log = LoggerFactory.getLogger(javaClass) + private val workerId = "${InetAddress.getLocalHost().hostName}-${java.util.UUID.randomUUID()}" + + /** Периодически обрабатывает refresh jobs; один job claim'ится только одним pod. */ + @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") + fun processPendingJobs() { + val jobs = claimJobs(limit = 10) + jobs.forEach { job -> processJob(job.spacecraftId) } + } + + private fun claimJobs(limit: Int): List = + transactionTemplate.execute { + val now = LocalDateTime.now() + val jobs = visibilityRefreshJobRepository.findPendingForUpdate(now, limit) + jobs.forEach { job -> + job.status = JobStatus.RUNNING + job.lockedBy = workerId + job.lockedUntil = now.plusSeconds(planningProperties.workerLockTtlSeconds) + job.attempts += 1 + job.updatedAt = now + } + visibilityRefreshJobRepository.saveAll(jobs) + jobs + }.orEmpty() + + private fun processJob(spacecraftId: String) { + val job = visibilityRefreshJobRepository.findById(spacecraftId).orElse(null) ?: return + val noradId = job.noradId + ?: spacecraftSnapshotRepository.findById(spacecraftId).orElse(null)?.noradId + ?: run { + markFailed(spacecraftId, "Norad id is missing") + return + } + + try { + val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) + val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) + val payload = externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) + spacecraftVisibilitySnapshotService.saveSnapshot(spacecraftId, noradId, payload, LocalDateTime.now()) + markDone(spacecraftId) + } catch (ex: Exception) { + log.warn("Visibility refresh job failed. spacecraftId={}, noradId={}", spacecraftId, noradId, ex) + releaseOrFail(spacecraftId) + } + } + + private fun markDone(spacecraftId: String) { + transactionTemplate.executeWithoutResult { + val job = visibilityRefreshJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult + job.status = if (job.rerunRequested) JobStatus.PENDING else JobStatus.DONE + job.rerunRequested = false + job.lockedBy = null + job.lockedUntil = null + job.updatedAt = LocalDateTime.now() + visibilityRefreshJobRepository.save(job) + } + } + + private fun releaseOrFail(spacecraftId: String) { + transactionTemplate.executeWithoutResult { + val job = visibilityRefreshJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult + job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING + job.lockedBy = null + job.lockedUntil = null + job.updatedAt = LocalDateTime.now() + visibilityRefreshJobRepository.save(job) + } + } + + private fun markFailed(spacecraftId: String, reason: String) { + transactionTemplate.executeWithoutResult { + val job = visibilityRefreshJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult + job.status = JobStatus.FAILED + job.reason = reason + job.lockedBy = null + job.lockedUntil = null + job.updatedAt = LocalDateTime.now() + visibilityRefreshJobRepository.save(job) + } + } + + private companion object { + const val MAX_ATTEMPTS = 3 + } +} diff --git a/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn b/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn index 5c8329f..d90f168 100644 --- a/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn +++ b/services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn @@ -1,124 +1,70 @@ - - Flow_15fg4ts - - - - - - - - - - Flow_0m0ymgb - Flow_0r63sxk - - - Flow_105c46r + Flow_start_survey - + - + - Flow_0r63sxk - Flow_15tmgj4 - - - - - - Flow_15tmgj4 - Flow_15fg4ts - - - - - - Flow_141jv88 - Flow_0m0ymgb + Flow_start_survey + Flow_survey_drop - Flow_0i87yhe - Flow_141jv88 + Flow_survey_drop + Flow_drop_send - + - + - Flow_105c46r - Flow_0i87yhe + Flow_drop_send + Flow_send_end + + Flow_send_end + + + + + - - - - - - - - - - - + + + + + + - - - - - - - - - - - - - - - - + - - - - - - - - - + - + + + + + - - - - - - - - - - - + + + diff --git a/services/pcp-tgu-service/src/main/resources/BPMN/updateAcceptedPlanSlots.bpmn b/services/pcp-tgu-service/src/main/resources/BPMN/updateAcceptedPlanSlots.bpmn new file mode 100644 index 0000000..784fbb2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/BPMN/updateAcceptedPlanSlots.bpmn @@ -0,0 +1,41 @@ + + + + + Flow_start_update_slots + + + + + + Flow_start_update_slots + Flow_update_slots_end + + + Flow_update_slots_end + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 1c969ed..903007d 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -11,3 +11,23 @@ spring: fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} + +planning: + notification-before-start-minutes: 263 + target-plan-duration-minutes: 180 + target-plan-duration-delta-minutes: 60 + external-update-enabled: true + external-points-lookahead-days: 3 + in-memory-history-days: 14 + external-update-fixed-delay-ms: 600000 + notification-check-fixed-delay-ms: 30000 + min-reissue-lead-minutes: 5 + worker-lock-ttl-seconds: 300 + +topics: + visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 + satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + +tgu: + test-controller: + enabled: false diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql new file mode 100644 index 0000000..ce66f0b --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql @@ -0,0 +1,105 @@ +CREATE TABLE IF NOT EXISTS spacecraft_snapshot ( + spacecraft_id VARCHAR(255) PRIMARY KEY, + norad_id BIGINT NOT NULL, + nsi_id VARCHAR(255), + business_key VARCHAR(255), + name VARCHAR(1024), + status VARCHAR(255), + mission VARCHAR(1024), + valid_from TIMESTAMP, + valid_to TIMESTAMP, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS spacecraft_visibility_snapshot ( + spacecraft_id VARCHAR(255) PRIMARY KEY, + norad_id BIGINT NOT NULL, + payload_json TEXT NOT NULL, + payload_hash VARCHAR(128) NOT NULL, + fetched_at TIMESTAMP NOT NULL, + visibility_from TIMESTAMP, + visibility_to TIMESTAMP, + windows_count INTEGER, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS planned_plan ( + plan_id UUID PRIMARY KEY, + spacecraft_id VARCHAR(255) NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + kpp_id VARCHAR(255) NOT NULL, + status VARCHAR(64) NOT NULL, + issue_not_before TIMESTAMP, + base_plan_id UUID, + chain_version BIGINT NOT NULL DEFAULT 0, + accepted_at TIMESTAMP, + superseded_by_plan_id UUID, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uq_planned_plan_business_key UNIQUE (spacecraft_id, start_time, end_time, kpp_id) +); + +CREATE INDEX IF NOT EXISTS idx_planned_plan_due + ON planned_plan (status, issue_not_before, start_time); + +CREATE INDEX IF NOT EXISTS idx_planned_plan_spacecraft_start + ON planned_plan (spacecraft_id, start_time); + +CREATE TABLE IF NOT EXISTS plan_issue_attempt ( + attempt_id UUID PRIMARY KEY, + plan_id UUID NOT NULL REFERENCES planned_plan(plan_id), + attempt_no INTEGER NOT NULL, + status VARCHAR(64) NOT NULL, + process_instance_key BIGINT, + decision_event_id UUID, + decision_reason TEXT, + started_at TIMESTAMP, + finished_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + CONSTRAINT uq_plan_issue_attempt_no UNIQUE (plan_id, attempt_no) +); + +CREATE INDEX IF NOT EXISTS idx_plan_issue_attempt_plan + ON plan_issue_attempt (plan_id, attempt_no); + +CREATE TABLE IF NOT EXISTS visibility_refresh_job ( + spacecraft_id VARCHAR(255) PRIMARY KEY, + norad_id BIGINT, + status VARCHAR(64) NOT NULL, + reason VARCHAR(255), + locked_by VARCHAR(255), + locked_until TIMESTAMP, + attempts INTEGER NOT NULL DEFAULT 0, + requested_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS plan_recalculation_job ( + spacecraft_id VARCHAR(255) PRIMARY KEY, + status VARCHAR(64) NOT NULL, + reason VARCHAR(255), + locked_by VARCHAR(255), + locked_until TIMESTAMP, + attempts INTEGER NOT NULL DEFAULT 0, + requested_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS processed_plan_decision_event ( + event_id UUID PRIMARY KEY, + received_at TIMESTAMP NOT NULL +); + +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS active_plan_id UUID; + +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS active_attempt_id UUID; + +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 0; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V5__create_accepted_plan_slots_update_job.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V5__create_accepted_plan_slots_update_job.sql new file mode 100644 index 0000000..4af81e8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V5__create_accepted_plan_slots_update_job.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS accepted_plan_slots_update_job ( + plan_id UUID PRIMARY KEY, + status VARCHAR(32) NOT NULL, + process_instance_key BIGINT NULL, + locked_by VARCHAR(255) NULL, + locked_until TIMESTAMP NULL, + attempts INT NOT NULL DEFAULT 0, + last_error TEXT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V6__add_job_rerun_requested.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V6__add_job_rerun_requested.sql new file mode 100644 index 0000000..98f33b0 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V6__add_job_rerun_requested.sql @@ -0,0 +1,5 @@ +ALTER TABLE visibility_refresh_job + ADD COLUMN IF NOT EXISTS rerun_requested BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE plan_recalculation_job + ADD COLUMN IF NOT EXISTS rerun_requested BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index 3971f90..90b7c7d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -4,6 +4,7 @@ import com.sun.net.httpserver.HttpServer import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mockito.Mockito.mock +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import space.nstart.pcp_tgu_service.config.ClassifierProperties import space.nstart.pcp_tgu_service.config.ExternalApiProperties import space.nstart.pcp_tgu_service.config.PlanningProperties @@ -28,7 +29,8 @@ class ExternalPointsClientTest { platform(nsiId = "allowed-2", noradId = 41386L) ) ), - planningProperties = planningProperties() + planningProperties = planningProperties(), + visibilityPayloadParser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) ) val points = client.fetchAllSpacecraftPoints() @@ -106,6 +108,8 @@ class ExternalPointsClientTest { externalPointsLookaheadDays = 3, inMemoryHistoryDays = 14, externalUpdateFixedDelayMs = 0, - notificationCheckFixedDelayMs = 30_000 + notificationCheckFixedDelayMs = 30_000, + minReissueLeadMinutes = 5, + workerLockTtlSeconds = 300 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt new file mode 100644 index 0000000..85bcc69 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -0,0 +1,189 @@ +package space.nstart.pcp_tgu_service.service + +import io.camunda.client.CamundaClient +import io.camunda.client.api.CamundaFuture +import io.camunda.client.api.command.CreateProcessInstanceCommandStep1 +import io.camunda.client.api.response.ProcessInstanceEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobEntity +import space.nstart.pcp_tgu_service.entity.AcceptedPlanSlotsUpdateJobStatus +import space.nstart.pcp_tgu_service.repository.AcceptedPlanSlotsUpdateJobRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class AcceptedPlanSlotsUpdateFlowTest { + + @Test + fun `enqueue inserts accepted plan slots update job only once`() { + val repository = mock(AcceptedPlanSlotsUpdateJobRepository::class.java) + val planId = UUID.randomUUID() + val service = AcceptedPlanSlotsUpdateJobService(repository) + + service.enqueue(planId) + service.enqueue(planId) + + verify(repository, times(2)).insertPendingIfAbsent( + eqNonNull(planId), + anyNonNull() + ) + } + + @Test + fun `process starter starts updateAcceptedPlanSlots with satellitePlanId variable`() { + val planId = UUID.randomUUID() + val capturedVariables = mutableListOf() + val camundaClient = mock(CamundaClient::class.java) + val createCommand = mock(CreateProcessInstanceCommandStep1::class.java) + val step2 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2::class.java) + val step3 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3::class.java) + val future = mock(CamundaFuture::class.java) as CamundaFuture + val processInstanceEvent = mock(ProcessInstanceEvent::class.java) + + `when`(camundaClient.newCreateInstanceCommand()).thenReturn(createCommand) + `when`(createCommand.bpmnProcessId(AcceptedPlanSlotsUpdateProcessStarter.UPDATE_ACCEPTED_PLAN_SLOTS_PROCESS_ID)).thenReturn(step2) + `when`(step2.latestVersion()).thenReturn(step3) + `when`(step3.variables(anyNonNull>())).thenAnswer { invocation -> + capturedVariables += invocation.arguments[0] + step3 + } + `when`(step3.send()).thenReturn(future) + `when`(future.join()).thenReturn(processInstanceEvent) + `when`(processInstanceEvent.getProcessInstanceKey()).thenReturn(123L) + val starter = AcceptedPlanSlotsUpdateProcessStarter(camundaClient) + + val processInstanceKey = starter.start(planId) + + assertEquals(123L, processInstanceKey) + verify(createCommand).bpmnProcessId("updateAcceptedPlanSlots") + assertEquals(mapOf("satellitePlanId" to planId.toString()), capturedVariables.single()) + } + + @Test + fun `worker claims one job and does not let second worker claim same locked job`() { + val planId = UUID.randomUUID() + val jobStore = linkedMapOf( + planId to AcceptedPlanSlotsUpdateJobEntity( + planId = planId, + status = AcceptedPlanSlotsUpdateJobStatus.PENDING + ) + ) + val repository = jobRepository(jobStore) + val starter = mock(AcceptedPlanSlotsUpdateProcessStarter::class.java) + val firstWorker = worker(repository, starter) + val secondWorker = worker(repository, starter) + + val firstClaim = firstWorker.claimJobs(limit = 10) + val secondClaim = secondWorker.claimJobs(limit = 10) + + assertEquals(listOf(planId), firstClaim.map { job -> job.planId }) + assertTrue(secondClaim.isEmpty()) + assertEquals(AcceptedPlanSlotsUpdateJobStatus.RUNNING, jobStore.getValue(planId).status) + } + + @Test + fun `worker starts BPMN and marks job done`() { + val planId = UUID.randomUUID() + val jobStore = linkedMapOf( + planId to AcceptedPlanSlotsUpdateJobEntity( + planId = planId, + status = AcceptedPlanSlotsUpdateJobStatus.PENDING + ) + ) + val repository = jobRepository(jobStore) + val starter = mock(AcceptedPlanSlotsUpdateProcessStarter::class.java) + `when`(starter.start(planId)).thenReturn(456L) + val worker = worker(repository, starter) + + worker.processPendingJobs() + + assertEquals(AcceptedPlanSlotsUpdateJobStatus.DONE, jobStore.getValue(planId).status) + assertEquals(456L, jobStore.getValue(planId).processInstanceKey) + verify(starter).start(planId) + } + + private fun worker( + repository: AcceptedPlanSlotsUpdateJobRepository, + starter: AcceptedPlanSlotsUpdateProcessStarter + ): AcceptedPlanSlotsUpdateWorker = + AcceptedPlanSlotsUpdateWorker( + planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300), + acceptedPlanSlotsUpdateJobRepository = repository, + acceptedPlanSlotsUpdateProcessStarter = starter, + transactionTemplate = transactionTemplate() + ) + + private fun jobRepository( + store: MutableMap + ): AcceptedPlanSlotsUpdateJobRepository { + val repository = mock(AcceptedPlanSlotsUpdateJobRepository::class.java) + `when`( + repository.findClaimableForUpdate( + anyNonNull(), + org.mockito.ArgumentMatchers.anyInt() + ) + ).thenAnswer { invocation -> + val now = invocation.arguments[0] as LocalDateTime + val limit = invocation.arguments[1] as Int + store.values + .filter { job -> + job.status == AcceptedPlanSlotsUpdateJobStatus.PENDING || + ( + job.status == AcceptedPlanSlotsUpdateJobStatus.RUNNING && + job.lockedUntil != null && + job.lockedUntil!!.isBefore(now) && + job.processInstanceKey == null + ) + } + .sortedBy { job -> job.createdAt } + .take(limit) + } + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> + Optional.ofNullable(store[invocation.arguments[0]]) + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val job = invocation.arguments[0] as AcceptedPlanSlotsUpdateJobEntity + store[job.planId] = job + job + } + `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> + val jobs = invocation.arguments[0] as Iterable + jobs.forEach { job -> store[job.planId] = job } + jobs.toList() + } + return repository + } + + private fun transactionTemplate(): TransactionTemplate = + TransactionTemplate( + object : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + override fun commit(status: TransactionStatus) = Unit + override fun rollback(status: TransactionStatus) = Unit + } + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + private fun eqNonNull(value: T): T { + org.mockito.ArgumentMatchers.eq(value) + return value + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt new file mode 100644 index 0000000..506b63e --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt @@ -0,0 +1,301 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.data.jpa.repository.Query +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.entity.JobStatus +import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity +import space.nstart.pcp_tgu_service.entity.VisibilityRefreshJobEntity +import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient +import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository +import java.time.LocalDateTime +import java.util.Optional + +class JobRerunFlowTest { + + @Test + fun `enqueue does not reset running visibility job lock`() { + val query = visibilityUpsertQuery() + + assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.status")) + assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_by")) + assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_until")) + } + + @Test + fun `enqueue does not reset running recalculation job lock`() { + val query = recalculationUpsertQuery() + + assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status")) + assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by")) + assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until")) + } + + @Test + fun `enqueue running job sets rerunRequested to true`() { + assertTrue( + visibilityUpsertQuery() + .contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN TRUE") + ) + assertTrue( + recalculationUpsertQuery() + .contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE") + ) + } + + @Test + fun `visibility markDone with rerunRequested moves job to pending`() { + val spacecraftId = "SC-1" + val jobStore = linkedMapOf( + spacecraftId to VisibilityRefreshJobEntity( + spacecraftId = spacecraftId, + noradId = 25544L, + status = JobStatus.PENDING, + rerunRequested = true + ) + ) + val repository = visibilityJobRepository(jobStore) + val externalPointsClient = mock(ExternalPointsClient::class.java) + val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) + `when`( + externalPointsClient.fetchRawVisibilityPayload( + org.mockito.ArgumentMatchers.anyLong(), + anyNonNull(), + anyNonNull() + ) + ).thenReturn("[]") + val worker = VisibilityRefreshWorker( + planningProperties = planningProperties(), + visibilityRefreshJobRepository = repository, + spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), + externalPointsClient = externalPointsClient, + spacecraftVisibilitySnapshotService = snapshotService, + transactionTemplate = transactionTemplate() + ) + + worker.processPendingJobs() + + val job = jobStore.getValue(spacecraftId) + assertEquals(JobStatus.PENDING, job.status) + assertEquals(false, job.rerunRequested) + assertNull(job.lockedBy) + assertNull(job.lockedUntil) + } + + @Test + fun `visibility markDone without rerunRequested moves job to done`() { + val spacecraftId = "SC-2" + val jobStore = linkedMapOf( + spacecraftId to VisibilityRefreshJobEntity( + spacecraftId = spacecraftId, + noradId = 25544L, + status = JobStatus.PENDING + ) + ) + val repository = visibilityJobRepository(jobStore) + val externalPointsClient = mock(ExternalPointsClient::class.java) + val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) + `when`( + externalPointsClient.fetchRawVisibilityPayload( + org.mockito.ArgumentMatchers.anyLong(), + anyNonNull(), + anyNonNull() + ) + ).thenReturn("[]") + val worker = VisibilityRefreshWorker( + planningProperties = planningProperties(), + visibilityRefreshJobRepository = repository, + spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), + externalPointsClient = externalPointsClient, + spacecraftVisibilitySnapshotService = snapshotService, + transactionTemplate = transactionTemplate() + ) + + worker.processPendingJobs() + + val job = jobStore.getValue(spacecraftId) + assertEquals(JobStatus.DONE, job.status) + assertEquals(false, job.rerunRequested) + assertNull(job.lockedBy) + assertNull(job.lockedUntil) + } + + @Test + fun `recalculation markDone with rerunRequested moves job to pending`() { + val spacecraftId = "SC-3" + val jobStore = linkedMapOf( + spacecraftId to PlanRecalculationJobEntity( + spacecraftId = spacecraftId, + status = JobStatus.PENDING, + rerunRequested = true + ) + ) + val repository = recalculationJobRepository(jobStore) + val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) + val worker = PlanRecalculationWorker( + planningProperties = planningProperties(), + planRecalculationJobRepository = repository, + rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, + transactionTemplate = transactionTemplate() + ) + + worker.processPendingJobs() + + val job = jobStore.getValue(spacecraftId) + assertEquals(JobStatus.PENDING, job.status) + assertEquals(false, job.rerunRequested) + assertNull(job.lockedBy) + assertNull(job.lockedUntil) + } + + @Test + fun `recalculation markDone without rerunRequested moves job to done`() { + val spacecraftId = "SC-4" + val jobStore = linkedMapOf( + spacecraftId to PlanRecalculationJobEntity( + spacecraftId = spacecraftId, + status = JobStatus.PENDING + ) + ) + val repository = recalculationJobRepository(jobStore) + val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) + val worker = PlanRecalculationWorker( + planningProperties = planningProperties(), + planRecalculationJobRepository = repository, + rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, + transactionTemplate = transactionTemplate() + ) + + worker.processPendingJobs() + + val job = jobStore.getValue(spacecraftId) + assertEquals(JobStatus.DONE, job.status) + assertEquals(false, job.rerunRequested) + assertNull(job.lockedBy) + assertNull(job.lockedUntil) + } + + private fun visibilityUpsertQuery(): String = + VisibilityRefreshJobRepository::class.java + .getMethod( + "upsertPending", + String::class.java, + java.lang.Long::class.java, + String::class.java, + LocalDateTime::class.java + ) + .getAnnotation(Query::class.java) + .value + + private fun recalculationUpsertQuery(): String = + PlanRecalculationJobRepository::class.java + .getMethod( + "upsertPending", + String::class.java, + String::class.java, + LocalDateTime::class.java + ) + .getAnnotation(Query::class.java) + .value + + private fun visibilityJobRepository( + store: MutableMap + ): VisibilityRefreshJobRepository { + val repository = mock(VisibilityRefreshJobRepository::class.java) + `when`( + repository.findPendingForUpdate( + anyNonNull(), + org.mockito.ArgumentMatchers.anyInt() + ) + ).thenAnswer { invocation -> + val now = invocation.arguments[0] as LocalDateTime + val limit = invocation.arguments[1] as Int + store.values + .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } + .sortedBy { job -> job.requestedAt } + .take(limit) + } + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> + Optional.ofNullable(store[invocation.arguments[0]]) + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val job = invocation.arguments[0] as VisibilityRefreshJobEntity + store[job.spacecraftId] = job + job + } + `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> + val jobs = invocation.arguments[0] as Iterable + jobs.forEach { job -> store[job.spacecraftId] = job } + jobs.toList() + } + return repository + } + + private fun recalculationJobRepository( + store: MutableMap + ): PlanRecalculationJobRepository { + val repository = mock(PlanRecalculationJobRepository::class.java) + `when`( + repository.findPendingForUpdate( + anyNonNull(), + org.mockito.ArgumentMatchers.anyInt() + ) + ).thenAnswer { invocation -> + val now = invocation.arguments[0] as LocalDateTime + val limit = invocation.arguments[1] as Int + store.values + .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } + .sortedBy { job -> job.requestedAt } + .take(limit) + } + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> + Optional.ofNullable(store[invocation.arguments[0]]) + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val job = invocation.arguments[0] as PlanRecalculationJobEntity + store[job.spacecraftId] = job + job + } + `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> + val jobs = invocation.arguments[0] as Iterable + jobs.forEach { job -> store[job.spacecraftId] = job } + jobs.toList() + } + return repository + } + + private fun isExpiredRunning(status: JobStatus, lockedUntil: LocalDateTime?, now: LocalDateTime): Boolean = + status == JobStatus.RUNNING && + lockedUntil != null && + lockedUntil.isBefore(now) + + private fun planningProperties(): PlanningProperties = + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) + + private fun transactionTemplate(): TransactionTemplate = + TransactionTemplate( + object : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + override fun commit(status: TransactionStatus) = Unit + override fun rollback(status: TransactionStatus) = Unit + } + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index c129a64..5bbba48 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -208,7 +208,9 @@ class PlanCalculationServiceTest { externalPointsLookaheadDays = 3, inMemoryHistoryDays = 14, externalUpdateFixedDelayMs = 0, - notificationCheckFixedDelayMs = 30_000 + notificationCheckFixedDelayMs = 30_000, + minReissueLeadMinutes = 5, + workerLockTtlSeconds = 300 ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationServiceTest.kt deleted file mode 100644 index 3c70232..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanConfirmationServiceTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import java.time.LocalDateTime -import java.util.UUID - -class PlanConfirmationServiceTest { - - @Test - fun `confirm only persists confirmation in tracked storage`() { - val planId = UUID.randomUUID() - val confirmedEntity = TrackedPlanEntity( - planId = planId, - spacecraftId = "25544", - startTime = LocalDateTime.of(2026, 3, 10, 10, 0), - endTime = LocalDateTime.of(2026, 3, 11, 10, 0), - kppId = "KPP-1" - ) - val trackedPlanService = mock(TrackedPlanService::class.java) - - `when`(trackedPlanService.confirm(planId)).thenReturn(confirmedEntity) - - val service = PlanConfirmationService(trackedPlanService) - - val result = service.confirm(planId) - - assertEquals(planId, result.planId) - verify(trackedPlanService).confirm(planId) - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt new file mode 100644 index 0000000..32fd2f4 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -0,0 +1,434 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.PlanDecision +import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class PlanIssueAndDecisionFlowTest { + + @Test + fun `issue worker creates one active attempt and repeated issue of same plan creates new attempt after state is cleared`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13)) + val planStore = linkedMapOf(plan.planId to plan) + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val plannedRepository = plannedRepository(planStore) + val attemptRepository = attemptRepository(attemptStore) + val stateRepository = stateRepository(state) + val starter = mock(BpmnPlanIssueProcessStarter::class.java) + `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val p = invocation.arguments[0] as PlannedPlanEntity + val a = invocation.arguments[1] as PlanIssueAttemptEntity + ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) + } + val worker = IssueDuePlanWorker( + planningProperties = planningProperties(), + plannedPlanRepository = plannedRepository, + planIssueAttemptRepository = attemptRepository, + spacecraftPlanningStateJpaRepository = stateRepository, + bpmnPlanIssueProcessStarter = starter, + transactionTemplate = transactionTemplate() + ) + + val firstResponse = worker.issuePlanNow(plan.planId) + val blockedResponse = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() + state.activePlanId = null + state.activeAttemptId = null + plan.status = PlannedPlanStatus.PLANNED + val secondResponse = worker.issuePlanNow(plan.planId) + + assertEquals(1, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.attemptNo) + assertEquals(2, attemptStore.values.first { it.attemptId == secondResponse.attemptId }.attemptNo) + assertNotEquals(firstResponse.attemptId, secondResponse.attemptId) + assertEquals(PlanIssueAttemptStatus.STARTED, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.status) + assertEquals(firstResponse.processInstanceKey, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.processInstanceKey) + assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) + assertTrue(blockedResponse?.message?.startsWith("404 NOT_FOUND") == true) + } + + @Test + fun `mark started does not roll accepted plan and attempt back after BPMN start race`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13)) + val planStore = linkedMapOf(plan.planId to plan) + val attemptStore = linkedMapOf() + val worker = issueWorker(planStore, attemptStore) { response -> + attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.ACCEPTED + planStore.getValue(response.planId).status = PlannedPlanStatus.ACCEPTED + } + + val response = worker.issuePlanNow(plan.planId) + val attempt = attemptStore.getValue(response.attemptId) + + assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) + assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(response.processInstanceKey, attempt.processInstanceKey) + assertTrue(attempt.startedAt != null) + } + + @Test + fun `mark started does not roll rejected planned plan and attempt back after BPMN start race`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13)) + val planStore = linkedMapOf(plan.planId to plan) + val attemptStore = linkedMapOf() + val worker = issueWorker(planStore, attemptStore) { response -> + attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED + planStore.getValue(response.planId).status = PlannedPlanStatus.PLANNED + } + + val response = worker.issuePlanNow(plan.planId) + val attempt = attemptStore.getValue(response.attemptId) + + assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) + assertEquals(PlannedPlanStatus.PLANNED, plan.status) + assertEquals(response.processInstanceKey, attempt.processInstanceKey) + assertTrue(attempt.startedAt != null) + } + + @Test + fun `mark started does not roll rejected expired plan and attempt back after BPMN start race`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13)) + val planStore = linkedMapOf(plan.planId to plan) + val attemptStore = linkedMapOf() + val worker = issueWorker(planStore, attemptStore) { response -> + attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED + planStore.getValue(response.planId).status = PlannedPlanStatus.EXPIRED + } + + val response = worker.issuePlanNow(plan.planId) + val attempt = attemptStore.getValue(response.attemptId) + + assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) + assertEquals(PlannedPlanStatus.EXPIRED, plan.status) + assertEquals(response.processInstanceKey, attempt.processInstanceKey) + assertTrue(attempt.startedAt != null) + } + + @Test + fun `accepted decision sets base plan clears active ids and enqueues recalculation once`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) + val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = decisionUseCase(plan, attempt, state, processedRepository, recalculationJobService, slotsUpdateJobService) + val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED) + + useCase.handle(message) + useCase.handle(message) + + assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) + assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(plan.planId, state.currentBasePlanId) + assertEquals(null, state.activePlanId) + assertEquals(null, state.activeAttemptId) + verify(recalculationJobService).enqueue(spacecraftId, "PLAN_ACCEPTED") + verify(slotsUpdateJobService).enqueue(plan.planId) + } + + @Test + fun `rejected before cutoff reopens same plan without direct new attempt`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) + val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = decisionUseCase( + plan = plan, + attempt = attempt, + state = state, + recalculationJobService = recalculationJobService, + slotsUpdateJobService = slotsUpdateJobService + ) + + useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) + + assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) + assertEquals(PlannedPlanStatus.PLANNED, plan.status) + assertEquals(null, state.activeAttemptId) + verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + verify(slotsUpdateJobService, never()).enqueue(plan.planId) + } + + @Test + fun `rejected after cutoff expires plan and promotes next planned plan`() { + val spacecraftId = "25544" + val currentPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusMinutes(2), LocalDateTime.now().plusMinutes(20), PlannedPlanStatus.WAITING_DECISION) + val nextPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2), PlannedPlanStatus.PLANNED) + val attempt = attempt(currentPlan.planId, status = PlanIssueAttemptStatus.STARTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = currentPlan.planId, activeAttemptId = attempt.attemptId) + val planStore = linkedMapOf(currentPlan.planId to currentPlan, nextPlan.planId to nextPlan) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val useCase = decisionUseCase( + plan = currentPlan, + attempt = attempt, + state = state, + plannedRepository = plannedRepository(planStore), + recalculationJobService = recalculationJobService + ) + + useCase.handle(decisionMessage(spacecraftId, currentPlan.planId, attempt.attemptId, PlanDecision.REJECTED)) + + assertEquals(PlannedPlanStatus.EXPIRED, currentPlan.status) + assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) + assertEquals(null, state.activeAttemptId) + assertEquals(true, nextPlan.issueNotBefore != null) + verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + } + + @Test + fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) + val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val activeAttemptId = UUID.randomUUID() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = decisionUseCase( + plan = plan, + attempt = attempt, + state = state, + recalculationJobService = recalculationJobService, + slotsUpdateJobService = slotsUpdateJobService + ) + + useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)) + + assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) + assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) + assertEquals(plan.planId, state.activePlanId) + assertEquals(activeAttemptId, state.activeAttemptId) + assertEquals(null, state.currentBasePlanId) + verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_ACCEPTED") + verify(slotsUpdateJobService, never()).enqueue(plan.planId) + } + + @Test + fun `terminal attempt is not reprocessed by new decision event`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.ACCEPTED) + val oldEventId = UUID.randomUUID() + val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.ACCEPTED) + attempt.decisionEventId = oldEventId + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = plan.planId, + activePlanId = null, + activeAttemptId = null + ) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = decisionUseCase( + plan = plan, + attempt = attempt, + state = state, + recalculationJobService = recalculationJobService, + slotsUpdateJobService = slotsUpdateJobService + ) + + useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) + + assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) + assertEquals(oldEventId, attempt.decisionEventId) + assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(plan.planId, state.currentBasePlanId) + verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + verify(slotsUpdateJobService, never()).enqueue(plan.planId) + } + + private fun decisionUseCase( + plan: PlannedPlanEntity, + attempt: PlanIssueAttemptEntity, + state: SpacecraftPlanningStateEntity, + processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(), + recalculationJobService: PlanRecalculationJobService = mock(PlanRecalculationJobService::class.java), + slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + plannedRepository: PlannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)) + ): HandlePlanDecisionUseCase = + HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository, + planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)), + plannedPlanRepository = plannedRepository, + spacecraftPlanningStateJpaRepository = stateRepository(state), + planRecalculationJobService = recalculationJobService, + acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, + planningProperties = planningProperties() + ) + + private fun issueWorker( + planStore: MutableMap, + attemptStore: MutableMap, + beforeStartReturns: (ProcessStartResponse) -> Unit + ): IssueDuePlanWorker { + val starter = mock(BpmnPlanIssueProcessStarter::class.java) + `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val p = invocation.arguments[0] as PlannedPlanEntity + val a = invocation.arguments[1] as PlanIssueAttemptEntity + ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) + .also(beforeStartReturns) + } + return IssueDuePlanWorker( + planningProperties = planningProperties(), + plannedPlanRepository = plannedRepository(planStore), + planIssueAttemptRepository = attemptRepository(attemptStore), + spacecraftPlanningStateJpaRepository = stateRepository(SpacecraftPlanningStateEntity(spacecraftId = planStore.values.first().spacecraftId)), + bpmnPlanIssueProcessStarter = starter, + transactionTemplate = transactionTemplate() + ) + } + + private fun processedRepository(): ProcessedPlanDecisionEventRepository { + val repository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(repository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1) + return repository + } + + private fun plannedRepository(store: MutableMap): PlannedPlanRepository { + val repository = mock(PlannedPlanRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.lockByPlanId(anyNonNull())).thenAnswer { invocation -> store[invocation.arguments[0]] } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val plan = invocation.arguments[0] as PlannedPlanEntity + store[plan.planId] = plan + plan + } + `when`(repository.findDueForUpdate(anyNonNull(), anyNonNull(), org.mockito.ArgumentMatchers.anyInt())).thenAnswer { invocation -> + val now = invocation.arguments[0] as LocalDateTime + val issueBefore = invocation.arguments[1] as LocalDateTime + store.values.filter { plan -> + plan.status == PlannedPlanStatus.PLANNED && + (plan.issueNotBefore == null || !plan.issueNotBefore!!.isAfter(now)) && + !plan.startTime.isAfter(issueBefore) && + plan.startTime.isAfter(now) + }.sortedBy { plan -> plan.startTime } + } + `when`(repository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) + .thenAnswer { invocation -> + store.values + .filter { plan -> + plan.spacecraftId == invocation.arguments[0] && + plan.status == invocation.arguments[1] && + plan.startTime > invocation.arguments[2] as LocalDateTime + } + .minByOrNull { plan -> plan.startTime } + } + return repository + } + + private fun attemptRepository(store: MutableMap): PlanIssueAttemptRepository { + val repository = mock(PlanIssueAttemptRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.lockByAttemptId(anyNonNull())).thenAnswer { invocation -> store[invocation.arguments[0]] } + `when`(repository.findTopByPlanIdOrderByAttemptNoDesc(anyNonNull())).thenAnswer { invocation -> + store.values.filter { attempt -> attempt.planId == invocation.arguments[0] }.maxByOrNull { attempt -> attempt.attemptNo } + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val attempt = invocation.arguments[0] as PlanIssueAttemptEntity + store[attempt.attemptId] = attempt + attempt + } + return repository + } + + private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { + val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) + `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + return repository + } + + private fun plannedPlan( + spacecraftId: String, + start: LocalDateTime, + end: LocalDateTime, + status: PlannedPlanStatus = PlannedPlanStatus.PLANNED + ): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status, + issueNotBefore = LocalDateTime.now().minusMinutes(1) + ) + + private fun attempt(planId: UUID, status: PlanIssueAttemptStatus): PlanIssueAttemptEntity = + PlanIssueAttemptEntity( + attemptId = UUID.randomUUID(), + planId = planId, + attemptNo = 1, + status = status + ) + + private fun decisionMessage( + spacecraftId: String, + planId: UUID, + attemptId: UUID, + decision: PlanDecision + ): PlanDecisionMessage = + PlanDecisionMessage( + eventId = UUID.randomUUID(), + spacecraftId = spacecraftId, + planId = planId, + attemptId = attemptId, + decision = decision, + decisionTime = LocalDateTime.now(), + reason = null + ) + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + + private fun planningProperties(): PlanningProperties = + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) + + private fun transactionTemplate(): TransactionTemplate = + TransactionTemplate( + object : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + override fun commit(status: TransactionStatus) = Unit + override fun rollback(status: TransactionStatus) = Unit + } + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt deleted file mode 100644 index ce89c37..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import space.nstart.pcp_tgu_service.repository.PlanRepository -import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository -import java.time.LocalDateTime -import java.util.UUID - -class PlanQueryServiceTest { - - @Test - fun `getPlansBySpacecraftId returns statuses for new tracked and superseded plans`() { - val now = LocalDateTime.now() - val spacecraftId = "25544" - val newPlan = Plan( - spacecraftId = spacecraftId, - startTime = now.plusHours(4), - endTime = now.plusHours(5), - kppId = "KPP-NEW", - planId = UUID.randomUUID() - ) - val waitingTracked = trackedPlan( - spacecraftId = spacecraftId, - startTime = now.plusHours(1), - endTime = now.plusHours(2), - kppId = "KPP-WAITING", - status = TrackedPlanStatus.WAITING_CONFIRMATION - ) - val supersededTracked = trackedPlan( - spacecraftId = spacecraftId, - startTime = now.plusHours(2), - endTime = now.plusHours(3), - kppId = "KPP-SUPERSEDED", - status = TrackedPlanStatus.SUPERSEDED - ) - val confirmedTracked = trackedPlan( - spacecraftId = spacecraftId, - startTime = now.minusHours(2), - endTime = now.minusHours(1), - kppId = "KPP-CONFIRMED", - status = TrackedPlanStatus.CONFIRMED, - confirmedAt = now.minusDays(1) - ) - val planRepository = PlanRepository().apply { - replacePlans(spacecraftId, listOf(newPlan)) - } - val trackedPlanService = FixedTrackedPlanService( - spacecraftId = spacecraftId, - trackedPlans = listOf(confirmedTracked, waitingTracked, supersededTracked) - ) - val service = PlanQueryService(planRepository, trackedPlanService) - - val result = service.getPlansBySpacecraftId(spacecraftId, historyDays = 7) - - assertEquals( - listOf( - TrackedPlanStatus.CONFIRMED, - TrackedPlanStatus.WAITING_CONFIRMATION, - TrackedPlanStatus.SUPERSEDED, - TrackedPlanStatus.NEW - ), - result.map { planView -> planView.status } - ) - } - - private class FixedTrackedPlanService( - private val spacecraftId: String, - private val trackedPlans: List - ) : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) { - - override fun getTrackedPlansBySpacecraftId(spacecraftId: String): List = - if (spacecraftId == this.spacecraftId) trackedPlans else emptyList() - - override fun getAllTrackedSpacecraftIds(): List = listOf(spacecraftId) - } - - private fun trackedPlan( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String, - status: TrackedPlanStatus, - confirmedAt: LocalDateTime? = null - ): TrackedPlanEntity = - TrackedPlanEntity( - planId = UUID.randomUUID(), - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId, - confirmedAt = confirmedAt, - status = status - ) -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorkerTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorkerTest.kt deleted file mode 100644 index 088d189..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanStatusWorkerTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import io.camunda.client.api.CamundaFuture -import io.camunda.client.api.command.CompleteJobCommandStep1 -import io.camunda.client.api.response.ActivatedJob -import io.camunda.client.api.response.CompleteJobResponse -import io.camunda.client.api.worker.JobClient -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import java.util.UUID - -class PlanStatusWorkerTest { - - @Test - fun `updatePlanStatus confirms tracked plan and completes job`() { - val planId = UUID.randomUUID() - val job = mock(ActivatedJob::class.java) - val client = mock(JobClient::class.java) - val completeCommand = mock(CompleteJobCommandStep1::class.java) - val future = mock(CamundaFuture::class.java) as CamundaFuture - val planConfirmationService = mock(PlanConfirmationService::class.java) - - `when`(job.variablesAsMap).thenReturn(mapOf("satellitePlanId" to planId.toString())) - `when`(job.key).thenReturn(123L) - `when`(client.newCompleteCommand(123L)).thenReturn(completeCommand) - `when`(completeCommand.send()).thenReturn(future) - `when`(future.join()).thenReturn(null) - - val worker = PlanStatusWorker(planConfirmationService) - - worker.updatePlanStatus(client, job) - - verify(planConfirmationService).confirm(planId) - verify(client).newCompleteCommand(123L) - verify(completeCommand).send() - verify(future).join() - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt new file mode 100644 index 0000000..3b3beea --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt @@ -0,0 +1,175 @@ +package space.nstart.pcp_tgu_service.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class RebuildPlanScheduleUseCaseTest { + + @Test + fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { + val spacecraftId = "25544" + val planStore = linkedMapOf() + val plannedRepository = plannedRepository(planStore) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val stateRepository = stateRepository(state) + val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) + `when`(snapshotRepository.findById(spacecraftId)).thenReturn( + Optional.of( + SpacecraftVisibilitySnapshotEntity( + spacecraftId = spacecraftId, + noradId = 25544L, + payloadJson = rawPayload(), + payloadHash = "hash", + fetchedAt = LocalDateTime.now() + ) + ) + ) + val calculationService = mock(PlanCalculationService::class.java) + val baseTime = LocalDateTime.now().plusHours(1) + val firstPlan = CalculatedPlan(spacecraftId, baseTime, baseTime.plusHours(2), "KPP-1") + val secondPlan = CalculatedPlan(spacecraftId, baseTime.plusHours(3), baseTime.plusHours(5), "KPP-2") + `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan, secondPlan)) + val service = RebuildPlanScheduleUseCase( + spacecraftVisibilitySnapshotRepository = snapshotRepository, + spacecraftPlanningStateJpaRepository = stateRepository, + plannedPlanRepository = plannedRepository, + visibilityPayloadParser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()), + planCalculationService = calculationService, + planningProperties = planningProperties() + ) + + service.rebuild(spacecraftId) + val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId + + `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) + service.rebuild(spacecraftId) + + assertEquals(firstPlanId, planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId) + assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.values.first { plan -> plan.kppId == "KPP-2" }.status) + assertNotEquals(planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId, planStore.values.first { plan -> plan.kppId == "KPP-2" }.planId) + assertEquals(2, state.chainVersion) + } + + private fun plannedRepository(store: MutableMap): PlannedPlanRepository { + val repository = mock(PlannedPlanRepository::class.java) + `when`( + repository.upsertPlannedByBusinessKey( + anyNonNull(), + anyNonNull(), + anyNonNull(), + anyNonNull(), + anyNonNull(), + anyNonNull(), + anyNonNull(), + org.mockito.ArgumentMatchers.anyLong(), + anyNonNull() + ) + ).thenAnswer { invocation -> + val planId = invocation.arguments[0] as UUID + val spacecraftId = invocation.arguments[1] as String + val startTime = invocation.arguments[2] as LocalDateTime + val endTime = invocation.arguments[3] as LocalDateTime + val kppId = invocation.arguments[4] as String + val issueNotBefore = invocation.arguments[5] as LocalDateTime + val basePlanId = invocation.arguments[6] as UUID? + val chainVersion = invocation.arguments[7] as Long + val now = invocation.arguments[8] as LocalDateTime + val existing = store.values.firstOrNull { plan -> + plan.spacecraftId == spacecraftId && + plan.startTime == startTime && + plan.endTime == endTime && + plan.kppId == kppId + } + + if (existing != null) { + if (existing.status == PlannedPlanStatus.SUPERSEDED || existing.status == PlannedPlanStatus.EXPIRED) { + existing.status = PlannedPlanStatus.PLANNED + } + if (existing.status == PlannedPlanStatus.PLANNED) { + existing.issueNotBefore = issueNotBefore + existing.basePlanId = basePlanId + existing.chainVersion = chainVersion + existing.updatedAt = now + } + existing + } else { + PlannedPlanEntity( + planId = planId, + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + status = PlannedPlanStatus.PLANNED, + issueNotBefore = issueNotBefore, + basePlanId = basePlanId, + chainVersion = chainVersion, + createdAt = now, + updatedAt = now + ).also { plan -> store[plan.planId] = plan } + } + } + `when`(repository.findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull())) + .thenAnswer { invocation -> + store.values.firstOrNull { plan -> + plan.spacecraftId == invocation.arguments[0] && + plan.startTime == invocation.arguments[1] && + plan.endTime == invocation.arguments[2] && + plan.kppId == invocation.arguments[3] + } + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val plan = invocation.arguments[0] as PlannedPlanEntity + store[plan.planId] = plan + plan + } + `when`(repository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) + .thenAnswer { invocation -> + store.values + .filter { plan -> + plan.spacecraftId == invocation.arguments[0] && + plan.status == invocation.arguments[1] && + plan.startTime > invocation.arguments[2] as LocalDateTime + } + .sortedBy { plan -> plan.startTime } + } + return repository + } + + private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { + val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) + `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + return repository + } + + private fun rawPayload(): String = + """[{"noradId":25544,"stationId":7,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}]""" + + private fun planningProperties(): PlanningProperties = + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt deleted file mode 100644 index f3170dd..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt +++ /dev/null @@ -1,332 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import io.camunda.client.CamundaClient -import io.camunda.client.api.CamundaFuture -import io.camunda.client.api.command.CreateProcessInstanceCommandStep1 -import io.camunda.client.api.response.ProcessInstanceEvent -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.Test -import org.mockito.ArgumentMatchers -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.config.ClassifierProperties -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables -import space.nstart.pcp_tgu_service.dto.PointMessage -import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanSource -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient -import space.nstart.pcp_tgu_service.repository.PlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository -import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository -import java.time.Instant -import java.time.LocalDateTime -import java.util.UUID - -class SatelliteMissionProcessServiceTest { - - @Test - fun `startCreateSatelliteMission returns conflict for confirmed plan`() { - val noradId = 25544L - val spacecraftId = noradId.toString() - val confirmedPlan = Plan( - spacecraftId = spacecraftId, - startTime = LocalDateTime.of(2026, 3, 10, 10, 0), - endTime = LocalDateTime.of(2026, 3, 11, 10, 0), - kppId = "KPP-1", - planId = UUID.randomUUID() - ) - val planCalculationService = mock(PlanCalculationService::class.java) - val planQueryService = mock(PlanQueryService::class.java) - - `when`(planCalculationService.calculateNearestPlanFrom(anyNonNull(), anyNonNull())).thenReturn(confirmedPlan) - `when`(planQueryService.reuseExistingPlan(confirmedPlan)).thenReturn(confirmedPlan) - - val service = SatelliteMissionProcessService( - camundaClient = mock(CamundaClient::class.java), - platformService = FixedPlatformService(listOf(platform(noradId))), - spacecraftPointsService = FixedSpacecraftPointsService( - SpacecraftPointsMessage(spacecraftId, emptyList()) - ), - planCalculationService = planCalculationService, - planQueryService = planQueryService, - trackedPlanService = FixedTrackedPlanService( - confirmedPlan = TrackedPlanEntity( - planId = confirmedPlan.planId, - spacecraftId = confirmedPlan.spacecraftId, - startTime = confirmedPlan.startTime, - endTime = confirmedPlan.endTime, - kppId = confirmedPlan.kppId, - status = TrackedPlanStatus.CONFIRMED - ) - ) - ) - - val exception = assertThrows(ResponseStatusException::class.java) { - service.startCreateSatelliteMission(noradId) - } - - assertEquals(HttpStatus.CONFLICT, exception.statusCode) - } - - @Test - fun `startCreateSatelliteMissionManually supersedes confirmed plan and starts new process`() { - val noradId = 25544L - val spacecraftId = noradId.toString() - val confirmedPlan = Plan( - spacecraftId = spacecraftId, - startTime = LocalDateTime.of(2026, 3, 10, 10, 0), - endTime = LocalDateTime.of(2026, 3, 11, 10, 0), - kppId = "KPP-1", - planId = UUID.randomUUID() - ) - val pointsMessage = SpacecraftPointsMessage( - spacecraftId = spacecraftId, - points = listOf(PointMessage(pointId = "KPP-1", observationWindows = emptyList())) - ) - val planCalculationService = mock(PlanCalculationService::class.java) - val planQueryService = mock(PlanQueryService::class.java) - val camundaClient = mock(CamundaClient::class.java) - val createCommand = mock(CreateProcessInstanceCommandStep1::class.java) - val processStep2 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2::class.java) - val processStep3 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3::class.java) - val future = mock(CamundaFuture::class.java) as CamundaFuture - val processInstanceEvent = mock(ProcessInstanceEvent::class.java) - - `when`(planCalculationService.calculateNearestPlanFrom(anyNonNull(), anyNonNull())).thenReturn(confirmedPlan) - `when`(camundaClient.newCreateInstanceCommand()).thenReturn(createCommand) - `when`(createCommand.bpmnProcessId("createSatelliteMission")).thenReturn(processStep2) - `when`(processStep2.latestVersion()).thenReturn(processStep3) - `when`(processStep3.variables(anyNonNull())).thenReturn(processStep3) - `when`(processStep3.send()).thenReturn(future) - `when`(future.join()).thenReturn(processInstanceEvent) - `when`(processInstanceEvent.getProcessInstanceKey()).thenReturn(123L) - `when`(processInstanceEvent.getProcessDefinitionKey()).thenReturn(456L) - `when`(processInstanceEvent.getBpmnProcessId()).thenReturn("createSatelliteMission") - `when`(processInstanceEvent.getVersion()).thenReturn(1) - - val trackedPlanService = FixedTrackedPlanService( - latestTrackedPlan = TrackedPlanEntity( - planId = confirmedPlan.planId, - spacecraftId = confirmedPlan.spacecraftId, - startTime = confirmedPlan.startTime, - endTime = confirmedPlan.endTime, - kppId = confirmedPlan.kppId, - status = TrackedPlanStatus.CONFIRMED - ) - ) - val service = SatelliteMissionProcessService( - camundaClient = camundaClient, - platformService = FixedPlatformService(listOf(platform(noradId))), - spacecraftPointsService = FixedSpacecraftPointsService(pointsMessage), - planCalculationService = planCalculationService, - planQueryService = planQueryService, - trackedPlanService = trackedPlanService - ) - - val response = service.startCreateSatelliteMissionManually(noradId) - - assertEquals(123L, response.processInstanceKey) - assertEquals(confirmedPlan.planId, trackedPlanService.supersededPlanId) - assertEquals(response.planId, trackedPlanService.supersededByPlanId) - assertEquals(TrackedPlanSource.MANUAL, trackedPlanService.registeredSource) - } - - @Test - fun `startCreateSatelliteMission allows repeated start for waiting confirmation plan`() { - val noradId = 25544L - val spacecraftId = noradId.toString() - val waitingPlan = Plan( - spacecraftId = spacecraftId, - startTime = LocalDateTime.of(2026, 3, 10, 10, 0), - endTime = LocalDateTime.of(2026, 3, 11, 10, 0), - kppId = "KPP-1", - planId = UUID.randomUUID() - ) - val pointsMessage = SpacecraftPointsMessage( - spacecraftId = spacecraftId, - points = listOf(PointMessage(pointId = "KPP-1", observationWindows = emptyList())) - ) - val planCalculationService = mock(PlanCalculationService::class.java) - val planQueryService = mock(PlanQueryService::class.java) - - `when`(planCalculationService.calculateNearestPlanFrom(anyNonNull(), anyNonNull())).thenReturn(waitingPlan) - `when`(planQueryService.reuseExistingPlan(waitingPlan)).thenReturn(waitingPlan) - - val camundaClient = mock(CamundaClient::class.java) - val createCommand = mock(CreateProcessInstanceCommandStep1::class.java) - val processStep2 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep2::class.java) - val processStep3 = mock(CreateProcessInstanceCommandStep1.CreateProcessInstanceCommandStep3::class.java) - val future = mock(CamundaFuture::class.java) as CamundaFuture - val processInstanceEvent = mock(ProcessInstanceEvent::class.java) - - `when`(camundaClient.newCreateInstanceCommand()).thenReturn(createCommand) - `when`(createCommand.bpmnProcessId("createSatelliteMission")).thenReturn(processStep2) - `when`(processStep2.latestVersion()).thenReturn(processStep3) - `when`(processStep3.variables(anyNonNull())).thenReturn(processStep3) - `when`(processStep3.send()).thenReturn(future) - `when`(future.join()).thenReturn(processInstanceEvent) - `when`(processInstanceEvent.getProcessInstanceKey()).thenReturn(123L) - `when`(processInstanceEvent.getProcessDefinitionKey()).thenReturn(456L) - `when`(processInstanceEvent.getBpmnProcessId()).thenReturn("createSatelliteMission") - `when`(processInstanceEvent.getVersion()).thenReturn(1) - - val trackedPlanService = FixedTrackedPlanService() - val service = SatelliteMissionProcessService( - camundaClient = camundaClient, - platformService = FixedPlatformService(listOf(platform(noradId))), - spacecraftPointsService = FixedSpacecraftPointsService(pointsMessage), - planCalculationService = planCalculationService, - planQueryService = planQueryService, - trackedPlanService = trackedPlanService - ) - - val response = service.startCreateSatelliteMission(noradId) - - assertEquals(waitingPlan.planId, response.planId) - assertEquals(123L, response.processInstanceKey) - assertEquals(waitingPlan.planId, trackedPlanService.registeredPlan?.planId) - assertEquals(123L, trackedPlanService.attachedProcessInstanceKey) - verify(planCalculationService).calculateNearestPlanFrom(anyNonNull(), anyNonNull()) - verify(planQueryService).reuseExistingPlan(waitingPlan) - } - - @Test - fun `startCreateSatelliteMissionManually returns not found when platform is not allowed`() { - val service = SatelliteMissionProcessService( - camundaClient = mock(CamundaClient::class.java), - platformService = FixedPlatformService(emptyList()), - spacecraftPointsService = FixedSpacecraftPointsService( - SpacecraftPointsMessage("25544", emptyList()) - ), - planCalculationService = mock(PlanCalculationService::class.java), - planQueryService = mock(PlanQueryService::class.java), - trackedPlanService = FixedTrackedPlanService() - ) - - val exception = assertThrows(ResponseStatusException::class.java) { - service.startCreateSatelliteMissionManually(25544L) - } - - assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) - } - - private class FixedPlatformService( - private val platforms: List - ) : PlatformService( - mock(PlatformsClassifierClient::class.java), - ClassifierProperties( - platformsUrl = "http://localhost", - platformsCacheTtl = java.time.Duration.ofMinutes(15), - allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") - ) - ) { - override fun loadAllowedPlatforms(now: Instant): List = platforms - } - - private class FixedSpacecraftPointsService( - private val message: SpacecraftPointsMessage - ) : SpacecraftPointsService( - mock(SpacecraftPointsRepository::class.java), - mock(PlanRepository::class.java), - mock(PlanCalculationService::class.java), - mock(TrackedPlanService::class.java), - mock(SpacecraftPlanningStateService::class.java), - PlanningProperties( - notificationBeforeStartMinutes = 30, - targetPlanDurationMinutes = 24 * 60L, - targetPlanDurationDeltaMinutes = 60L, - externalUpdateEnabled = false, - externalPointsLookaheadDays = 3, - inMemoryHistoryDays = 14, - externalUpdateFixedDelayMs = 0, - notificationCheckFixedDelayMs = 30_000 - ) - ) { - override fun getMessageBySpacecraftId(spacecraftId: String): SpacecraftPointsMessage = message - - override fun getSpacecraftPointsBySpacecraftId(spacecraftId: String): SpacecraftPoints = - SpacecraftPoints(spacecraftId = spacecraftId, points = emptyList()) - } - - private class FixedTrackedPlanService( - private val confirmedPlan: TrackedPlanEntity? = null, - private val latestTrackedPlan: TrackedPlanEntity? = null - ) : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) { - var registeredPlan: Plan? = null - var registeredSource: TrackedPlanSource? = null - var attachedProcessInstanceKey: Long? = null - var supersededPlanId: UUID? = null - var supersededByPlanId: UUID? = null - - override fun findConfirmedPlan(plan: Plan): TrackedPlanEntity? = confirmedPlan - - override fun findLatestTrackedPlanByBusinessFields(plan: Plan): TrackedPlanEntity? = latestTrackedPlan - - override fun register(plan: Plan, source: TrackedPlanSource, basePlanId: UUID?): TrackedPlanEntity { - registeredPlan = plan - registeredSource = source - return TrackedPlanEntity( - planId = plan.planId, - spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, - kppId = plan.kppId - ) - } - - override fun attachProcessInstance(planId: UUID, processInstanceKey: Long): TrackedPlanEntity { - attachedProcessInstanceKey = processInstanceKey - return TrackedPlanEntity( - planId = planId, - spacecraftId = registeredPlan!!.spacecraftId, - startTime = registeredPlan!!.startTime, - endTime = registeredPlan!!.endTime, - kppId = registeredPlan!!.kppId, - processInstanceKey = processInstanceKey - ) - } - - override fun markSuperseded(planId: UUID, supersededByPlanId: UUID): TrackedPlanEntity { - supersededPlanId = planId - this.supersededByPlanId = supersededByPlanId - val plan = latestTrackedPlan ?: error("latestTrackedPlan is required") - return TrackedPlanEntity( - planId = plan.planId, - spacecraftId = plan.spacecraftId, - startTime = plan.startTime, - endTime = plan.endTime, - kppId = plan.kppId, - status = TrackedPlanStatus.SUPERSEDED, - supersededByPlanId = supersededByPlanId - ) - } - } - - private fun platform(noradId: Long): PlatformCatalogItem = - PlatformCatalogItem( - nsiId = UUID.randomUUID().toString(), - businessKey = "business-key-$noradId", - name = "satellite", - status = "OPERATIONAL", - noradId = noradId, - mission = "MISSION", - validFrom = Instant.parse("2026-01-01T00:00:00Z"), - validTo = Instant.parse("9999-12-31T23:59:59Z") - ) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsServiceTest.kt deleted file mode 100644 index c4cdcf6..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftPointsServiceTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.mockito.ArgumentMatchers.anyList -import org.mockito.ArgumentMatchers.anyString -import org.mockito.Mockito.doAnswer -import org.mockito.Mockito.mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.ObservationWindow -import space.nstart.pcp_tgu_service.domain.Plan -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.domain.InsertionPoint -import space.nstart.pcp_tgu_service.repository.PlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository -import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository -import java.time.LocalDateTime -import java.util.UUID - -class SpacecraftPointsServiceTest { - - @Test - fun `saveAndRecalculate keeps confirmed plans in output after recalculation`() { - val spacecraftPointsRepository = mock(SpacecraftPointsRepository::class.java) - val planRepository = mock(PlanRepository::class.java) - val planCalculationService = mock(PlanCalculationService::class.java) - val planningProperties = PlanningProperties( - notificationBeforeStartMinutes = 30, - targetPlanDurationMinutes = 24 * 60L, - targetPlanDurationDeltaMinutes = 60L, - externalUpdateEnabled = false, - externalPointsLookaheadDays = 3, - inMemoryHistoryDays = 14, - externalUpdateFixedDelayMs = 0, - notificationCheckFixedDelayMs = 30_000 - ) - val spacecraftId = "25544" - val confirmedPlan = Plan( - spacecraftId = spacecraftId, - startTime = LocalDateTime.of(2026, 3, 10, 10, 0), - endTime = LocalDateTime.of(2026, 3, 11, 10, 0), - kppId = "KPP-1", - planId = UUID.randomUUID() - ) - val trackedPlanService = object : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) { - override fun getConfirmedPlansBySpacecraftIdSince(spacecraftId: String, since: LocalDateTime): List = - listOf(confirmedPlan) - } - val spacecraftPlanningStateService = - object : SpacecraftPlanningStateService(mock(SpacecraftPlanningStateJpaRepository::class.java)) { - override fun getCurrentBasePlanId(spacecraftId: String): UUID? = null - } - - val service = SpacecraftPointsService( - spacecraftPointsRepository = spacecraftPointsRepository, - planRepository = planRepository, - planCalculationService = planCalculationService, - trackedPlanService = trackedPlanService, - spacecraftPlanningStateService = spacecraftPlanningStateService, - planningProperties = planningProperties - ) - - val duplicateRecalculatedPlan = Plan( - spacecraftId = spacecraftId, - startTime = confirmedPlan.startTime, - endTime = confirmedPlan.endTime, - kppId = confirmedPlan.kppId, - planId = UUID.randomUUID() - ) - val newRecalculatedPlan = Plan( - spacecraftId = spacecraftId, - startTime = LocalDateTime.of(2026, 3, 11, 12, 0), - endTime = LocalDateTime.of(2026, 3, 12, 12, 0), - kppId = "KPP-2", - planId = UUID.randomUUID() - ) - val spacecraftPoints = SpacecraftPoints( - spacecraftId = spacecraftId, - points = listOf( - InsertionPoint( - pointId = "KPP-1", - observationWindows = listOf( - ObservationWindow( - startTime = confirmedPlan.startTime, - endTime = confirmedPlan.endTime, - elevationAngle = 10.0 - ) - ) - ) - ) - ) - - `when`(planCalculationService.calculatePlans(spacecraftPoints)) - .thenReturn(listOf(duplicateRecalculatedPlan, newRecalculatedPlan)) - var savedSpacecraftId: String? = null - var savedPlans: List = emptyList() - doAnswer { invocation -> - savedSpacecraftId = invocation.getArgument(0) - savedPlans = invocation.getArgument(1) - null - }.`when`(planRepository).replacePlans(anyString(), anyList()) - - service.saveAndRecalculate(spacecraftPoints) - - verify(planRepository).replacePlans(anyString(), anyList()) - assertEquals(spacecraftId, savedSpacecraftId) - assertEquals(2, savedPlans.size) - assertTrue(savedPlans.any { it.planId == confirmedPlan.planId }) - assertTrue(savedPlans.any { it.planId == newRecalculatedPlan.planId }) - assertTrue(savedPlans.none { it.planId == duplicateRecalculatedPlan.planId }) - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt new file mode 100644 index 0000000..7ac41da --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt @@ -0,0 +1,96 @@ +package space.nstart.pcp_tgu_service.service + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.security.MessageDigest +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Optional + +class VisibilityFlowTest { + + @Test + fun `parser builds spacecraft points from raw payload and keeps station ordering`() { + val parser = parser() + + val points = parser.parse("25544", rawPayload(stationId = 9) + rawPayloadTail(stationId = 7)) + + assertEquals("25544", points.spacecraftId) + assertEquals(listOf("7", "9"), points.points.map { point -> point.pointId }) + assertEquals( + Instant.parse("2026-05-26T10:00:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime(), + points.points[1].observationWindows.single().startTime + ) + } + + @Test + fun `same visibility payload updates fetchedAt but does not enqueue recalculation`() { + val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val payload = rawPayloadArray(stationId = 7) + val existing = SpacecraftVisibilitySnapshotEntity( + spacecraftId = "25544", + noradId = 25544L, + payloadJson = payload, + payloadHash = sha256(payload), + fetchedAt = LocalDateTime.of(2026, 5, 26, 10, 0) + ) + `when`(repository.findById("25544")).thenReturn(Optional.of(existing)) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + + service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) + + assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt) + verify(recalculationJobService, never()).enqueue("25544", "VISIBILITY_CHANGED") + } + + @Test + fun `changed visibility payload stores raw payload and enqueues recalculation`() { + val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) + val recalculationJobService = mock(PlanRecalculationJobService::class.java) + `when`(repository.findById("25544")).thenReturn(Optional.empty()) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val payload = rawPayloadArray(stationId = 7) + + service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) + + verify(repository, times(1)).save(anyNonNull()) + verify(recalculationJobService).enqueue("25544", "VISIBILITY_CHANGED") + } + + private fun parser(): VisibilityPayloadParser = + VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) + + private fun rawPayloadArray(stationId: Long): String = + rawPayload(stationId).plus("]") + + private fun rawPayload(stationId: Long): String = + """[{"noradId":25544,"stationId":$stationId,"revolution":1,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}""" + + private fun rawPayloadTail(stationId: Long): String = + """,{"noradId":25544,"stationId":$stationId,"revolution":1,"onStart":{"time":"2026-05-26T11:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T11:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T11:10:00Z","elevation":1.0}}]""" + + private fun sha256(value: String): String = + MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> "%02x".format(byte) } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +}