Init
This commit is contained in:
+18
@@ -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)
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
)
|
||||
+13
@@ -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
|
||||
)
|
||||
+34
@@ -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
|
||||
)
|
||||
+15
@@ -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
|
||||
+9
@@ -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
|
||||
)
|
||||
+46
@@ -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())
|
||||
}
|
||||
+44
@@ -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())
|
||||
}
|
||||
+40
@@ -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())
|
||||
}
|
||||
+38
@@ -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())
|
||||
}
|
||||
+35
@@ -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())
|
||||
}
|
||||
+19
@@ -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>
|
||||
)
|
||||
+25
@@ -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
|
||||
)
|
||||
+19
@@ -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>
|
||||
)
|
||||
+12
@@ -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
|
||||
)
|
||||
+27
@@ -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?
|
||||
)
|
||||
+10
@@ -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
|
||||
)
|
||||
+47
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+250
@@ -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()
|
||||
}
|
||||
}
|
||||
+12
@@ -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
|
||||
)
|
||||
+53
@@ -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
|
||||
)
|
||||
+33
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -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 = "")
|
||||
}
|
||||
+63
@@ -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 = ""
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/** Описывает способ создания отслеживаемого плана. */
|
||||
enum class TrackedPlanSource {
|
||||
AUTO_CHAIN,
|
||||
MANUAL
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package space.nstart.pcp_tgu_service.entity
|
||||
|
||||
/** Описывает состояние жизненного цикла отслеживаемого плана. */
|
||||
enum class TrackedPlanStatus {
|
||||
NEW,
|
||||
WAITING_CONFIRMATION,
|
||||
CONFIRMED,
|
||||
SUPERSEDED
|
||||
}
|
||||
+171
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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}")
|
||||
}
|
||||
+47
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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>
|
||||
+48
@@ -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()
|
||||
}
|
||||
+46
@@ -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?
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+287
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+112
@@ -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
|
||||
)
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -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
|
||||
}
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
+136
@@ -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"
|
||||
}
|
||||
+179
@@ -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);
|
||||
+5
@@ -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
|
||||
);
|
||||
+234
@@ -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
|
||||
)
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
+99
@@ -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
|
||||
)
|
||||
}
|
||||
+40
@@ -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()
|
||||
}
|
||||
}
|
||||
+309
@@ -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
|
||||
}
|
||||
}
|
||||
+116
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user