Основные изменения:

состояние окон видимости, расписания планов, попыток выдачи и 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:
Дмитрий Соловьев
2026-05-27 10:29:41 +03:00
parent cf3bb63bc1
commit 3dce0aec0d
89 changed files with 3922 additions and 1953 deletions
@@ -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
@@ -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,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
)
@@ -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())
}
@@ -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())
}
@@ -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())
}
@@ -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())
}
@@ -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())
}
@@ -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
)
@@ -0,0 +1,9 @@
package space.nstart.pcp_tgu_service.domain
/**
* Решение внешней системы по конкретной попытке выдачи плана.
*/
enum class PlanDecision {
ACCEPTED,
REJECTED
}
@@ -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
)
@@ -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?
)
@@ -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
)
}
}
@@ -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
)
@@ -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
)
}
}
@@ -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?
)
@@ -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())
}
@@ -0,0 +1,12 @@
package space.nstart.pcp_tgu_service.entity
/**
* Жизненный цикл job, которая запускает короткий BPMN updateAcceptedPlanSlots.
*/
enum class AcceptedPlanSlotsUpdateJobStatus {
PENDING,
RUNNING,
DONE,
FAILED,
AMBIGUOUS
}
@@ -0,0 +1,11 @@
package space.nstart.pcp_tgu_service.entity
/**
* Жизненный цикл фоновой job, которая безопасно claim'ится несколькими pod через БД.
*/
enum class JobStatus {
PENDING,
RUNNING,
DONE,
FAILED
}
@@ -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)
}
@@ -0,0 +1,13 @@
package space.nstart.pcp_tgu_service.entity
/**
* Жизненный цикл одной попытки выдачи persisted plan через BPMN.
*/
enum class PlanIssueAttemptStatus {
STARTING,
STARTED,
ACCEPTED,
REJECTED,
FAILED,
AMBIGUOUS
}
@@ -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 = "")
}
@@ -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 = ""
)
}
@@ -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
}
@@ -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())
}
@@ -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()
) {
@@ -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)
}
@@ -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)
}
@@ -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 = "")
}
@@ -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,
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
)
}
}
@@ -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>
}
@@ -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?
}
@@ -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>
}
@@ -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
}
}
}
@@ -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>
}
@@ -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
}
@@ -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?
}
@@ -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()
}
@@ -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
}
@@ -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>
@@ -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>
}
@@ -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())
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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"
}
}
@@ -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)
}
}
@@ -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
}
@@ -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
)
}
@@ -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()
}
}
}
@@ -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,
@@ -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)
}
@@ -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
}
}
@@ -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") }
}
@@ -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())
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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"
}
@@ -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
}
}
@@ -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)
}
}
@@ -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"
}
@@ -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
)
}
@@ -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)
}
}
}
@@ -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
)
}
@@ -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())
}
}
@@ -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
@@ -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;
@@ -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
);
@@ -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;
@@ -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
)
}
@@ -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
}
}
@@ -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
}
}
@@ -208,7 +208,9 @@ class PlanCalculationServiceTest {
externalPointsLookaheadDays = 3,
inMemoryHistoryDays = 14,
externalUpdateFixedDelayMs = 0,
notificationCheckFixedDelayMs = 30_000
notificationCheckFixedDelayMs = 30_000,
minReissueLeadMinutes = 5,
workerLockTtlSeconds = 300
)
)
@@ -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)
}
}
@@ -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
}
}
@@ -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
)
}
@@ -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()
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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 })
}
}
@@ -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
}
}