This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,18 @@
package space.nstart.pcp_tgu_service
/** Запускает TGU-сервис и разворачивает его BPMN-описание при старте. */
import io.camunda.client.annotation.Deployment
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@Deployment(resources = ["classpath:BPMN/createSatelliteMission.bpmn"])
@ConfigurationPropertiesScan
class TguApplication
/** Точка входа Spring Boot приложения. */
fun main(args: Array<String>) {
runApplication<TguApplication>(*args)
}
@@ -0,0 +1,27 @@
package space.nstart.pcp_tgu_service.config
/** Настраивает Jackson-сериализацию для payload'ов клиента Camunda. */
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.KotlinModule
import io.camunda.client.api.JsonMapper as CamundaJsonMapper
import io.camunda.client.impl.CamundaObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class CamundaJacksonConfig {
/** Создаёт JSON mapper для Camunda с поддержкой Kotlin-классов и Java time типов. */
@Bean("camundaJsonMapper")
fun camundaJsonMapper(): CamundaJsonMapper {
val objectMapper = JsonMapper.builder()
.addModule(KotlinModule.Builder().build())
.addModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()
return CamundaObjectMapper(objectMapper)
}
}
@@ -0,0 +1,13 @@
package space.nstart.pcp_tgu_service.config
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* Настройки внешних классификаторов.
*/
@ConfigurationProperties(prefix = "classifier")
data class ClassifierProperties(
/** URL классификатора космических аппаратов. */
val platformsUrl: String
)
@@ -0,0 +1,13 @@
package space.nstart.pcp_tgu_service.config
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* Настройки внешнего API с окнами видимости КА.
*/
@ConfigurationProperties(prefix = "external-api")
data class ExternalApiProperties(
/** Базовый URL сервиса расчёта видимости КА. */
val baseUrl: String
)
@@ -0,0 +1,34 @@
package space.nstart.pcp_tgu_service.config
import org.springframework.boot.context.properties.ConfigurationProperties
/**
* Настройки планирования и автозапуска процессов по планам.
*/
@ConfigurationProperties(prefix = "planning")
data class PlanningProperties(
/** За сколько минут до начала плана автоматически запускать Camunda-процесс. */
val notificationBeforeStartMinutes: Long,
/** Минимальный разрыв в минутах между стартами соседних планов при расчете цепочки. */
val targetPlanDurationMinutes: Long,
/** Допустимое отклонение от целевой длительности плана в минутах. */
val targetPlanDurationDeltaMinutes: Long,
/** Включает периодическое обновление пунктов из внешнего API. */
val externalUpdateEnabled: Boolean,
/** Горизонт запроса данных по пунктам из внешнего API в днях, начиная от текущего момента. */
val externalPointsLookaheadDays: Long,
/** Сколько дней подтвержденной истории планов держать в in-memory выдаче. */
val inMemoryHistoryDays: Long,
/** Интервал в миллисекундах между запросами во внешнее API. */
val externalUpdateFixedDelayMs: Long,
/** Интервал в миллисекундах между проверками, пора ли автозапускать Camunda-процесс по плану. */
val notificationCheckFixedDelayMs: Long
)
@@ -0,0 +1,15 @@
package space.nstart.pcp_tgu_service.config
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableScheduling
/**
* Конфигурация планировщика задач.
*
* Включает поддержку аннотации @Scheduled.
* Благодаря этому в сервисах можно создавать
* периодические задачи.
*/
@Configuration
@EnableScheduling
class SchedulingConfig
@@ -0,0 +1,9 @@
package space.nstart.pcp_tgu_service.config
/** Привязывает имена Kafka-топиков, используемых сервисом. */
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "topics")
data class TopicsProperties(
val spacecraftPointsUpdate: String
)
@@ -0,0 +1,46 @@
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())
}
@@ -0,0 +1,44 @@
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())
}
@@ -0,0 +1,40 @@
package space.nstart.pcp_tgu_service.controller
/** Содержит методы API для чтения текущих планов. */
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 space.nstart.pcp_tgu_service.dto.PlanResponse
import space.nstart.pcp_tgu_service.service.PlanQueryService
@RestController
@RequestMapping("/api/plans")
class PlanController(
private val planQueryService: PlanQueryService
) {
/** Возвращает планы по всем КА с указанным окном подтверждённой истории. */
@GetMapping
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono<List<PlanResponse>> =
Mono.fromCallable {
planQueryService.getAllPlans(historyDays).map { planView ->
PlanResponse.from(planView.plan, planView.status)
}
}.subscribeOn(Schedulers.boundedElastic())
/** Возвращает цепочку планов для одного КА. */
@GetMapping("/{spacecraftId}")
fun getPlansBySpacecraftId(
@PathVariable spacecraftId: String,
@RequestParam(defaultValue = "7") historyDays: Long
): Mono<List<PlanResponse>> =
Mono.fromCallable {
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map { planView ->
PlanResponse.from(planView.plan, planView.status)
}
}.subscribeOn(Schedulers.boundedElastic())
}
@@ -0,0 +1,38 @@
package space.nstart.pcp_tgu_service.controller
/** Содержит методы API только на чтение для платформ из внешнего классификатора. */
import space.nstart.pcp_tgu_service.dto.ContentItemDTO
import space.nstart.pcp_tgu_service.service.PlatformService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestParam
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-контроллер для просмотра списка космических аппаратов.
*/
@RestController
@RequestMapping("/api/platforms")
@Tag(name = "Platforms", description = "Работа со списком космических аппаратов")
class PlatformController(
private val platformService: PlatformService
) {
/**
* Возвращает список космических аппаратов из внешнего классификатора.
*/
/** Возвращает платформы с необязательной фильтрацией по типу и статусу. */
@GetMapping
@Operation(summary = "Получить список космических аппаратов")
fun getPlatforms(
@RequestParam(required = false) platformTypeCode: String?,
@RequestParam(required = false) status: String?
): Mono<List<ContentItemDTO>> =
Mono.fromCallable {
platformService.loadPlatforms(platformTypeCode, status)
}.subscribeOn(Schedulers.boundedElastic())
}
@@ -0,0 +1,35 @@
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,19 @@
package space.nstart.pcp_tgu_service.domain
/**
* Пункт закладки (точка наблюдения).
*
* Один пункт может иметь несколько окон наблюдения,
* когда космический аппарат проходит над этим пунктом.
*
* @property pointId идентификатор пункта закладки.
* @property observationWindows список окон наблюдения для этого пункта.
*/
data class InsertionPoint(
/** Идентификатор пункта закладки */
val pointId: String,
/** Окна наблюдения для данного пункта */
val observationWindows: List<ObservationWindow>
)
@@ -0,0 +1,25 @@
package space.nstart.pcp_tgu_service.domain
import java.time.LocalDateTime
/**
* Окно наблюдения космического аппарата.
*
* Это период времени, когда аппарат можно наблюдать
* из конкретного пункта закладки.
*
* @property startTime время начала окна наблюдения.
* @property endTime время окончания окна наблюдения.
* @property elevationAngle угол места (параметр наблюдения).
*/
data class ObservationWindow(
/** Время начала окна наблюдения */
val startTime: LocalDateTime,
/** Время окончания окна наблюдения */
val endTime: LocalDateTime,
/** Угол места */
val elevationAngle: Double
)
@@ -0,0 +1,28 @@
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,19 @@
package space.nstart.pcp_tgu_service.domain
/**
* Набор доступных пунктов наблюдения для одного космического аппарата.
*
* Этот объект представляет все входные данные,
* которые сервис получает по конкретному КА.
*
* @property spacecraftId идентификатор космического аппарата.
* @property points список пунктов закладки.
*/
data class SpacecraftPoints(
/** Идентификатор космического аппарата */
val spacecraftId: String,
/** Список доступных пунктов закладки */
val points: List<InsertionPoint>
)
@@ -0,0 +1,12 @@
package space.nstart.pcp_tgu_service.dto
/** Набор переменных, передаваемых в Camunda при запуске процесса спутниковой миссии. */
import java.time.LocalDateTime
data class CreateSatelliteMissionVariables(
val spacecraftId: String,
val satellitePlanId: String,
val planStartTime: LocalDateTime,
val planEndTime: LocalDateTime,
val kppId: String
)
@@ -0,0 +1,27 @@
package space.nstart.pcp_tgu_service.dto
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/**
* DTO одного окна видимости КА из внешнего API.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class ExternalRvaItemDto(
val noradId: Long,
val stationId: Long,
val revolution: Long?,
val onStart: ExternalRvaPointDto,
val onMaximum: ExternalRvaPointDto,
val onStop: ExternalRvaPointDto
)
/**
* DTO временной точки в ответе внешнего API.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class ExternalRvaPointDto(
val time: String,
val elevation: Double,
val azimuth: Double?,
val range: Double?
)
@@ -0,0 +1,10 @@
package space.nstart.pcp_tgu_service.dto
/** REST DTO запроса для подтверждения отслеживаемого плана по идентификатору. */
import jakarta.validation.constraints.NotNull
import java.util.UUID
data class PlanConfirmationRequest(
@field:NotNull
val planId: UUID
)
@@ -0,0 +1,47 @@
package space.nstart.pcp_tgu_service.dto
import space.nstart.pcp_tgu_service.domain.Plan
import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus
import java.time.LocalDateTime
import java.util.UUID
/**
* DTO ответа REST API для плана.
*/
data class PlanResponse(
/** UUID-идентификатор плана */
val planId: UUID,
/** Идентификатор космического аппарата */
val spacecraftId: String,
/** Время начала плана */
val startTime: LocalDateTime,
/** Время окончания плана */
val endTime: LocalDateTime,
/** Идентификатор пункта закладки */
val kppId: String,
/** Статус плана в жизненном цикле выдачи. */
val status: TrackedPlanStatus
) {
companion object {
/**
* Преобразует доменную сущность плана в DTO ответа.
*/
fun from(plan: Plan, status: TrackedPlanStatus): PlanResponse =
PlanResponse(
planId = plan.planId,
spacecraftId = plan.spacecraftId,
startTime = plan.startTime,
endTime = plan.endTime,
kppId = plan.kppId,
status = status
)
}
}
@@ -0,0 +1,250 @@
package space.nstart.pcp_tgu_service.dto
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
/**
* Корневой DTO JSON-ответа.
* Соответствует всему объекту JSON.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class PlatformsResponseDTO(
/**
* UUID справочника.
*/
val uuid: String?,
/**
* API имя сущности.
*/
val apiName: String?,
/**
* GUI имя сущности.
*/
val guiName: String?,
/**
* Статус справочника.
*/
val status: String?,
/**
* Признак иерархии.
*/
val hierarchy: Boolean?,
/**
* Правило валидации.
*/
val validationRule: String?,
/**
* Список описаний полей справочника.
*/
val fields: List<FieldDTO>?,
/**
* Основной список элементов.
*/
val content: List<ContentItemDTO>?,
/**
* Информация о пагинации.
*/
val page: PageDTO?
)
/**
* DTO описания одного поля.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class FieldDTO(
/**
* UUID поля.
*/
val uuid: String?,
/**
* API имя поля.
*/
val apiName: String?,
/**
* GUI имя поля.
*/
val guiName: String?,
/**
* Статус поля.
*/
val status: String?,
/**
* Тип поля.
*/
val type: String?,
/**
* API имя связанного классификатора.
*/
val fkClassifierApiName: String?,
/**
* API имя связанного поля.
*/
val fkFieldApiName: String?
)
/**
* DTO одного элемента массива content.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentItemDTO(
/**
* UUID записи.
*/
val uuid: String?,
/**
* UUID родительского элемента.
*/
val parentItemUuid: String?,
/**
* Статус записи.
*/
val status: String?,
/**
* Дата начала действия.
*/
val initiation: String?,
/**
* Дата окончания действия.
*/
val expiration: String?,
/**
* Вложенный объект данных.
*/
val data: ContentDataDTO?
)
/**
* DTO вложенного объекта data внутри content.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class ContentDataDTO(
/**
* Код типа платформы.
*/
@param:JsonProperty("platform_type_code")
val platformTypeCode: String?,
/**
* NORAD ID.
*/
@param:JsonProperty("norad_id")
val noradId: Long?,
/**
* Уникальный номер изделия (УМН).
*/
val umn: String?,
/**
* Название аппарата.
*/
val name: String?,
/**
* Описание аппарата.
*/
val description: String?,
/**
* Название типа платформы.
*/
@param:JsonProperty("platform_type_name")
val platformTypeName: String?
)
/**
* DTO блока page (пагинация).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class PageDTO(
/**
* Размер страницы.
*/
val size: Int?,
/**
* Номер страницы.
*/
val number: Int?,
/**
* Общее количество элементов.
*/
val totalElements: Long?,
/**
* Общее количество страниц.
*/
val totalPages: Int?
)
/**
* Класс парсера JSON.
*/
class PlatformsJsonParser(
/**
* ObjectMapper Jackson.
* Предоставляется Spring Boot автоматически.
*/
private val objectMapper: ObjectMapper
) {
/**
* Парсит весь JSON в корневой DTO.
*
* @param json JSON строка
* @return PlatformsResponseDTO
*/
fun parse(json: String): PlatformsResponseDTO {
/**
* Десериализация JSON -> Kotlin объект.
*/
return objectMapper.readValue(json, PlatformsResponseDTO::class.java)
}
/**
* Парсит JSON и возвращает только массив content.
*
* @param json JSON строка
* @return список элементов content
*/
fun parseContent(json: String): List<ContentItemDTO> {
/**
* Парсим корневой объект.
*/
val response = objectMapper.readValue(json, PlatformsResponseDTO::class.java)
/**
* Если content отсутствует — возвращаем пустой список.
*/
return response.content.orEmpty()
}
}
@@ -0,0 +1,12 @@
package space.nstart.pcp_tgu_service.dto
/** REST DTO, возвращаемый после запуска нового экземпляра процесса Camunda. */
import java.util.UUID
data class ProcessStartResponse(
val processInstanceKey: Long,
val processDefinitionKey: Long,
val bpmnProcessId: String,
val version: Int,
val planId: UUID
)
@@ -0,0 +1,53 @@
package space.nstart.pcp_tgu_service.dto
import java.time.LocalDateTime
/**
* DTO сообщения из Kafka с обновлением данных
* по одному космическому аппарату.
*
* @property spacecraftId идентификатор космического аппарата.
* @property points список пунктов наблюдения.
*/
data class SpacecraftPointsMessage(
/** Идентификатор космического аппарата */
val spacecraftId: String,
/** Список пунктов наблюдения */
val points: List<PointMessage>
)
/**
* DTO пункта закладки.
*
* @property pointId идентификатор пункта.
* @property observationWindows окна наблюдения.
*/
data class PointMessage(
/** Идентификатор пункта */
val pointId: String,
/** Окна наблюдения */
val observationWindows: List<ObservationWindowMessage>
)
/**
* DTO окна наблюдения.
*
* @property startTime время начала окна.
* @property endTime время окончания окна.
* @property elevationAngle угол места.
*/
data class ObservationWindowMessage(
/** Время начала окна наблюдения */
val startTime: LocalDateTime,
/** Время окончания окна наблюдения */
val endTime: LocalDateTime,
/** Угол места */
val elevationAngle: Double
)
@@ -0,0 +1,33 @@
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,26 @@
package space.nstart.pcp_tgu_service.entity
/** JPA-сущность, хранящая текущий подтверждённый базовый план для КА. */
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "spacecraft_planning_state")
class SpacecraftPlanningStateEntity(
@Id
@Column(name = "spacecraft_id", nullable = false, updatable = false)
var spacecraftId: String,
@Column(name = "current_base_plan_id")
var currentBasePlanId: UUID? = null,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now()
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(spacecraftId = "")
}
@@ -0,0 +1,63 @@
package space.nstart.pcp_tgu_service.entity
/** JPA-сущность, хранящая план вместе с его процессным и подтверждающим жизненным циклом. */
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
@Entity
@Table(name = "tracked_plan")
class TrackedPlanEntity(
@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,
@Column(name = "process_instance_key")
var processInstanceKey: Long? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "confirmed_at")
var confirmedAt: LocalDateTime? = null,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
var status: TrackedPlanStatus = TrackedPlanStatus.WAITING_CONFIRMATION,
@Enumerated(EnumType.STRING)
@Column(name = "plan_source", nullable = false)
var planSource: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN,
@Column(name = "base_plan_id")
var basePlanId: UUID? = null,
@Column(name = "superseded_by_plan_id")
var supersededByPlanId: UUID? = null
) {
/** Создаёт пустой экземпляр сущности, необходимый для JPA. */
constructor() : this(
planId = UUID.randomUUID(),
spacecraftId = "",
startTime = LocalDateTime.MIN,
endTime = LocalDateTime.MIN,
kppId = ""
)
}
@@ -0,0 +1,7 @@
package space.nstart.pcp_tgu_service.entity
/** Описывает способ создания отслеживаемого плана. */
enum class TrackedPlanSource {
AUTO_CHAIN,
MANUAL
}
@@ -0,0 +1,9 @@
package space.nstart.pcp_tgu_service.entity
/** Описывает состояние жизненного цикла отслеживаемого плана. */
enum class TrackedPlanStatus {
NEW,
WAITING_CONFIRMATION,
CONFIRMED,
SUPERSEDED
}
@@ -0,0 +1,171 @@
package space.nstart.pcp_tgu_service.integration.api
/** Вызывает внешнее API видимости и преобразует его ответ в доменные модели. */
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.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ContentItemDTO
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
/**
* Клиент внешнего API с окнами видимости КА.
*/
@Component
class ExternalPointsClient(
externalApiProperties: ExternalApiProperties,
private val platformService: PlatformService,
private val planningProperties: PlanningProperties
) {
private val log = LoggerFactory.getLogger(javaClass)
private val zoneId: ZoneId = ZoneId.systemDefault()
private val webClient: WebClient = WebClient.builder()
.baseUrl(externalApiProperties.baseUrl)
.build()
/**
* Загружает окна видимости по всем КА из классификатора,
* у которых заполнен NORAD ID.
*/
/** Загружает окна видимости для всех КА с заполненным NORAD-идентификатором в классификаторе. */
fun fetchAllSpacecraftPoints(): List<SpacecraftPoints> {
val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays)
return platformService.loadPlatforms()
.mapNotNull { platform ->
val noradId = platform.data?.noradId ?: return@mapNotNull null
fetchSpacecraftPointsSafely(
platform = platform,
satelliteId = noradId,
timeStart = timeStart,
timeStop = timeStop
)
}
}
/** Оборачивает загрузку по одному КА и подавляет транспортные ошибки только для него. */
private fun fetchSpacecraftPointsSafely(
platform: ContentItemDTO,
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): SpacecraftPoints? =
try {
fetchSpacecraftPoints(
satelliteId = satelliteId,
timeStart = timeStart,
timeStop = timeStop
)
} catch (ex: WebClientException) {
logExternalApiError(platform, satelliteId, timeStart, timeStop, ex)
null
}
/**
* Загружает окна видимости по одному КА и интервалу времени.
*/
/** Загружает окна видимости для одного КА в указанном временном интервале. */
fun fetchSpacecraftPoints(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): SpacecraftPoints {
val response = webClient.get()
.uri { uriBuilder ->
uriBuilder
.path("/api/satellites/{satelliteId}/rva")
.queryParam("time_start", timeStart)
.queryParam("time_stop", timeStop)
.build(satelliteId)
}
.retrieve()
.bodyToMono(Array<ExternalRvaItemDto>::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)
}
private fun logExternalApiError(
platform: ContentItemDTO,
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime,
ex: WebClientException
) {
val platformName = platform.data?.name
if (ex is WebClientResponseException) {
log.warn(
"Skipping spacecraft due to external API response error. name={}, noradId={}, timeStart={}, timeStop={}, status={}, responseBody={}",
platformName,
satelliteId,
timeStart,
timeStop,
ex.statusCode.value(),
ex.responseBodyAsString
)
return
}
log.warn(
"Skipping spacecraft due to external API transport error. name={}, noradId={}, timeStart={}, timeStop={}, message={}",
platformName,
satelliteId,
timeStart,
timeStop,
ex.message
)
}
}
@@ -0,0 +1,31 @@
package space.nstart.pcp_tgu_service.integration.api
/** Вызывает сервис классификатора и возвращает сырые метаданные платформ. */
import space.nstart.pcp_tgu_service.config.ClassifierProperties
import space.nstart.pcp_tgu_service.dto.PlatformsResponseDTO
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
/**
* HTTP-клиент для получения списка космических аппаратов из классификатора.
*/
@Component
class PlatformsClassifierClient(
private val classifierProperties: ClassifierProperties
) {
private val webClient: WebClient = WebClient.builder()
.baseUrl(classifierProperties.platformsUrl)
.build()
/**
* Загружает полный ответ классификатора платформ.
*/
/** Загружает полный ответ классификатора вместе с содержимым платформ. */
fun fetchPlatforms(): PlatformsResponseDTO =
webClient.get()
.retrieve()
.bodyToMono(PlatformsResponseDTO::class.java)
.block()
?: throw IllegalStateException("Classifier response body is empty: ${classifierProperties.platformsUrl}")
}
@@ -0,0 +1,47 @@
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,77 @@
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,7 @@
package space.nstart.pcp_tgu_service.repository
/** Предоставляет доступ к БД для сохранённого состояния планирования КА. */
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
import org.springframework.data.jpa.repository.JpaRepository
interface SpacecraftPlanningStateJpaRepository : JpaRepository<SpacecraftPlanningStateEntity, String>
@@ -0,0 +1,48 @@
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,46 @@
package space.nstart.pcp_tgu_service.repository
/** Предоставляет методы доступа к БД для отслеживаемых планов и их состояний. */
import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity
import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus
import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalDateTime
import java.util.UUID
interface TrackedPlanJpaRepository : JpaRepository<TrackedPlanEntity, UUID> {
/** Возвращает отслеживаемые планы КА в указанном статусе. */
fun findAllBySpacecraftIdAndStatus(spacecraftId: String, status: TrackedPlanStatus): List<TrackedPlanEntity>
/** Возвращает все tracked-планы КА, упорядоченные по времени старта. */
fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List<TrackedPlanEntity>
/** Возвращает планы, подтверждённые с указанного момента, упорядоченные по времени старта. */
fun findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime(
spacecraftId: String,
status: TrackedPlanStatus,
confirmedAt: LocalDateTime
): List<TrackedPlanEntity>
/** Возвращает отслеживаемый план по его бизнес-полям и статусу. */
fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus(
spacecraftId: String,
startTime: LocalDateTime,
endTime: LocalDateTime,
kppId: String,
status: TrackedPlanStatus
): TrackedPlanEntity?
/** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */
fun findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc(
spacecraftId: String,
startTime: LocalDateTime,
endTime: LocalDateTime,
kppId: String
): TrackedPlanEntity?
/** Возвращает последний подтверждённый отслеживаемый план для КА. */
fun findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc(
spacecraftId: String,
status: TrackedPlanStatus
): TrackedPlanEntity?
}
@@ -0,0 +1,76 @@
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,287 @@
package space.nstart.pcp_tgu_service.service
/** Рассчитывает цепочки планов по окнам видимости КА с учётом целевой длительности и дельты. */
import org.springframework.stereotype.Service
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
@Service
class PlanCalculationService(
private val planningProperties: PlanningProperties
) {
/** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */
private val overlapShiftByWindows: Int = 1
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<Plan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
return calculatePlansFromCandidates(
spacecraftId = spacecraftPoints.spacecraftId,
candidates = candidates,
initialStartIndex = 0
)
}
/**
* Строит часть цепочки после уже известного плана.
*
* Если предыдущий план удалось сопоставить с текущими окнами наблюдения,
* следующий план начинается с нахлестом на одну ЗРВ.
* Если сопоставление не удалось, используется безопасный fallback без нахлеста.
*/
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: Plan): List<Plan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
/** Индекс кандидата, с которого начинался предыдущий план. */
val previousStartIndex: Int = candidates.indexOfFirst { candidate ->
candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime
}
/** Индекс кандидата, по которому заканчивался предыдущий план. */
val previousEndIndex: Int = candidates.withIndex()
.firstOrNull { indexedCandidate ->
indexedCandidate.index > previousStartIndex &&
indexedCandidate.value.endTime == previousPlan.endTime
}
?.index
?: -1
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */
val initialStartIndex: Int = if (
previousStartIndex >= 0 &&
previousEndIndex > previousStartIndex
) {
resolveNextStartIndex(
currentStartIndex = previousStartIndex,
endIndex = previousEndIndex
)
} else {
candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime }
}
if (initialStartIndex == -1) {
return emptyList()
}
return calculatePlansFromCandidates(
spacecraftId = spacecraftPoints.spacecraftId,
candidates = candidates,
initialStartIndex = initialStartIndex
)
}
/**
* Возвращает план, который начинается строго с ближайшей закладки не раньше `fromTime`.
*
* Сервис не перескакивает на более позднюю закладку.
* Если ближайшая не попадает в целевое окно, всё равно выбирается
* лучший доступный конец, наиболее близкий к целевой длительности.
*/
fun calculateNearestPlanFrom(spacecraftPoints: SpacecraftPoints, fromTime: LocalDateTime): Plan? {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
/** Индекс ближайшей закладки, которая начинается не раньше указанного времени. */
val nearestStartIndex: Int = candidates.indexOfFirst { candidate -> candidate.startTime >= fromTime }
if (nearestStartIndex == -1 || nearestStartIndex >= candidates.lastIndex) {
return null
}
/** Индекс лучшего кандидата окончания для ближайшей закладки. */
val endIndex: Int = findEndCandidateIndexOrNull(
candidates = candidates,
startIndex = nearestStartIndex
) ?: return null
return candidates[nearestStartIndex].toPlan(
spacecraftId = spacecraftPoints.spacecraftId,
endTime = candidates[endIndex].endTime
)
}
/** Преобразует отсортированных кандидатов в последовательную цепочку планов. */
private fun calculatePlansFromCandidates(
spacecraftId: String,
candidates: List<StartCandidate>,
initialStartIndex: Int
): List<Plan> {
if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) {
return emptyList()
}
/** Результирующая цепочка планов. */
val resultPlans: MutableList<Plan> = mutableListOf()
/** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */
var currentStartIndex: Int = initialStartIndex
while (currentStartIndex < candidates.lastIndex) {
/** Текущий кандидат старта плана. */
val currentCandidate: StartCandidate = candidates[currentStartIndex]
/** Индекс найденного кандидата окончания плана. */
val endIndex: Int = findEndCandidateIndexOrNull(
candidates = candidates,
startIndex = currentStartIndex
) ?: break
resultPlans += currentCandidate.toPlan(
spacecraftId = spacecraftId,
endTime = candidates[endIndex].endTime
)
/** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */
val nextStartIndex: Int = resolveNextStartIndex(
currentStartIndex = currentStartIndex,
endIndex = endIndex
)
if (nextStartIndex <= currentStartIndex) {
break
}
currentStartIndex = nextStartIndex
}
return resultPlans
}
/** Разворачивает все окна видимости в отсортированный список кандидатов начала и конца плана. */
private fun buildCandidates(spacecraftPoints: SpacecraftPoints): List<StartCandidate> =
spacecraftPoints.points
.flatMap { point: InsertionPoint ->
point.observationWindows.map { observationWindow ->
StartCandidate(
pointId = point.pointId,
startTime = observationWindow.startTime,
endTime = observationWindow.endTime
)
}
}
.sortedBy { candidate -> candidate.startTime }
/** Возвращает целевую длительность плана из настроек. */
private fun targetDuration(): Duration =
Duration.ofMinutes(planningProperties.targetPlanDurationMinutes)
/** Возвращает начальный допуск вокруг целевой длительности плана. */
private fun initialDelta(): Duration =
Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)
/**
* Возвращает безопасный индекс старта следующего плана.
*
* Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего.
* Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат,
* чтобы не зациклиться и не повторить тот же самый план.
*/
private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int =
maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows)
/**
* Находит лучший конечный кандидат для плана, начинающегося с `startIndex`.
*
* Сначала сервис старается попасть в целевое окно `target ± delta`.
* Если это невозможно, он всё равно обязан построить план и берёт
* любой доступный конец, наиболее близкий к целевой длительности.
*/
private fun findEndCandidateIndexOrNull(
candidates: List<StartCandidate>,
startIndex: Int
): Int? {
/** Кандидат, с которого начинается рассматриваемый план. */
val startCandidate: StartCandidate = candidates[startIndex]
/** Все доступные кандидаты окончания после выбранного старта. */
val allEndCandidates: List<IndexedValue<StartCandidate>> = candidates.withIndex()
.filter { indexedCandidate -> indexedCandidate.index > startIndex }
if (allEndCandidates.isEmpty()) {
return null
}
/** Допуск относительно целевой длительности плана. */
val currentDelta: Duration = initialDelta()
/** Все кандидаты окончания, попавшие в настроенное окно допуска. */
val matchingCandidates: List<IndexedValue<StartCandidate>> = allEndCandidates
.filter { indexedCandidate ->
isWithinTargetWindow(
startTime = startCandidate.startTime,
candidateTime = indexedCandidate.value.endTime,
delta = currentDelta
)
}
return selectPreferredEndCandidate(
candidates = if (matchingCandidates.isNotEmpty()) matchingCandidates else allEndCandidates,
startTime = startCandidate.startTime
)?.index
}
/** Проверяет, попадает ли длительность до конца кандидата в текущее окно допуска. */
private fun isWithinTargetWindow(
startTime: LocalDateTime,
candidateTime: LocalDateTime,
delta: Duration
): Boolean {
/** Фактическая длительность плана от текущего старта до рассматриваемого конца. */
val duration: Duration = Duration.between(startTime, candidateTime)
/** Минимально допустимая длительность плана. */
val minDuration: Duration = targetDuration().minus(delta)
/** Максимально допустимая длительность плана. */
val maxDuration: Duration = targetDuration().plus(delta)
return duration >= minDuration && duration <= maxDuration
}
/**
* Выбирает лучший конец по минимальному отклонению от целевой длительности.
*
* При равной близости предпочтение отдаётся более длинному плану,
* чтобы не занижать результат без необходимости.
*/
private fun selectPreferredEndCandidate(
candidates: List<IndexedValue<StartCandidate>>,
startTime: LocalDateTime
): IndexedValue<StartCandidate>? {
/** Целевая длительность плана из настроек. */
val targetDuration: Duration = targetDuration()
return candidates.minWithOrNull(
compareBy<IndexedValue<StartCandidate>> { candidate ->
durationDistance(
first = Duration.between(startTime, candidate.value.endTime),
second = targetDuration
)
}.thenByDescending { candidate ->
Duration.between(startTime, candidate.value.endTime)
}
)
}
/** Возвращает модуль разницы между двумя длительностями. */
private fun durationDistance(first: Duration, second: Duration): Duration =
first.minus(second).abs()
private data class StartCandidate(
/** Идентификатор пункта закладки, которому принадлежит окно. */
val pointId: String,
/** Время начала окна наблюдения. */
val startTime: LocalDateTime,
/** Время окончания окна наблюдения. */
val endTime: LocalDateTime
) {
/** Преобразует кандидата в доменный план с заданным временем окончания. */
fun toPlan(spacecraftId: String, endTime: LocalDateTime): Plan =
Plan(
spacecraftId = spacecraftId,
startTime = startTime,
endTime = endTime,
kppId = pointId
)
}
}
@@ -0,0 +1,16 @@
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)
}
@@ -0,0 +1,58 @@
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
}
}
@@ -0,0 +1,112 @@
package space.nstart.pcp_tgu_service.service
/** Формирует представление планов для API, объединяя историю из tracked-хранилища и хвост из памяти. */
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.entity.TrackedPlanEntity
import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus
import space.nstart.pcp_tgu_service.repository.PlanRepository
import java.time.LocalDateTime
import java.util.UUID
@Service
class PlanQueryService(
private val planRepository: PlanRepository,
private val trackedPlanService: TrackedPlanService
) {
/** Возвращает планы по всем КА, известным репозиторию планов в памяти. */
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 }
}
/** Возвращает ближайший будущий план для указанного КА или выбрасывает исключение, если его нет. */
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
}
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
)
}
@@ -0,0 +1,32 @@
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,29 @@
package space.nstart.pcp_tgu_service.service
/** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */
import space.nstart.pcp_tgu_service.dto.ContentItemDTO
import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient
import org.springframework.stereotype.Service
@Service
class PlatformService(
private val platformsClassifierClient: PlatformsClassifierClient
) {
/** Возвращает платформы из классификатора с необязательной фильтрацией по коду типа и статусу. */
fun loadPlatforms(
platformTypeCode: String? = null,
status: String? = null
): List<ContentItemDTO> {
val platforms = platformsClassifierClient.fetchPlatforms().content.orEmpty()
return platforms.filter { platform ->
val matchesPlatformTypeCode = platformTypeCode.isNullOrBlank() ||
platform.data?.platformTypeCode == platformTypeCode
val matchesStatus = status.isNullOrBlank() ||
platform.status == status
matchesPlatformTypeCode && matchesStatus
}
}
}
@@ -0,0 +1,174 @@
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.loadPlatforms()
.any { platform -> platform.data?.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
}
}
@@ -0,0 +1,32 @@
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)
}
}
@@ -0,0 +1,136 @@
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,179 @@
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,125 @@
<?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:startEvent>
<bpmn:serviceTask id="updatePlanStatus" name="Обновление статуса плана">
<bpmn:extensionElements>
<zeebe:taskDefinition type="updatePlanStatus" />
</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: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:serviceTask>
<bpmn:serviceTask id="calculateSatelliteSurveyMissions" name="Сформировать план съемки">
<bpmn:extensionElements>
<zeebe:taskDefinition type="calculateSatelliteSurveyMissions" retries="3" />
</bpmn:extensionElements>
<bpmn:incoming>Flow_105c46r</bpmn:incoming>
<bpmn:outgoing>Flow_0i87yhe</bpmn:outgoing>
</bpmn:serviceTask>
</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_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" />
</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">
<di:waypoint x="218" y="120" />
<di:waypoint x="270" y="120" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_141jv88_di" bpmnElement="Flow_141jv88">
<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>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-tgu-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:38888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS tracked_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,
process_instance_key BIGINT,
created_at TIMESTAMP NOT NULL,
confirmed_at TIMESTAMP,
status VARCHAR(64) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tracked_plan_spacecraft_id ON tracked_plan(spacecraft_id);
CREATE INDEX IF NOT EXISTS idx_tracked_plan_status ON tracked_plan(status);
@@ -0,0 +1,7 @@
ALTER TABLE tracked_plan
ADD COLUMN IF NOT EXISTS plan_source VARCHAR(64) NOT NULL DEFAULT 'AUTO_CHAIN',
ADD COLUMN IF NOT EXISTS base_plan_id UUID,
ADD COLUMN IF NOT EXISTS superseded_by_plan_id UUID;
CREATE INDEX IF NOT EXISTS idx_tracked_plan_base_plan_id ON tracked_plan(base_plan_id);
CREATE INDEX IF NOT EXISTS idx_tracked_plan_superseded_by_plan_id ON tracked_plan(superseded_by_plan_id);
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS spacecraft_planning_state (
spacecraft_id VARCHAR(255) PRIMARY KEY,
current_base_plan_id UUID,
updated_at TIMESTAMP NOT NULL
);
@@ -0,0 +1,234 @@
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 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.SpacecraftPoints
import java.time.LocalDateTime
class PlanCalculationServiceTest {
@Test
fun `calculatePlans keeps neighbouring plans overlapped by one window when possible`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plans = service.calculatePlans(
spacecraftPoints(
window("KPP-1", start),
window("KPP-2", start.plusHours(8)),
window("KPP-3", start.plusHours(16)),
window("KPP-4", start.plusHours(23).plusMinutes(55), 15),
window("KPP-5", start.plusHours(24).plusMinutes(10)),
window("KPP-6", start.plusHours(32)),
window("KPP-7", start.plusHours(40)),
window("KPP-8", start.plusHours(47).plusMinutes(55), 15)
)
)
assertEquals(4, plans.size)
assertEquals(start, plans[0].startTime)
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
assertEquals(start.plusHours(16), plans[1].startTime)
assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plans[1].endTime)
assertEquals("KPP-3", plans[1].kppId)
assertEquals(start.plusHours(32), plans[2].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime)
assertEquals(start.plusHours(40), plans[3].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime)
}
@Test
fun `calculatePlansAfter keeps one window overlap with previous plan when it can be matched`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val spacecraftPoints = spacecraftPoints(
window("KPP-1", start),
window("KPP-2", start.plusHours(8)),
window("KPP-3", start.plusHours(16)),
window("KPP-4", start.plusHours(23).plusMinutes(55), 15),
window("KPP-5", start.plusHours(24).plusMinutes(10)),
window("KPP-6", start.plusHours(32)),
window("KPP-7", start.plusHours(40)),
window("KPP-8", start.plusHours(47).plusMinutes(55), 15)
)
val fullPlans = service.calculatePlans(spacecraftPoints)
val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first())
assertEquals(3, plansAfterFirst.size)
assertEquals(start.plusHours(16), plansAfterFirst[0].startTime)
assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plansAfterFirst[0].endTime)
assertEquals("KPP-3", plansAfterFirst[0].kppId)
assertEquals(start.plusHours(32), plansAfterFirst[1].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime)
assertEquals(start.plusHours(40), plansAfterFirst[2].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[2].endTime)
}
@Test
fun `calculatePlans returns empty list when only one window is available`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 60L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plans = service.calculatePlans(
spacecraftPoints(
window("KPP-1", start)
)
)
assertTrue(plans.isEmpty())
}
@Test
fun `calculatePlans falls back to the closest end point when tail is outside delta`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plans = service.calculatePlans(
spacecraftPoints(
window("KPP-1", start),
window("KPP-2", start.plusHours(23).plusMinutes(55), 15),
window("KPP-3", start.plusHours(30))
)
)
assertEquals(2, plans.size)
assertEquals(start, plans[0].startTime)
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
assertEquals(start.plusHours(23).plusMinutes(55), plans[1].startTime)
assertEquals(start.plusHours(30).plusMinutes(10), plans[1].endTime)
}
@Test
fun `calculateNearestPlanFrom uses nearest start and returns closest available end even outside delta`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plan = service.calculateNearestPlanFrom(
spacecraftPoints(
window("KPP-NEAREST", start),
window("KPP-SHORT-END", start.plusHours(2)),
window("KPP-LONG-END", start.plusDays(2))
),
fromTime = start
)
assertEquals(start, plan!!.startTime)
assertEquals(start.plusHours(2).plusMinutes(10), plan.endTime)
assertEquals("KPP-NEAREST", plan.kppId)
}
@Test
fun `calculatePlans moves forward when overlap is impossible`() {
val service = createService(targetMinutes = 60L, deltaMinutes = 10L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plans = service.calculatePlans(
spacecraftPoints(
window("KPP-1", start),
window("KPP-2", start.plusMinutes(55), 15),
window("KPP-3", start.plusHours(1).plusMinutes(30), 35)
)
)
assertEquals(2, plans.size)
assertEquals(start, plans[0].startTime)
assertEquals(start.plusMinutes(55), plans[1].startTime)
}
@Test
fun `calculatePlans picks duration closest to target when windows are separated by large gaps`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plans = service.calculatePlans(
spacecraftPoints(
window("KPP-1", start),
window("KPP-2", start.plusHours(1).plusMinutes(30)),
window("KPP-3", start.plusDays(2))
)
)
assertEquals(2, plans.size)
assertEquals(start, plans[0].startTime)
assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime)
assertEquals(start.plusHours(1).plusMinutes(30), plans[1].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plans[1].endTime)
}
@Test
fun `calculatePlans chooses plan closest to target even when only short and long options exist`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val plans = service.calculatePlans(
spacecraftPoints(
window("KPP-1", start),
window("KPP-2", start.plusHours(1).plusMinutes(30)),
window("KPP-3", start.plusDays(2))
)
)
assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime)
}
@Test
fun `calculatePlans handles ten simultaneous windows without looping`() {
val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
val simultaneousWindows = buildList {
for (index in 1..10) {
add(window("SIM-$index", start))
}
for (index in 1..10) {
add(window("END-$index", start.plusHours(23).plusMinutes(55), 15))
}
}
val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray()))
assertTrue(plans.isNotEmpty())
assertTrue(plans.size <= simultaneousWindows.size)
assertEquals(start, plans.first().startTime)
assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime)
}
private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService =
PlanCalculationService(
PlanningProperties(
notificationBeforeStartMinutes = 30,
targetPlanDurationMinutes = targetMinutes,
targetPlanDurationDeltaMinutes = deltaMinutes,
externalUpdateEnabled = false,
externalPointsLookaheadDays = 3,
inMemoryHistoryDays = 14,
externalUpdateFixedDelayMs = 0,
notificationCheckFixedDelayMs = 30_000
)
)
private fun spacecraftPoints(vararg windows: Pair<String, ObservationWindow>): SpacecraftPoints =
SpacecraftPoints(
spacecraftId = "25544",
points = windows
.groupBy({ it.first }, { it.second })
.map { (pointId, observationWindows) ->
InsertionPoint(
pointId = pointId,
observationWindows = observationWindows
)
}
)
private fun window(pointId: String, startTime: LocalDateTime, durationMinutes: Long = 10): Pair<String, ObservationWindow> =
pointId to ObservationWindow(
startTime = startTime,
endTime = startTime.plusMinutes(durationMinutes),
elevationAngle = 10.0
)
}
@@ -0,0 +1,35 @@
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,99 @@
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
)
}
@@ -0,0 +1,40 @@
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,309 @@
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.PlanningProperties
import space.nstart.pcp_tgu_service.domain.Plan
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import space.nstart.pcp_tgu_service.dto.ContentDataDTO
import space.nstart.pcp_tgu_service.dto.ContentItemDTO
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.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)
}
private class FixedPlatformService(
private val platforms: List<ContentItemDTO>
) : PlatformService(mock(PlatformsClassifierClient::class.java)) {
override fun loadPlatforms(platformTypeCode: String?, status: String?): List<ContentItemDTO> = 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): ContentItemDTO =
ContentItemDTO(
uuid = UUID.randomUUID().toString(),
parentItemUuid = null,
status = "ACTIVE",
initiation = null,
expiration = null,
data = ContentDataDTO(
platformTypeCode = null,
noradId = noradId,
umn = null,
name = "satellite",
description = null,
platformTypeName = null
)
)
@Suppress("UNCHECKED_CAST")
private fun <T> anyNonNull(): T {
ArgumentMatchers.any<T>()
return null as T
}
}
@@ -0,0 +1,116 @@
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 })
}
}