Основные изменения:
состояние окон видимости, расписания планов, попыток выдачи и job’ов перенесено в БД; план и попытка выдачи разделены на разные сущности; Kafka-события теперь двигают persisted state/jobs, а не локальную память pod’а; BPMN createSatelliteMission теперь отвечает только за одну попытку выдачи плана; обновление слотов после принятия плана вынесено в отдельный BPMN-процесс; workers используют DB claim/lock model и безопасны для нескольких pod’ов; тестовый ручной API вынесен в отдельный disabled-by-default controller
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
|
||||
+7
-1
@@ -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
|
||||
)
|
||||
|
||||
+5
-1
@@ -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
|
||||
)
|
||||
|
||||
-46
@@ -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<SpacecraftPoints> =
|
||||
Mono.fromCallable {
|
||||
externalPointsClient.fetchSpacecraftPoints(satelliteId, timeStart, timeStop)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
-44
@@ -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<TrackedPlanResponse> =
|
||||
Mono.fromCallable {
|
||||
TrackedPlanResponse.from(trackedPlanService.getByPlanId(planId))
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Подтверждает план и только фиксирует этот факт в БД. */
|
||||
@PostMapping
|
||||
@Operation(summary = "Подтвердить план по planId и сохранить подтверждение в БД")
|
||||
fun confirmPlan(@Valid @RequestBody request: PlanConfirmationRequest): Mono<TrackedPlanResponse> =
|
||||
Mono.fromCallable {
|
||||
TrackedPlanResponse.from(planConfirmationService.confirm(request.planId))
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
+2
-6
@@ -21,9 +21,7 @@ class PlanController(
|
||||
@GetMapping
|
||||
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono<List<PlanResponse>> =
|
||||
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<List<PlanResponse>> =
|
||||
Mono.fromCallable {
|
||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map { planView ->
|
||||
PlanResponse.from(planView.plan, planView.status)
|
||||
}
|
||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
|
||||
-35
@@ -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<ProcessStartResponse> =
|
||||
Mono.fromCallable {
|
||||
satelliteMissionProcessService.startCreateSatelliteMissionManually(noradId)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
+95
@@ -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<Void> =
|
||||
Mono.fromRunnable<Void> {
|
||||
visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER")
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Ставит и сразу выполняет rebuild persisted schedule для КА. */
|
||||
@PostMapping("/spacecraft/{spacecraftId}/rebuild-plans")
|
||||
fun rebuildPlans(@PathVariable spacecraftId: String): Mono<Void> =
|
||||
Mono.fromRunnable<Void> {
|
||||
planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER")
|
||||
rebuildPlanScheduleUseCase.rebuild(spacecraftId)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */
|
||||
@PostMapping("/plans/{planId}/issue")
|
||||
fun issuePlan(@PathVariable planId: UUID): Mono<ProcessStartResponse> =
|
||||
Mono.fromCallable {
|
||||
issueDuePlanWorker.issuePlanNow(planId)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Инжектит decision event через тот же use case, что Kafka consumer. */
|
||||
@PostMapping("/plan-decisions")
|
||||
fun handleDecision(@RequestBody message: PlanDecisionMessage): Mono<Void> =
|
||||
Mono.fromRunnable<Void> {
|
||||
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<String> =
|
||||
Mono.fromCallable {
|
||||
externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop)
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
|
||||
/** Возвращает persisted plan для проверки результата ручных операций. */
|
||||
@GetMapping("/plans/{planId}")
|
||||
fun getPlan(@PathVariable planId: UUID): Mono<PlanResponse> =
|
||||
Mono.fromCallable {
|
||||
PlanResponse.from(planQueryService.getByPlanId(planId))
|
||||
}.subscribeOn(Schedulers.boundedElastic())
|
||||
}
|
||||
+23
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package space.nstart.pcp_tgu_service.domain
|
||||
|
||||
/**
|
||||
* Решение внешней системы по конкретной попытке выдачи плана.
|
||||
*/
|
||||
enum class PlanDecision {
|
||||
ACCEPTED,
|
||||
REJECTED
|
||||
}
|
||||
+12
@@ -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
|
||||
)
|
||||
|
||||
+31
@@ -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?
|
||||
)
|
||||
+6
-6
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
-33
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+21
@@ -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?
|
||||
)
|
||||
+49
@@ -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())
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/**
|
||||
* Жизненный цикл job, которая запускает короткий BPMN updateAcceptedPlanSlots.
|
||||
*/
|
||||
enum class AcceptedPlanSlotsUpdateJobStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
DONE,
|
||||
FAILED,
|
||||
AMBIGUOUS
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/**
|
||||
* Жизненный цикл фоновой job, которая безопасно claim'ится несколькими pod через БД.
|
||||
*/
|
||||
enum class JobStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
DONE,
|
||||
FAILED
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/**
|
||||
* Жизненный цикл одной попытки выдачи persisted plan через BPMN.
|
||||
*/
|
||||
enum class PlanIssueAttemptStatus {
|
||||
STARTING,
|
||||
STARTED,
|
||||
ACCEPTED,
|
||||
REJECTED,
|
||||
FAILED,
|
||||
AMBIGUOUS
|
||||
}
|
||||
+48
@@ -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 = "")
|
||||
}
|
||||
+67
@@ -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 = ""
|
||||
)
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+25
@@ -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())
|
||||
}
|
||||
+9
@@ -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()
|
||||
) {
|
||||
|
||||
+54
@@ -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)
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
+51
@@ -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 = "")
|
||||
}
|
||||
+18
-52
@@ -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<ExternalRvaItemDto>::class.java)
|
||||
.bodyToMono(String::class.java)
|
||||
.block()
|
||||
?.toList()
|
||||
.orEmpty()
|
||||
|
||||
return mapToDomain(satelliteId, response)
|
||||
}
|
||||
|
||||
/** Группирует внешние записи RVA по станциям и переводит их во внутреннюю модель планирования. */
|
||||
private fun mapToDomain(
|
||||
satelliteId: Long,
|
||||
response: List<ExternalRvaItemDto>
|
||||
): 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,
|
||||
|
||||
+52
@@ -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<ExternalRvaItemDto>::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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
-47
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
@@ -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<AcceptedPlanSlotsUpdateJobEntity, UUID> {
|
||||
/** Атомарно создаёт 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<AcceptedPlanSlotsUpdateJobEntity>
|
||||
}
|
||||
+25
@@ -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<PlanIssueAttemptEntity, UUID> {
|
||||
/** Возвращает последний номер попытки для 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?
|
||||
}
|
||||
+85
@@ -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<PlanRecalculationJobEntity, String> {
|
||||
/** Атомарно ставит или переоткрывает 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<PlanRecalculationJobEntity>
|
||||
}
|
||||
-77
@@ -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<String, MutableList<Plan>> = ConcurrentHashMap()
|
||||
|
||||
/**
|
||||
* Полностью заменяет список планов по одному КА.
|
||||
*
|
||||
* Используется после пересчёта планов.
|
||||
*
|
||||
* @param spacecraftId идентификатор космического аппарата.
|
||||
* @param plans новый список планов.
|
||||
*/
|
||||
fun replacePlans(spacecraftId: String, plans: List<Plan>) {
|
||||
storage[spacecraftId] = plans
|
||||
.sortedBy { plan -> plan.startTime }
|
||||
.toMutableList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает все планы по всем КА.
|
||||
*
|
||||
* @return общий отсортированный список планов.
|
||||
*/
|
||||
fun findAll(): List<Plan> =
|
||||
storage.values
|
||||
.flatten()
|
||||
.sortedBy { plan -> plan.startTime }
|
||||
|
||||
/**
|
||||
* Возвращает список планов по одному КА.
|
||||
*
|
||||
* @param spacecraftId идентификатор космического аппарата.
|
||||
* @return список планов указанного КА.
|
||||
*/
|
||||
fun findBySpacecraftId(spacecraftId: String): List<Plan> =
|
||||
storage[spacecraftId]
|
||||
?.sortedBy { plan -> plan.startTime }
|
||||
?: emptyList()
|
||||
|
||||
/**
|
||||
* Обновляет конкретный план.
|
||||
*
|
||||
* План ищется по spacecraftId и startTime.
|
||||
*
|
||||
* @param spacecraftId идентификатор космического аппарата.
|
||||
* @param updatedPlan обновлённый план.
|
||||
*/
|
||||
fun updatePlan(spacecraftId: String, updatedPlan: Plan) {
|
||||
val plans: MutableList<Plan> = storage[spacecraftId] ?: return
|
||||
|
||||
val index: Int = plans.indexOfFirst { existingPlan ->
|
||||
existingPlan.startTime == updatedPlan.startTime
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
plans[index] = updatedPlan
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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<PlannedPlanEntity, UUID> {
|
||||
/** Ищет persisted plan по стабильному business key. */
|
||||
fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(
|
||||
spacecraftId: String,
|
||||
startTime: LocalDateTime,
|
||||
endTime: LocalDateTime,
|
||||
kppId: String
|
||||
): PlannedPlanEntity?
|
||||
|
||||
/** Возвращает планы КА в порядке старта. */
|
||||
fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает все планы в порядке старта. */
|
||||
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает будущие planned-планы КА. */
|
||||
fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(
|
||||
spacecraftId: String,
|
||||
status: PlannedPlanStatus,
|
||||
startTime: LocalDateTime
|
||||
): List<PlannedPlanEntity>
|
||||
|
||||
/** Возвращает ближайший 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<PlannedPlanEntity>
|
||||
}
|
||||
+27
@@ -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<ProcessedPlanDecisionEventEntity, UUID> {
|
||||
/** Атомарно вставляет 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
|
||||
}
|
||||
+15
-1
@@ -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<SpacecraftPlanningStateEntity, String>
|
||||
interface SpacecraftPlanningStateJpaRepository : JpaRepository<SpacecraftPlanningStateEntity, String> {
|
||||
/** Блокирует строку состояния КА для изменения active/base указателей. */
|
||||
@Query(
|
||||
value = """
|
||||
SELECT *
|
||||
FROM spacecraft_planning_state
|
||||
WHERE spacecraft_id = :spacecraftId
|
||||
FOR UPDATE
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun lockBySpacecraftId(@Param("spacecraftId") spacecraftId: String): SpacecraftPlanningStateEntity?
|
||||
}
|
||||
|
||||
-48
@@ -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<String, SpacecraftPoints> = 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<SpacecraftPoints> =
|
||||
storage.values.toList()
|
||||
}
|
||||
+101
@@ -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<SpacecraftSnapshotEntity, String> {
|
||||
/** Возвращает только активные КА для рабочих фоновых процессов. */
|
||||
fun findAllByActiveTrue(): List<SpacecraftSnapshotEntity>
|
||||
|
||||
/** Атомарно создаёт или обновляет 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<String>,
|
||||
@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
|
||||
}
|
||||
+7
@@ -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<SpacecraftVisibilitySnapshotEntity, String>
|
||||
+89
@@ -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<VisibilityRefreshJobEntity, String> {
|
||||
/** Атомарно ставит или переоткрывает 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<VisibilityRefreshJobEntity>
|
||||
}
|
||||
+21
@@ -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())
|
||||
}
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
+101
@@ -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<AcceptedPlanSlotsUpdateJobEntity> =
|
||||
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
|
||||
}
|
||||
}
|
||||
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
-76
@@ -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)
|
||||
}
|
||||
}
|
||||
+172
@@ -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
|
||||
}
|
||||
+162
@@ -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<PreparedAttempt> {
|
||||
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
|
||||
)
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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<Plan> {
|
||||
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
|
||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
|
||||
|
||||
@@ -36,7 +36,7 @@ class PlanCalculationService(
|
||||
* следующий план начинается с нахлестом на одну ЗРВ.
|
||||
* Если сопоставление не удалось, используется безопасный fallback без нахлеста.
|
||||
*/
|
||||
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: Plan): List<Plan> {
|
||||
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
|
||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||
val candidates: List<StartCandidate> = 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<StartCandidate> = 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<StartCandidate>,
|
||||
initialStartIndex: Int
|
||||
): List<Plan> {
|
||||
): List<CalculatedPlan> {
|
||||
if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/** Результирующая цепочка планов. */
|
||||
val resultPlans: MutableList<Plan> = mutableListOf()
|
||||
val resultPlans: MutableList<CalculatedPlan> = 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,
|
||||
|
||||
-16
@@ -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)
|
||||
}
|
||||
-58
@@ -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
|
||||
}
|
||||
}
|
||||
+24
-96
@@ -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<PlanView> =
|
||||
(
|
||||
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<PlanView> {
|
||||
/** Текущий момент времени для отсечения прошлого и будущего. */
|
||||
val now: LocalDateTime = LocalDateTime.now()
|
||||
/** Нормализованное количество дней истории, которое не может быть отрицательным. */
|
||||
val normalizedHistoryDays: Long = historyDays.coerceAtLeast(0)
|
||||
val historySince = now.minusDays(normalizedHistoryDays)
|
||||
/** Tracked-планы, которые уже участвуют в пользовательской выдаче. */
|
||||
val trackedPlansForView: List<PlanView> = 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<UUID> = trackedPlansForView.map { planView -> planView.plan.planId }.toSet()
|
||||
/** Будущие планы из памяти, которые ещё не вошли в tracked-хранилище. */
|
||||
val futureNewPlans: List<PlanView> = 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<PlannedPlanEntity> {
|
||||
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<PlannedPlanEntity> {
|
||||
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") }
|
||||
}
|
||||
|
||||
+20
@@ -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())
|
||||
}
|
||||
}
|
||||
+85
@@ -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<PlanRecalculationJobEntity> =
|
||||
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
|
||||
}
|
||||
}
|
||||
-32
@@ -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)
|
||||
}
|
||||
}
|
||||
+113
@@ -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<String>,
|
||||
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"
|
||||
}
|
||||
-174
@@ -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
|
||||
}
|
||||
}
|
||||
-32
@@ -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)
|
||||
}
|
||||
}
|
||||
-136
@@ -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<Plan> = 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<Plan> = if (basePlan == null) {
|
||||
planCalculationService.calculatePlans(spacecraftPoints)
|
||||
} else {
|
||||
planCalculationService.calculatePlansAfter(spacecraftPoints, basePlan)
|
||||
}
|
||||
|
||||
/** Набор бизнес-ключей подтвержденных планов, чтобы не дублировать их в хвосте. */
|
||||
val confirmedKeys: Set<String> = confirmedHistory.map { confirmedPlan -> confirmedPlan.toBusinessKey() }.toSet()
|
||||
/** Итоговая объединенная цепочка для хранения в памяти. */
|
||||
val mergedPlans: List<Plan> = confirmedHistory + futurePlans.filter { plan ->
|
||||
plan.toBusinessKey() !in confirmedKeys
|
||||
}
|
||||
|
||||
planRepository.replacePlans(spacecraftId, mergedPlans)
|
||||
}
|
||||
|
||||
/** Строит стабильный бизнес-ключ для подавления дублей после пересчёта. */
|
||||
private fun Plan.toBusinessKey(): String =
|
||||
"$spacecraftId|$startTime|$endTime|$kppId"
|
||||
}
|
||||
+91
@@ -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
|
||||
)
|
||||
}
|
||||
+56
@@ -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<PlatformCatalogItem>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-179
@@ -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<Plan> =
|
||||
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<TrackedPlanEntity> =
|
||||
trackedPlanJpaRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
|
||||
|
||||
/** Возвращает идентификаторы КА, для которых есть tracked-планы. */
|
||||
@Transactional(readOnly = true)
|
||||
fun getAllTrackedSpacecraftIds(): List<String> =
|
||||
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
|
||||
)
|
||||
}
|
||||
+20
@@ -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())
|
||||
}
|
||||
}
|
||||
+113
@@ -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<VisibilityRefreshJobEntity> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,124 +1,70 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_05089qd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="5.41.0" modeler:executionPlatform="Camunda Cloud" modeler:executionPlatformVersion="8.8.0">
|
||||
<bpmn:process id="createSatelliteMission" isExecutable="true">
|
||||
<bpmn:endEvent id="endSatelliteMission">
|
||||
<bpmn:incoming>Flow_15fg4ts</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="Flow_0i87yhe" sourceRef="calculateSatelliteSurveyMissions" targetRef="calculateSatelliteDropMissions" />
|
||||
<bpmn:sequenceFlow id="Flow_0m0ymgb" sourceRef="sendSatellitePlan" targetRef="satellitePlanAccepted" />
|
||||
<bpmn:sequenceFlow id="Flow_0r63sxk" sourceRef="satellitePlanAccepted" targetRef="updatePlanStatus" />
|
||||
<bpmn:sequenceFlow id="Flow_105c46r" sourceRef="startSatelliteMission" targetRef="calculateSatelliteSurveyMissions" />
|
||||
<bpmn:sequenceFlow id="Flow_141jv88" sourceRef="calculateSatelliteDropMissions" targetRef="sendSatellitePlan" />
|
||||
<bpmn:sequenceFlow id="Flow_15fg4ts" sourceRef="updateSlotsStatus" targetRef="endSatelliteMission" />
|
||||
<bpmn:sequenceFlow id="Flow_15tmgj4" sourceRef="updatePlanStatus" targetRef="updateSlotsStatus" />
|
||||
<bpmn:intermediateCatchEvent id="satellitePlanAccepted">
|
||||
<bpmn:incoming>Flow_0m0ymgb</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_0r63sxk</bpmn:outgoing>
|
||||
<bpmn:messageEventDefinition id="MessageEventDefinition_0wt6aih" messageRef="Message_1vkej6h" />
|
||||
</bpmn:intermediateCatchEvent>
|
||||
<bpmn:startEvent id="startSatelliteMission">
|
||||
<bpmn:outgoing>Flow_105c46r</bpmn:outgoing>
|
||||
<bpmn:outgoing>Flow_start_survey</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:serviceTask id="updatePlanStatus" name="Обновление статуса плана">
|
||||
<bpmn:serviceTask id="calculateSatelliteSurveyMissions" name="Сформировать план съемки">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:taskDefinition type="updatePlanStatus" />
|
||||
<zeebe:taskDefinition type="calculateSatelliteSurveyMissions" retries="3" />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_0r63sxk</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_15tmgj4</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="updateSlotsStatus" name="Обновление статуса слотов">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:taskDefinition type="updateSlotsStatus" />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_15tmgj4</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_15fg4ts</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="sendSatellitePlan" name="Отправка плана в СПО УОГ">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:taskDefinition type="sendSatellitePlan" />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_141jv88</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_0m0ymgb</bpmn:outgoing>
|
||||
<bpmn:incoming>Flow_start_survey</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_survey_drop</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="calculateSatelliteDropMissions" name="Сформировать план сбросов">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:taskDefinition type="calculateSatelliteDropMissions" retries="3" />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_0i87yhe</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_141jv88</bpmn:outgoing>
|
||||
<bpmn:incoming>Flow_survey_drop</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_drop_send</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:serviceTask id="calculateSatelliteSurveyMissions" name="Сформировать план съемки">
|
||||
<bpmn:serviceTask id="sendSatellitePlan" name="Отправка плана в СПО УОГ">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:taskDefinition type="calculateSatelliteSurveyMissions" retries="3" />
|
||||
<zeebe:taskDefinition type="sendSatellitePlan" />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_105c46r</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_0i87yhe</bpmn:outgoing>
|
||||
<bpmn:incoming>Flow_drop_send</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_send_end</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:endEvent id="endSatelliteMission">
|
||||
<bpmn:incoming>Flow_send_end</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="Flow_start_survey" sourceRef="startSatelliteMission" targetRef="calculateSatelliteSurveyMissions" />
|
||||
<bpmn:sequenceFlow id="Flow_survey_drop" sourceRef="calculateSatelliteSurveyMissions" targetRef="calculateSatelliteDropMissions" />
|
||||
<bpmn:sequenceFlow id="Flow_drop_send" sourceRef="calculateSatelliteDropMissions" targetRef="sendSatellitePlan" />
|
||||
<bpmn:sequenceFlow id="Flow_send_end" sourceRef="sendSatellitePlan" targetRef="endSatelliteMission" />
|
||||
</bpmn:process>
|
||||
<bpmn:message id="Message_0caslea" />
|
||||
<bpmn:message id="Message_39ltke4" />
|
||||
<bpmn:message id="Message_0nothk0" />
|
||||
<bpmn:message id="Message_1vkej6h" name="Message_SatellitePlanAccepted">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:subscription correlationKey="=satellitePlanId" />
|
||||
</bpmn:extensionElements>
|
||||
</bpmn:message>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="createSatelliteMission">
|
||||
<bpmndi:BPMNShape id="Event_08dmvzc_di" bpmnElement="satellitePlanAccepted">
|
||||
<dc:Bounds x="752" y="102" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="startSatelliteMission">
|
||||
<dc:Bounds x="182" y="102" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_1o0a7ou_di" bpmnElement="calculateSatelliteSurveyMissions">
|
||||
<dc:Bounds x="270" y="80" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0xhz2da_di" bpmnElement="calculateSatelliteDropMissions">
|
||||
<dc:Bounds x="430" y="80" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0rgmxq5_di" bpmnElement="sendSatellitePlan">
|
||||
<dc:Bounds x="590" y="80" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0xhz2da_di" bpmnElement="calculateSatelliteDropMissions">
|
||||
<dc:Bounds x="430" y="80" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_1o0a7ou_di" bpmnElement="calculateSatelliteSurveyMissions">
|
||||
<dc:Bounds x="270" y="80" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_1yorqot_di" bpmnElement="updatePlanStatus">
|
||||
<dc:Bounds x="840" y="80" width="100" height="80" />
|
||||
<bpmndi:BPMNLabel />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_01df1b7_di" bpmnElement="updateSlotsStatus">
|
||||
<dc:Bounds x="1000" y="80" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Event_0ua03fi_di" bpmnElement="endSatelliteMission">
|
||||
<dc:Bounds x="1152" y="102" width="36" height="36" />
|
||||
<dc:Bounds x="752" y="102" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Flow_0i87yhe_di" bpmnElement="Flow_0i87yhe">
|
||||
<di:waypoint x="370" y="120" />
|
||||
<di:waypoint x="430" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_0m0ymgb_di" bpmnElement="Flow_0m0ymgb">
|
||||
<di:waypoint x="690" y="120" />
|
||||
<di:waypoint x="752" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_105c46r_di" bpmnElement="Flow_105c46r">
|
||||
<bpmndi:BPMNEdge id="Flow_start_survey_di" bpmnElement="Flow_start_survey">
|
||||
<di:waypoint x="218" y="120" />
|
||||
<di:waypoint x="270" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_141jv88_di" bpmnElement="Flow_141jv88">
|
||||
<bpmndi:BPMNEdge id="Flow_survey_drop_di" bpmnElement="Flow_survey_drop">
|
||||
<di:waypoint x="370" y="120" />
|
||||
<di:waypoint x="430" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_drop_send_di" bpmnElement="Flow_drop_send">
|
||||
<di:waypoint x="530" y="120" />
|
||||
<di:waypoint x="590" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_0r63sxk_di" bpmnElement="Flow_0r63sxk">
|
||||
<di:waypoint x="788" y="120" />
|
||||
<di:waypoint x="840" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_15fg4ts_di" bpmnElement="Flow_15fg4ts">
|
||||
<di:waypoint x="1100" y="120" />
|
||||
<di:waypoint x="1152" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_15tmgj4_di" bpmnElement="Flow_15tmgj4">
|
||||
<di:waypoint x="940" y="120" />
|
||||
<di:waypoint x="1000" y="120" />
|
||||
<bpmndi:BPMNEdge id="Flow_send_end_di" bpmnElement="Flow_send_end">
|
||||
<di:waypoint x="690" y="120" />
|
||||
<di:waypoint x="752" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_updateAcceptedPlanSlots" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="5.41.0" modeler:executionPlatform="Camunda Cloud" modeler:executionPlatformVersion="8.8.0">
|
||||
<bpmn:process id="updateAcceptedPlanSlots" isExecutable="true">
|
||||
<bpmn:startEvent id="startUpdateAcceptedPlanSlots">
|
||||
<bpmn:outgoing>Flow_start_update_slots</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:serviceTask id="updateSlotsStatus" name="Обновление статуса слотов">
|
||||
<bpmn:extensionElements>
|
||||
<zeebe:taskDefinition type="updateSlotsStatus" />
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_start_update_slots</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_update_slots_end</bpmn:outgoing>
|
||||
</bpmn:serviceTask>
|
||||
<bpmn:endEvent id="endUpdateAcceptedPlanSlots">
|
||||
<bpmn:incoming>Flow_update_slots_end</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="Flow_start_update_slots" sourceRef="startUpdateAcceptedPlanSlots" targetRef="updateSlotsStatus" />
|
||||
<bpmn:sequenceFlow id="Flow_update_slots_end" sourceRef="updateSlotsStatus" targetRef="endUpdateAcceptedPlanSlots" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_updateAcceptedPlanSlots">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_updateAcceptedPlanSlots" bpmnElement="updateAcceptedPlanSlots">
|
||||
<bpmndi:BPMNShape id="StartEvent_update_slots_di" bpmnElement="startUpdateAcceptedPlanSlots">
|
||||
<dc:Bounds x="182" y="102" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_updateSlotsStatus_di" bpmnElement="updateSlotsStatus">
|
||||
<dc:Bounds x="270" y="80" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="EndEvent_update_slots_di" bpmnElement="endUpdateAcceptedPlanSlots">
|
||||
<dc:Bounds x="432" y="102" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Flow_start_update_slots_di" bpmnElement="Flow_start_update_slots">
|
||||
<di:waypoint x="218" y="120" />
|
||||
<di:waypoint x="270" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_update_slots_end_di" bpmnElement="Flow_update_slots_end">
|
||||
<di:waypoint x="370" y="120" />
|
||||
<di:waypoint x="432" y="120" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
||||
@@ -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
|
||||
|
||||
+105
@@ -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;
|
||||
+11
@@ -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
|
||||
);
|
||||
+5
@@ -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;
|
||||
+6
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+189
@@ -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<Any>()
|
||||
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<ProcessInstanceEvent>
|
||||
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<Map<String, String>>())).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<UUID, AcceptedPlanSlotsUpdateJobEntity>
|
||||
): 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<AcceptedPlanSlotsUpdateJobEntity>(anyNonNull())).thenAnswer { invocation ->
|
||||
val jobs = invocation.arguments[0] as Iterable<AcceptedPlanSlotsUpdateJobEntity>
|
||||
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 <T> anyNonNull(): T {
|
||||
org.mockito.ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
|
||||
private fun <T> eqNonNull(value: T): T {
|
||||
org.mockito.ArgumentMatchers.eq(value)
|
||||
return value
|
||||
}
|
||||
}
|
||||
+301
@@ -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<String, VisibilityRefreshJobEntity>
|
||||
): 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<VisibilityRefreshJobEntity>(anyNonNull())).thenAnswer { invocation ->
|
||||
val jobs = invocation.arguments[0] as Iterable<VisibilityRefreshJobEntity>
|
||||
jobs.forEach { job -> store[job.spacecraftId] = job }
|
||||
jobs.toList()
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
private fun recalculationJobRepository(
|
||||
store: MutableMap<String, PlanRecalculationJobEntity>
|
||||
): 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<PlanRecalculationJobEntity>(anyNonNull())).thenAnswer { invocation ->
|
||||
val jobs = invocation.arguments[0] as Iterable<PlanRecalculationJobEntity>
|
||||
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 <T> anyNonNull(): T {
|
||||
org.mockito.ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -208,7 +208,9 @@ class PlanCalculationServiceTest {
|
||||
externalPointsLookaheadDays = 3,
|
||||
inMemoryHistoryDays = 14,
|
||||
externalUpdateFixedDelayMs = 0,
|
||||
notificationCheckFixedDelayMs = 30_000
|
||||
notificationCheckFixedDelayMs = 30_000,
|
||||
minReissueLeadMinutes = 5,
|
||||
workerLockTtlSeconds = 300
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
+434
@@ -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<UUID, PlanIssueAttemptEntity>()
|
||||
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<UUID, PlanIssueAttemptEntity>()
|
||||
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<UUID, PlanIssueAttemptEntity>()
|
||||
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<UUID, PlanIssueAttemptEntity>()
|
||||
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<UUID, PlannedPlanEntity>,
|
||||
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
|
||||
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<UUID, PlannedPlanEntity>): 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<UUID, PlanIssueAttemptEntity>): 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 <T> anyNonNull(): T {
|
||||
org.mockito.ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
}
|
||||
-99
@@ -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<TrackedPlanEntity>
|
||||
) : TrackedPlanService(mock(TrackedPlanJpaRepository::class.java)) {
|
||||
|
||||
override fun getTrackedPlansBySpacecraftId(spacecraftId: String): List<TrackedPlanEntity> =
|
||||
if (spacecraftId == this.spacecraftId) trackedPlans else emptyList()
|
||||
|
||||
override fun getAllTrackedSpacecraftIds(): List<String> = 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
|
||||
)
|
||||
}
|
||||
-40
@@ -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<CompleteJobResponse>
|
||||
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()
|
||||
}
|
||||
}
|
||||
+175
@@ -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<UUID, PlannedPlanEntity>()
|
||||
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<UUID, PlannedPlanEntity>): 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 <T> anyNonNull(): T {
|
||||
org.mockito.ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
}
|
||||
-332
@@ -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<ProcessInstanceEvent>
|
||||
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<CreateSatelliteMissionVariables>())).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<ProcessInstanceEvent>
|
||||
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<CreateSatelliteMissionVariables>())).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<PlatformCatalogItem>
|
||||
) : 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<PlatformCatalogItem> = 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 <T> anyNonNull(): T {
|
||||
ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
}
|
||||
-116
@@ -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<Plan> =
|
||||
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<Plan> = 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 })
|
||||
}
|
||||
}
|
||||
+96
@@ -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 <T> anyNonNull(): T {
|
||||
org.mockito.ArgumentMatchers.any<T>()
|
||||
return null as T
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user