This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
@@ -0,0 +1,8 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,82 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
kotlin("plugin.lombok")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
version = "1.0.0"
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation(project(":libs:ballistics-lib"))
implementation("${property("dep.spring.actuator")}")
implementation("org.springframework.boot:spring-boot-starter-logging")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("jakarta.validation:jakarta.validation-api")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:${property("versions.open-api")}")
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.springframework.kafka:spring-kafka")
implementation("io.camunda:camunda-spring-boot-4-starter:8.8.10")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.locationtech.jts:jts-core:1.19.0")
testImplementation("junit:junit")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.testcontainers:junit-jupiter")
testRuntimeOnly("com.h2database:h2")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
runtimeOnly("org.postgresql:postgresql")
implementation("jakarta.validation:jakarta.validation-api")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
mavenBom("org.testcontainers:testcontainers-bom:${property("versions.testcontainers")}")
}
}
tasks.withType<Jar> {
manifest {
attributes["Built-By"] = "nstart"
attributes["Implementation-Version"] = archiveVersion
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_mission_planing_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.kafka.annotation.EnableKafka
@EnableKafka
@SpringBootApplication
class PcpMissionPlaningServiceApplication
fun main(args: Array<String>) {
runApplication<PcpMissionPlaningServiceApplication>(*args)
}
@@ -0,0 +1,88 @@
package space.nstart.pcp.pcp_mission_planing_service.configuration
import org.apache.kafka.clients.admin.AdminClientConfig
import org.apache.kafka.clients.admin.NewTopic
import org.apache.kafka.clients.consumer.ConsumerConfig
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.serialization.StringDeserializer
import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory
import org.springframework.kafka.core.ConsumerFactory
import org.springframework.kafka.core.DefaultKafkaConsumerFactory
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaAdmin
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Configuration
class KafkaConfig {
@Value("\${app.kafka.topics.booked-slots}")
private lateinit var bookedSlotsTopic: String
@Value("\${app.kafka.topics.mode-status-changed:pcp.route.in.v1}")
private lateinit var modeStatusChangedTopic: String
@Value("\${spring.kafka.bootstrap-servers}")
private lateinit var bootstrapServers: String
@Value("\${spring.kafka.consumer.group-id}")
private lateinit var groupId: String
@Value("\${spring.kafka.consumer.auto-offset-reset:latest}")
private lateinit var autoOffsetReset: String
@Value("\${spring.kafka.consumer.enable-auto-commit:false}")
private var enableAutoCommit: Boolean = false
@Bean
fun bookedSlotsTopic(): NewTopic = NewTopic(bookedSlotsTopic, 1, 1)
@Bean
fun modeStatusChangedTopic(): NewTopic = NewTopic(modeStatusChangedTopic, 1, 1)
@Bean
fun kafkaAdmin(): KafkaAdmin =
KafkaAdmin(
mutableMapOf<String, Any>(
AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers
)
)
@Bean
fun consumerFactory(): ConsumerFactory<String, String> =
DefaultKafkaConsumerFactory(
mutableMapOf<String, Any>(
ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ConsumerConfig.GROUP_ID_CONFIG to groupId,
ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset,
ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to enableAutoCommit,
ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java,
ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java
)
)
@Bean
fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory<String, String> =
ConcurrentKafkaListenerContainerFactory<String, String>().apply {
setConsumerFactory(consumerFactory())
}
@Bean
fun producerFactory(): ProducerFactory<String, Any> =
DefaultKafkaProducerFactory(
mutableMapOf<String, Any>(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java
)
)
@Bean
fun kafkaTemplate(): KafkaTemplate<String, Any> = KafkaTemplate(producerFactory())
}
@@ -0,0 +1,181 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.ArraySchema
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.responses.ApiResponses
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService
import java.time.LocalDateTime
import java.util.UUID
@RestController
@RequestMapping("/api/missions")
@Tag(name = "Missions", description = "Управление миссиями и режимами")
class MissionController(
private val missionPlaningService: MissionPlaningService
) {
@Operation(summary = "Получить список миссий")
@GetMapping
fun all() = missionPlaningService.all()
@Operation(summary = "Получить миссию по id")
@GetMapping("/{mission_id}")
fun byId(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID
) = missionPlaningService.byId(id)
@Operation(summary = "Получить режимы миссии")
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "Список режимов (SURVEY и Drop)",
content = [
Content(
mediaType = "application/json",
array = ArraySchema(schema = Schema(implementation = ModeResponseDTO::class))
)
]
),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@GetMapping("/{mission_id}/modes")
fun modes(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID
) = missionPlaningService.modesByMission(id)
@Operation(summary = "Получить режимы аппарата за интервал времени")
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "Список режимов (SURVEY и DROP) для аппарата в указанном интервале",
content = [
Content(
mediaType = "application/json",
array = ArraySchema(schema = Schema(implementation = ModeResponseDTO::class))
)
]
),
ApiResponse(responseCode = "400", description = "Некорректный интервал времени")
]
)
@GetMapping("/modes/satellite")
fun modesBySatelliteAndInterval(
@Parameter(description = "Идентификатор аппарата")
@RequestParam("satellite_id") satelliteId: Long,
@Parameter(description = "Начало интервала")
@RequestParam timeStart: LocalDateTime,
@Parameter(description = "Конец интервала")
@RequestParam timeStop: LocalDateTime
) = missionPlaningService.modesBySatelliteAndInterval(satelliteId, timeStart, timeStop)
@Operation(summary = "Запустить расчет сбросов для миссии")
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "Расчет выполнен"),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@PostMapping("/{mission_id}/drops/calculate")
fun calculateDrops(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID
): ResponseEntity<Void> {
missionPlaningService.calculateDrops(id)
return ResponseEntity.noContent().build()
}
@Operation(summary = "Запустить расчет плана съемки для миссии")
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "Расчет выполнен",
content = [
Content(
mediaType = "application/json",
schema = Schema(implementation = MissionSurveyCalculationResponseDTO::class)
)
]
),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@PostMapping("/{mission_id}/surveys/calculate")
fun calculateSurveys(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID,
@Parameter(description = "Конкретный snapshot этапа 4")
@RequestParam(name = "comPlanSnapshotId", required = false) comPlanSnapshotId: Long?
): MissionSurveyCalculationResponseDTO =
missionPlaningService.loadSlots(id, comPlanSnapshotId = comPlanSnapshotId)
@Operation(summary = "Подтвердить миссию")
@ApiResponses(
value = [
ApiResponse(responseCode = "204", description = "Миссия подтверждена"),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@PostMapping("/{mission_id}/confirm")
fun confirmMission(
@Parameter(description = "Идентификатор миссии")
@PathVariable("mission_id") id: UUID
): ResponseEntity<Void> {
missionPlaningService.confirmMission(id)
return ResponseEntity.noContent().build()
}
@Operation(summary = "Изменить статус режима съемки")
@PutMapping("/survey-modes/{mode_id}/status")
fun updateSurveyModeStatus(
@PathVariable("mode_id") id: Long,
@Valid @RequestBody body: SurveyModeStatusUpdateRequest
): ResponseEntity<Void> {
missionPlaningService.updateSurveyModeStatus(id, body.status)
return ResponseEntity.noContent().build()
}
@Operation(summary = "Создать миссию")
@PostMapping
fun add(@Valid @RequestBody body: MissionRequestDTO) = missionPlaningService.add(body)
@Operation(summary = "Обновить миссию")
@PutMapping("/{mission_id}")
fun update(@PathVariable("mission_id") id: UUID, @Valid @RequestBody body: MissionRequestDTO) =
missionPlaningService.update(id, body)
@Operation(summary = "Удалить миссию")
@DeleteMapping("/{mission_id}")
fun delete(@PathVariable("mission_id") id: UUID): ResponseEntity<Void> {
missionPlaningService.delete(id)
return ResponseEntity.noContent().build()
}
}
@@ -0,0 +1,9 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import jakarta.validation.constraints.NotNull
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
data class SurveyModeStatusUpdateRequest(
@field:NotNull
val status: SurveyModeStatus = SurveyModeStatus.PLANNED
)
@@ -0,0 +1,23 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.Table
import java.time.OffsetDateTime
@Entity
@Table(name = "drop_modes")
@DiscriminatorValue("DROP")
class DropModeEntity(
id: Long? = null,
plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null,
revolution: Long = 0,
@Column(name = "station")
var station: String? = null,
@Column(name = "duration", nullable = false)
var duration: Double = 0.0
) : ModeEntity(id, plan, timeStart, revolution)
@@ -0,0 +1,26 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
@Entity
@Table(name = "drop_mode_survey")
class DropModeSurveyEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "drop_mode_id", nullable = false)
var dropMode: DropModeEntity? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "survey_mode_id", nullable = false)
var surveyMode: SurveyModeEntity? = null
)
@@ -0,0 +1,31 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "missions")
class MissionEntity(
@Id
@Column(name = "mission_id", nullable = false, updatable = false)
val missionId: UUID = UUID.randomUUID(),
@Column(name = "satellite_id", nullable = false)
var satelliteId: Long = 0,
@Column(name = "station", length = 32)
var station: String? = null,
@Column(name = "mission_start")
var missionStart: LocalDateTime? = null,
@Column(name = "mission_stop")
var missionStop: LocalDateTime? = null,
@Column(name = "status", length = 15)
var status: String = "NEW"
)
@@ -0,0 +1,26 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
@Entity
@Table(name = "mode_booking")
class ModeBookingEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mode_id", nullable = false)
var mode: SurveyModeEntity? = null,
@Column(name = "slot_id", nullable = false)
var slotId: Long = 0
)
@@ -0,0 +1,36 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorColumn
import jakarta.persistence.DiscriminatorType
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Inheritance
import jakarta.persistence.InheritanceType
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import java.time.OffsetDateTime
@Entity
@Table(name = "modes")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.STRING, length = 20)
abstract class ModeEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
open val id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "plan_id", nullable = false)
open var plan: PlanEntity? = null,
@Column(name = "time_start")
open var timeStart: OffsetDateTime? = null,
@Column(name = "revolution", nullable = false)
open var revolution: Long = 0
)
@@ -0,0 +1,39 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import java.time.LocalDateTime
@Entity
@Table(name = "plans")
class PlanEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mission_id", nullable = false)
var mission: MissionEntity? = null,
@Column(name = "plan_start")
var planStart: LocalDateTime? = null,
@Column(name = "plan_stop")
var planStop: LocalDateTime? = null,
@Column(name = "plan_number", nullable = false)
var planNumber: Long = 0,
@Column(name = "revolution", nullable = false)
var revolution: Long = 0,
@Column(name = "stage4_snapshot_id")
var stage4SnapshotId: Long? = null
)
@@ -0,0 +1,49 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import java.time.OffsetDateTime
@Entity
@Table(name = "survey_modes")
@DiscriminatorValue("SURVEY")
class SurveyModeEntity(
id: Long? = null,
plan: PlanEntity? = null,
timeStart: OffsetDateTime? = null,
revolution: Long = 0,
@Column(name = "lat", nullable = false)
var lat: Double = 0.0,
@Column(name = "long", nullable = false)
var long: Double = 0.0,
@Column(name = "duration", nullable = false)
var duration: Double = 0.0,
@Column(name = "contour_wkt", columnDefinition = "TEXT")
var contourWkt: String? = null,
@Column(name = "contour_geometry", insertable = false, updatable = false, columnDefinition = "geometry(Polygon, 4326)")
var contourGeometry: String? = null,
@Column(name = "roll", nullable = false)
var roll: Double = 0.0,
@Column(name = "satellite_mode_id")
var satelliteModeId: Long? = null,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
var status: SurveyModeStatus = SurveyModeStatus.PLANNED,
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 20)
var source: SurveyModeSource = SurveyModeSource.SLOT
) : ModeEntity(id, plan, timeStart, revolution)
@@ -0,0 +1,11 @@
package space.nstart.pcp.pcp_mission_planing_service.entity
enum class SurveyModeSource {
USER,
SLOT,
HEAT_MAP,
SLOTS,
COMPLAN,
MIXED,
MANUAL
}
@@ -0,0 +1,8 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
interface DropModeRepository : JpaRepository<DropModeEntity, Long> {
fun findAllByPlan_IdOrderByTimeStartAsc(planId: Long): List<DropModeEntity>
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import java.util.UUID
interface DropModeSurveyRepository : JpaRepository<DropModeSurveyEntity, Long> {
fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List<DropModeSurveyEntity>
fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List<DropModeSurveyEntity>
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
import java.util.UUID
interface MissionRepository : JpaRepository<MissionEntity, UUID> {
fun countBySatelliteId(satelliteId: Long): Long
fun deleteAllBySatelliteId(satelliteId: Long): Long
}
@@ -0,0 +1,10 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity
import java.util.UUID
interface ModeBookingRepository : JpaRepository<ModeBookingEntity, Long> {
fun findAllByMode_IdIn(modeIds: Collection<Long>): List<ModeBookingEntity>
fun findAllByMode_Plan_Mission_MissionId(missionId: UUID): List<ModeBookingEntity>
}
@@ -0,0 +1,31 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import jakarta.transaction.Transactional
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import java.time.OffsetDateTime
import java.util.UUID
interface ModeRepository : JpaRepository<ModeEntity, Long> {
fun findAllByPlan_IdOrderByTimeStartAsc(planId: Long): List<ModeEntity>
fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List<ModeEntity>
fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId: Long,
timeStop: OffsetDateTime
): List<ModeEntity>
fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long
@Modifying
@Transactional
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true)
fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int
@Modifying
@Transactional
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true)
fun deleteDropModesByPlanId(@Param("planId") planId: Long): Int
}
@@ -0,0 +1,13 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import org.springframework.data.jpa.repository.JpaRepository
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import java.util.Optional
import java.util.UUID
interface PlanRepository : JpaRepository<PlanEntity, Long> {
fun findAllByMissionMissionIdOrderByPlanNumberAsc(missionId: UUID): List<PlanEntity>
fun findFirstByMissionMissionIdOrderByIdAsc(missionId: UUID): Optional<PlanEntity>
fun findFirstByMissionMissionIdAndPlanNumber(missionId: UUID, planNumber: Long): Optional<PlanEntity>
fun countByMissionSatelliteId(satelliteId: Long): Long
}
@@ -0,0 +1,32 @@
package space.nstart.pcp.pcp_mission_planing_service.repository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.repository.query.Param
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import java.time.OffsetDateTime
import java.util.UUID
interface SurveyModeRepository : JpaRepository<SurveyModeEntity, Long> {
fun findAllByPlan_IdOrderByTimeStartAsc(planId: Long): List<SurveyModeEntity>
fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List<SurveyModeEntity>
@Query(
"""
select sm
from SurveyModeEntity sm
join sm.plan p
join p.mission m
where concat('', m.satelliteId) = :satelliteNumber
and sm.revolution = :revolution
and sm.timeStart between :timeStartFrom and :timeStartTo
order by sm.timeStart asc, sm.id asc
"""
)
fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
@Param("satelliteNumber") satelliteNumber: String,
@Param("revolution") revolution: Long,
@Param("timeStartFrom") timeStartFrom: OffsetDateTime,
@Param("timeStartTo") timeStartTo: OffsetDateTime
): List<SurveyModeEntity>
}
@@ -0,0 +1,85 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.MediaType
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToFlux
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.OrbPointDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PointViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RadioVisibilityAreaDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamsWithPointsDTO
import java.time.LocalDateTime
@Service
class BallisticsService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>){
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.ballistics-service:ic-service}")
val url = ""
private fun client() = webClientBuilder.baseUrl(url).build()
fun mplSquare(req : ObjViewRequestDTO) =
client()
.post()
.uri("/api/obj-view/mpl-square")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToFlux<SquareViewParamDTO>()
.toIterable()
fun mplPoints(req : ObjViewRequestDTO) =
client()
.post()
.uri("/api/obj-view/mpl-point")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToFlux<PointViewParamDTO>()
.toIterable()
fun rva(id : Long,
tn : LocalDateTime,
tk : LocalDateTime) =
client()
.get()
.uri("/api/satellites/{id}/rva?time_start={time_start}&time_stop={time_stop}",
id,
tn,
tk)
.retrieve()
.bodyToFlux<RadioVisibilityAreaDTO>()
.toIterable()
fun cellCovering(cell : Long) =
client()
.get()
.uri("/api/obj-view/cell-coverage/{cell}", cell)
.retrieve()
.bodyToFlux<SquareViewParamsWithPointsDTO>()
.toIterable()
fun exactTime(sat : Long, req : ExactTimePositionRequestDTO) =
client()
.post()
.uri("/api/satellites/{satelliteId}/extract-time", sat)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(req)
.retrieve()
.bodyToFlux<OrbPointDTO>()
.toIterable()
}
@@ -0,0 +1,93 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotsStatusChangedEvent
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.nio.charset.StandardCharsets
import java.time.LocalDateTime
import java.util.UUID
@Component
/**
* Kafka-first publisher for booked slots lifecycle updates.
* Legacy HTTP processing in slots-service is retired from the main pipeline.
*/
class BookedSlotsEventPublisher(
kafkaTemplateProvider: ObjectProvider<KafkaTemplate<String, Any>>,
objectMapperProvider: ObjectProvider<ObjectMapper>,
@Value("\${app.kafka.topics.booked-slots:pcp.booked-slots}") private val topic: String,
@Value("\${spring.application.name:pcp-mission-planing-service}") private val applicationName: String
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val kafkaTemplate = kafkaTemplateProvider.ifAvailable
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
fun publish(
bookedSlotIds: List<Long>,
status: BookedSlotStatus,
missionId: UUID? = null,
modeId: Long? = null,
satelliteId: Long? = null
) {
val normalizedIds = bookedSlotIds.distinct().filter { it > 0 }
if (normalizedIds.isEmpty()) {
return
}
val template = kafkaTemplate
if (template == null) {
logger.warn(
"Kafka booked slots publisher is unavailable, event skipped: status={}, bookedSlots={}, missionId={}, modeId={}",
status,
normalizedIds.size,
missionId,
modeId
)
return
}
val event = BookedSlotsStatusChangedEvent(
bookedSlotIds = normalizedIds,
status = status,
occurredAt = LocalDateTime.now(),
sourceService = applicationName,
missionId = missionId,
modeId = modeId,
satelliteId = satelliteId
)
val message = KafkaMessage(
type = PcpKafkaEvent.BookedSlotsStatusChangedEvent,
data = event
).apply {
source = applicationName
}
val payload = try {
objectMapper.writeValueAsString(message)
} catch (exception: JsonProcessingException) {
throw IllegalStateException("Failed to serialize booked slots event", exception)
}
val record = ProducerRecord<String, Any>(topic, missionId?.toString() ?: modeId?.toString() ?: normalizedIds.first().toString(), payload)
record.headers().add("type", PcpKafkaEvent.BookedSlotsStatusChangedEvent.name.toByteArray(StandardCharsets.UTF_8))
template.send(record)
logger.info(
"Published Kafka-first booked slots status event: status={}, bookedSlots={}, missionId={}, modeId={}, satelliteId={}",
status,
normalizedIds.size,
missionId,
modeId,
satelliteId
)
}
}
@@ -0,0 +1,100 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToFlux
import org.springframework.web.reactive.function.client.bodyToMono
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO
import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime
@Service
/**
* Stage 5 primary reader for persisted stage 4 survey modes.
* The source of truth is pcp-complex-mission-service, not slots-service.
*/
class ComplexMissionService(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${pcp.services.complex-mission-service:pcp-complex-mission-service}")
val url = ""
private fun client() = webClientBuilder.baseUrl(url).build()
fun activeSnapshot(
satelliteId: Long
): SatelliteModeSnapshotResponseDTO? =
client()
.get()
.uri(
"/api/satellites/{satelliteId}/snapshots/active",
satelliteId
)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono<String>()
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(SatelliteModeSnapshotResponseDTO::class.java)
.blockOptional()
.orElse(null)
fun snapshot(snapshotId: Long): SatelliteModeSnapshotResponseDTO =
client()
.get()
.uri("/api/satellites/snapshots/{snapshotId}", snapshotId)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono<String>()
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(SatelliteModeSnapshotResponseDTO::class.java)
.block()
?: throw CustomErrorException("Snapshot $snapshotId not found")
fun snapshotModes(
snapshotId: Long,
intervalStart: LocalDateTime,
intervalEnd: LocalDateTime
): List<SatelliteModeResponseDTO> =
client()
.get()
.uri(
"/api/satellites/snapshots/{snapshotId}/modes?intervalStart={intervalStart}&intervalEnd={intervalEnd}",
snapshotId,
intervalStart,
intervalEnd
)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono<String>()
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToFlux<SatelliteModeResponseDTO>()
.collectList()
.block()
.orEmpty()
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
}
@@ -0,0 +1,135 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import io.camunda.client.annotation.JobWorker
import io.camunda.client.CamundaClient
import io.camunda.client.api.response.ActivatedJob
import io.camunda.client.api.worker.JobClient
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import java.time.LocalDateTime
import java.util.UUID
@Service
class FlowService(
private val camundaClient: CamundaClient,
private val missionPlaningService: MissionPlaningService
) {
private val logger : Logger = LoggerFactory.getLogger(this.javaClass)
@JobWorker(type = "calculateSatelliteSurveyMissions", autoComplete = false)
fun surveyPlan(client: JobClient, job: ActivatedJob) {
try {
val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString()
logger.info("Запрос на формирование плана съемки ${id}")
val mission = missionPlaningService.add(MissionRequestDTO(
missionId = UUID.fromString(id),
satelliteId = job.variablesAsMap.getOrDefault("spacecraftId", 0).toString().toLong(),
missionStart = job.variablesAsMap["planStartTime"]
?.toString()
?.let(LocalDateTime::parse),
missionStop = job.variablesAsMap["planEndTime"]
?.toString()
?.let(LocalDateTime::parse)
))
missionPlaningService.loadSlots(mission.missionId)
val cnt = missionPlaningService.modesByMission(UUID.fromString(id)).size
client.newCompleteCommand(job.key)
.variable("modes", cnt)
.send()
.exceptionally {
ex -> logger.warn(ex.message)
throw ex
}
}
catch (ex : Exception){
logger.warn(ex.message)
}
}
@JobWorker(type = "calculateSatelliteDropMissions", autoComplete = false)
fun surveyDrop(client: JobClient, job: ActivatedJob) {
try {
val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString()
logger.info("Запрос на формирование плана сбросов ${id}")
missionPlaningService.loadSlots(UUID.fromString(id))
val cnt = missionPlaningService.modesByMission(UUID.fromString(id)).size
client.newCompleteCommand(job.key)
.variable("modes", cnt)
.send()
.exceptionally {
ex -> logger.warn(ex.message)
throw ex
}
}
catch (ex : Exception){
logger.warn(ex.message)
}
}
@JobWorker(type = "sendSatellitePlan", autoComplete = false)
fun sendPlan(client: JobClient, job: ActivatedJob) {
try {
val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString()
logger.info("Отправлен план ${id}")
client.newCompleteCommand(job.key)
.send()
.exceptionally {
ex -> logger.warn(ex.message)
throw ex
}
Thread.sleep(5000)
logger.info("Иммитация сообщения о получении подтверждения $id")
camundaClient
.newPublishMessageCommand()
.messageName("Message_SatellitePlanAccepted")
.correlationKey(id)
.send()
.join()
}
catch (ex : Exception){
logger.warn(ex.message)
}
}
@JobWorker(type = "updateSlotsStatus", autoComplete = false)
fun confirmPlan(client: JobClient, job: ActivatedJob) {
try {
logger.info("Утверждение поана")
val id = job.variablesAsMap.getOrDefault("satellitePlanId", 0).toString()
val ids = missionPlaningService.confirmMission(UUID.fromString(id))
logger.info("План утвержден ${id}")
client.newCompleteCommand(job.key)
.variable("slots", ids)
.send()
.exceptionally {
ex -> logger.warn(ex.message)
throw ex
}
}
catch (ex : Exception){
logger.warn(ex.message)
}
}
}
@@ -0,0 +1,781 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import ballistics.flightLine.PointOnEarthCalculator
import ballistics.orbitalPoints.timeStepper.AbstractStepper
import ballistics.orbitalPoints.timeStepper.RungeStepper
import ballistics.types.EarthType
import ballistics.types.OrbitalPoint
import ballistics.types.Orientation
import ballistics.types.THBLPoint
import ballistics.types.WorkCSType
import ballistics.utils.fromDateTime
import ballistics.utils.math.Vector3D
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.io.WKTWriter
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository
import space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.ObjViewRequestDTO
import space.nstart.pcp.pcp_types_lib.dto.ballistics.PositionDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.UUID
import kotlin.math.PI
import kotlin.math.abs
@Service
class MissionPlaningService(
private val missionRepository: MissionRepository,
private val modeRepository: ModeRepository,
private val planRepository: PlanRepository,
private val surveyModeRepository: SurveyModeRepository,
private val dropModeRepository: DropModeRepository,
private val dropModeSurveyRepository: DropModeSurveyRepository,
private val modeBookingRepository: ModeBookingRepository,
private val bookedSlotsEventPublisher: BookedSlotsEventPublisher,
private val ballisticsService: BallisticsService,
private val complexMissionService: ComplexMissionService,
private val satelliteCatalogClient: SatelliteCatalogClient,
private val satelliteMissionTypeFactory: SatelliteMissionTypeFactory,
private val logger: Logger = LoggerFactory.getLogger(MissionPlaningService::class.java)
) {
fun all(): List<MissionResponseDTO> = missionRepository.findAll().map { it.toResponse() }
fun byId(id: UUID): MissionResponseDTO =
missionRepository.findById(id)
.orElseThrow { notFound(id) }
.toResponse()
@Transactional
fun add(body: MissionRequestDTO): MissionResponseDTO {
logger.info("сохранить миссию ${body.missionId} ${body.missionStart} ${body.missionStop}")
val mission = missionRepository.save(
MissionEntity(
missionId = body.missionId?: UUID.randomUUID(),
satelliteId = body.satelliteId,
station = body.station,
missionStart = body.missionStart,
missionStop = body.missionStop,
status = normalizeStatus(body.status)
)
)
syncMainPlanWithMission(mission)
return mission.toResponse()
}
@Transactional
fun update(id: UUID, body: MissionRequestDTO): MissionResponseDTO {
val mission = missionRepository.findById(id).orElseThrow { notFound(id) }
mission.satelliteId = body.satelliteId
mission.station = body.station
mission.missionStart = body.missionStart
mission.missionStop = body.missionStop
mission.status = normalizeStatus(body.status)
val savedMission = missionRepository.save(mission)
syncMainPlanWithMission(savedMission)
return savedMission.toResponse()
}
fun delete(id: UUID) {
if (!missionRepository.existsById(id)) {
throw notFound(id)
}
missionRepository.deleteById(id)
}
fun modesByMission(id: UUID): List<ModeResponseDTO> {
if (!missionRepository.existsById(id)) {
throw notFound(id)
}
val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(id)
return mapModesToResponse(modes)
}
fun modesBySatelliteAndInterval(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): List<ModeResponseDTO> {
if (timeStop.isBefore(timeStart)) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"timeStop must be greater than or equal to timeStart"
)
}
val modes = modeRepository
.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
satelliteId,
timeStop.atOffset(ZoneOffset.UTC)
)
.filter { it.intersects(timeStart, timeStop) }
return mapModesToResponse(modes)
}
private fun MissionEntity.toResponse() = MissionResponseDTO(
missionId = missionId,
satelliteId = satelliteId,
station = station,
missionStart = missionStart,
missionStop = missionStop,
status = status
)
private fun normalizeStatus(status: String?): String = status?.trim().takeUnless { it.isNullOrEmpty() } ?: "NEW"
private fun notFound(id: UUID) =
ResponseStatusException(HttpStatus.NOT_FOUND, "Mission with id $id not found")
private fun mapModesToResponse(modes: List<ModeEntity>): List<ModeResponseDTO> {
val bookingsByModeId = bookingsByModeId(modes)
return modes.map { it.toModeResponse(bookingsByModeId) }
}
private fun bookingsByModeId(modes: List<ModeEntity>): Map<Long, List<Long>> {
val surveyModeIds = modes.filterIsInstance<SurveyModeEntity>().mapNotNull { it.id }
if (surveyModeIds.isEmpty()) {
return emptyMap()
}
return modeBookingRepository.findAllByMode_IdIn(surveyModeIds)
.groupBy(
keySelector = { it.mode?.id },
valueTransform = { it.slotId }
)
.mapKeys { (modeId, _) -> modeId ?: -1L }
.mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() }
}
private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean {
val modeStart = timeStart?.toLocalDateTime() ?: return false
val modeStop = modeStop() ?: return false
return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop)
}
private fun ModeEntity.modeStop(): LocalDateTime? {
val modeStart = timeStart?.toLocalDateTime() ?: return null
val durationSeconds = when (this) {
is SurveyModeEntity -> duration
is DropModeEntity -> duration
else -> 0.0
}.coerceAtLeast(0.0)
return modeStart.plusNanos((durationSeconds * 1_000_000_000L).toLong())
}
private fun syncMainPlanWithMission(mission: MissionEntity) {
val plan = planRepository.findFirstByMissionMissionIdOrderByIdAsc(mission.missionId)
.orElseGet { PlanEntity(mission = mission, planNumber = 1) }
plan.mission = mission
plan.planStart = mission.missionStart
plan.planStop = mission.missionStop
plan.planNumber = 1
planRepository.save(plan)
}
private fun ModeEntity.toModeResponse(bookingsByModeId: Map<Long, List<Long>> = emptyMap()): ModeResponseDTO {
val modeId = id ?: throw IllegalStateException("Mode id is null")
return when (this) {
is SurveyModeEntity -> toSurveyResponse(bookingsByModeId[modeId].orEmpty())
is DropModeEntity -> DropModeResponseDTO(
id = modeId,
planId = plan?.id ?: throw IllegalStateException("Plan id is null for mode $modeId"),
timeStart = timeStart,
revolution = revolution,
station = station,
duration = duration,
surveys = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(modeId)
.map { link ->
val survey = link.surveyMode
?: throw IllegalStateException("Survey mode is null for drop mode $modeId")
survey.id?:-1
}
)
else -> throw IllegalStateException("Unsupported mode type: ${this::class.qualifiedName}")
}
}
private fun SurveyModeEntity.toSurveyResponse(bookedSlotIds: List<Long> = emptyList()): SurveyModeResponseDTO {
val surveyId = id ?: throw IllegalStateException("Survey mode id is null")
val surveyPlanId = plan?.id ?: throw IllegalStateException("Plan id is null for survey mode $surveyId")
return SurveyModeResponseDTO(
id = surveyId,
planId = surveyPlanId,
timeStart = timeStart,
revolution = revolution,
status = status.name,
satelliteModeId = satelliteModeId,
source = source.name,
lat = lat,
longitude = long,
duration = duration,
contourWkt = contourWkt,
roll = roll,
bookedSlotIds = bookedSlotIds
)
}
@Transactional
fun loadSlots(
missionId: UUID,
overrideSatelliteId: Long? = null,
overrideIntervalStart: LocalDateTime? = null,
overrideIntervalEnd: LocalDateTime? = null,
comPlanSnapshotId: Long? = null
): MissionSurveyCalculationResponseDTO {
val startedAt = LocalDateTime.now()
logger.info(
"Расчет плана съемки stage 5 по persisted snapshot этапа 4 для миссии {}: requestedComPlanSnapshotId={}",
missionId,
comPlanSnapshotId
)
val mission = missionRepository.findById(missionId).orElseThrow {
notFound(missionId)
}
if (overrideSatelliteId != null || overrideIntervalStart != null || overrideIntervalEnd != null) {
overrideSatelliteId?.let { mission.satelliteId = it }
overrideIntervalStart?.let { mission.missionStart = it }
overrideIntervalEnd?.let { mission.missionStop = it }
missionRepository.save(mission)
}
val satelliteId = overrideSatelliteId ?: mission.satelliteId
val intervalStart = overrideIntervalStart ?: mission.missionStart
?: throw CustomErrorException("Не задано время начала миссии")
val intervalEnd = overrideIntervalEnd ?: mission.missionStop
?: throw CustomErrorException("Не задано время конца миссии")
if (intervalEnd.isBefore(intervalStart)) {
throw CustomErrorException("Некорректный интервал миссии: missionStop < missionStart")
}
val satellite = satelliteCatalogClient.getSatellite(satelliteId)
val missionType = satelliteMissionTypeFactory.getBySatellite(satellite)
logger.info(
"Расчет плана съемки для миссии {} выполняется по логике типа спутника: satelliteId={}, typeCode={}, missionType={}",
missionId,
satelliteId,
satellite.typeCode,
missionType.typeCode
)
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
.orElseGet {
planRepository.save(
PlanEntity(
mission = mission,
planStart = mission.missionStart,
planStop = mission.missionStop,
planNumber = 1
)
)
}
plan.planStart = intervalStart
plan.planStop = intervalEnd
val planId = plan.id ?: throw IllegalStateException("Не найден план для миссии ${mission.missionId}")
val snapshotSelection = resolveStage4Snapshot(
satelliteId = satelliteId,
intervalStart = intervalStart,
intervalEnd = intervalEnd,
explicitSnapshotId = comPlanSnapshotId
)
plan.stage4SnapshotId = snapshotSelection.snapshot.id
planRepository.save(plan)
val survs = complexMissionService.snapshotModes(
snapshotId = snapshotSelection.snapshot.id,
intervalStart = intervalStart,
intervalEnd = intervalEnd
)
logger.info(
"Этап 5 загружает persisted snapshot этапа 4 из pcp-complex-mission-service: missionId={}, satelliteId={}, interval=[{} - {}], selection={}, snapshotId={}, loadedModes={}, manualModes={}, modesWithBookedSlots={}",
missionId,
satelliteId,
intervalStart,
intervalEnd,
snapshotSelection.selectionMode,
snapshotSelection.snapshot.id,
survs.size,
survs.count { it.source == SurveyModeSource.MANUAL.name },
survs.count { it.bookedSlotIds.isNotEmpty() }
)
modeRepository.deleteSurveyModesByPlanId(planId)
if (survs.isEmpty()) {
val finishedAt = LocalDateTime.now()
return MissionSurveyCalculationResponseDTO(
missionId = missionId,
completed = true,
startedAt = startedAt,
finishedAt = finishedAt,
durationMs = Duration.between(startedAt, finishedAt).toMillis(),
comPlanSnapshotId = snapshotSelection.snapshot.id,
surveyModesCount = 0,
manualModesCount = 0,
complanModesCount = 0,
slotsModesCount = 0,
mixedModesCount = 0,
bookedSlotIdsCount = 0
)
}
val preparedModes = survs.map { surv ->
missionType.prepareSurvey(satellite, surv, plan) to surv.bookedSlotIds.distinct()
}
val savedModes = surveyModeRepository.saveAll(
(preparedModes.mapNotNull { it.first })
).toList()
val modeBookings = savedModes.zip(preparedModes.map { it.second })
.flatMap { (mode, slotIds) ->
slotIds
.filter { it > 0 }
.map { slotId -> ModeBookingEntity(mode = mode, slotId = slotId) }
}
if (modeBookings.isNotEmpty()) {
modeBookingRepository.saveAll(modeBookings)
}
logger.info(
"Расчет плана съемки stage 5 для миссии {} завершен по persisted snapshot stage 4: selection={}, snapshotId={}, createdSurveyModes={}, preservedBookings={}",
missionId,
snapshotSelection.selectionMode,
snapshotSelection.snapshot.id,
savedModes.size,
modeBookings.size
)
val finishedAt = LocalDateTime.now()
return MissionSurveyCalculationResponseDTO(
missionId = missionId,
completed = true,
startedAt = startedAt,
finishedAt = finishedAt,
durationMs = Duration.between(startedAt, finishedAt).toMillis(),
comPlanSnapshotId = snapshotSelection.snapshot.id,
surveyModesCount = savedModes.size,
manualModesCount = survs.count { it.source == SurveyModeSource.MANUAL.name },
complanModesCount = survs.count { it.source == SurveyModeSource.COMPLAN.name },
slotsModesCount = survs.count { it.source == SurveyModeSource.SLOTS.name },
mixedModesCount = survs.count { it.source == SurveyModeSource.MIXED.name },
bookedSlotIdsCount = survs.flatMap { it.bookedSlotIds }.filter { it > 0 }.distinct().size
)
}
private data class Stage4SnapshotSelection(
val snapshot: space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO,
val selectionMode: String
)
private fun resolveStage4Snapshot(
satelliteId: Long,
intervalStart: LocalDateTime,
intervalEnd: LocalDateTime,
explicitSnapshotId: Long?
): Stage4SnapshotSelection {
val snapshot = if (explicitSnapshotId != null) {
val explicitSnapshot = complexMissionService.snapshot(explicitSnapshotId)
logger.info(
"Stage 5 uses explicitly provided stage4 snapshot: satelliteId={}, interval=[{} - {}], snapshotId={}",
satelliteId,
intervalStart,
intervalEnd,
explicitSnapshotId
)
explicitSnapshot
} else {
val activeSnapshot = complexMissionService.activeSnapshot(satelliteId)
?: throw CustomErrorException(
"Active stage4 snapshot not found for satelliteId=$satelliteId and interval=[$intervalStart - $intervalEnd]"
)
logger.info(
"Stage 5 auto-selected active stage4 snapshot by satelliteId and will filter modes by mission interval: satelliteId={}, interval=[{} - {}], snapshotId={}",
satelliteId,
intervalStart,
intervalEnd,
activeSnapshot.id
)
activeSnapshot
}
if (snapshot.satelliteId != satelliteId) {
throw CustomErrorException(
"Snapshot ${snapshot.id} does not match requested satelliteId=$satelliteId: " +
"snapshot.satelliteId=${snapshot.satelliteId}"
)
}
return Stage4SnapshotSelection(
snapshot = snapshot,
selectionMode = if (explicitSnapshotId != null) "explicit" else "auto-active"
)
}
private fun extractCenterLatLon(contourWkt: String?): Pair<Double, Double> {
if (contourWkt.isNullOrBlank()) {
return 0.0 to 0.0
}
val pairs = Regex("(-?\\d+(?:\\.\\d+)?)\\s+(-?\\d+(?:\\.\\d+)?)")
.findAll(contourWkt)
.mapNotNull { match ->
val lon = match.groupValues[1].toDoubleOrNull()
val lat = match.groupValues[2].toDoubleOrNull()
if (lon == null || lat == null) null else lon to lat
}
.toMutableList()
if (pairs.isEmpty()) {
return 0.0 to 0.0
}
if (pairs.size > 1 && pairs.first() == pairs.last()) {
pairs.removeAt(pairs.lastIndex)
}
val avgLon = pairs.map { it.first }.average()
val avgLat = pairs.map { it.second }.average()
return avgLat to avgLon
}
fun prepareSurvey(surv: SatelliteModeResponseDTO, plan: PlanEntity): SurveyModeEntity =
satelliteMissionTypeFactory.defaultType().prepareSurvey(
satellite = space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO(),
surv = surv,
plan = plan
)
private fun contour(
stepper: AbstractStepper,
tn: LocalDateTime,
tk: LocalDateTime,
roll: Double,
capture: Double
): String {
var t = tn
val points = mutableListOf<OrbitalPoint>()
while (t <= tk) {
points.add(
stepper.calculate(fromDateTime(t))
?: throw CustomErrorException("Ошибка расчета выхода на заданное время")
)
t = t.plusSeconds(5)
}
val r = points.map {
viewParams(it, roll + capture) ?: THBLPoint(
0.0,
0.0,
0.0,
0.0
)
}
val l = points.map {
viewParams(it, roll - capture) ?: THBLPoint(
0.0,
0.0,
0.0,
0.0
)
}
val geom = mutableListOf<Coordinate>()
for (rp in r)
geom.add(Coordinate(rp.long * 180 / PI, rp.lat * 180 / PI, 0.0))
for (lp in l.reversed())
geom.add(Coordinate(lp.long * 180 / PI, lp.lat * 180 / PI, 0.0))
if (!geom.isEmpty())
geom.add(Coordinate(geom.first().x, geom.first().y))
val geometryFactory = GeometryFactory()
val shell = geometryFactory.createLinearRing(geom.toTypedArray())
val polygon = geometryFactory.createPolygon(shell)
val wktWriter = WKTWriter()
return wktWriter.write(polygon)
}
private fun viewParams(pos: OrbitalPoint, gamma: Double): THBLPoint? {
val calculator = PointOnEarthCalculator(EarthType.PZ90d02, WorkCSType.WCSOrbit)
return calculator.pointOnEarth(pos, Orientation(0.0, gamma * PI / 180.0, 0.0))
}
@Transactional
fun calculateDrops(missionId : UUID){
logger.info("Расчет плана сбросов для миссии $missionId")
val mission = missionRepository.findById(missionId).orElseThrow {
notFound(missionId)
}
val satellite = satelliteCatalogClient.getSatellite(mission.satelliteId)
val missionType = satelliteMissionTypeFactory.getBySatellite(satellite)
logger.info(
"Расчет плана сбросов для миссии {} выполняется по логике типа спутника: satelliteId={}, typeCode={}, missionType={}",
missionId,
mission.satelliteId,
satellite.typeCode,
missionType.typeCode
)
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)
.orElseGet {
planRepository.save(
PlanEntity(
mission = mission,
planStart = mission.missionStart,
planStop = mission.missionStop,
planNumber = 1
)
)
}
val planId = plan.id ?: throw IllegalStateException("Plan id is null for mission $missionId")
modeRepository.deleteDropModesByPlanId(planId)
val surveys = surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)
logger.info("Для миссии $missionId загруженно ${surveys.size} съемок для расчета плана сбросов")
if (surveys.isEmpty()) {
return
}
val zones = ballisticsService.rva(mission.satelliteId,
mission.missionStart?: throw CustomErrorException("Не задано время начала миссии"),
mission.missionStop?: throw CustomErrorException("Не задано время конца миссии"))
.sortedBy { it.onStart.time }
.toList()
if (zones.isEmpty()) {
logger.warn("Для миссии $missionId нет ЗРВ")
return
}
logger.info("Для миссии $missionId полученно ${zones.size} ЗРВ")
val unassigned = surveys.toMutableList()
zones.forEach { zone ->
val zoneStart = zone.onStart.time
val zoneDuration = Duration.between(zone.onStart.time, zone.onStop.time).toMillis().toDouble() / 1000.0
if (zoneDuration <= 0.0) {
return@forEach
}
val selectedSurveys = mutableListOf<SurveyModeEntity>()
var usedDuration = 0.0
val iterator = unassigned.iterator()
while (iterator.hasNext()) {
val survey = iterator.next()
val surveyStartOffset = survey.timeStart
if (surveyStartOffset == null) {
iterator.remove()
continue
}
val surveyStart = surveyStartOffset.toLocalDateTime()
val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
if (!surveyEnd.isBefore(zoneStart)) {
continue
}
val dropDuration = missionType.calculateDropDuration(satellite, survey).coerceAtLeast(0.0)
if (usedDuration + dropDuration > zoneDuration) {
continue
}
usedDuration += dropDuration
selectedSurveys.add(survey)
iterator.remove()
}
if (selectedSurveys.isEmpty()) {
return@forEach
}
val dropMode = dropModeRepository.save(
DropModeEntity(
plan = plan,
timeStart = zoneStart.atOffset(ZoneOffset.UTC),
revolution = zone.revolution,
station = zone.stationId.toString(),
duration = usedDuration
)
)
val links = selectedSurveys.map { survey ->
DropModeSurveyEntity(
dropMode = dropMode,
surveyMode = survey
)
}
dropModeSurveyRepository.saveAll(links)
}
logger.info("План сбросов для миссии $missionId расчитан")
}
fun calculateDropDuration(surv : SurveyModeEntity) : Double =
satelliteMissionTypeFactory.defaultType().calculateDropDuration(
satellite = space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO(),
survey = surv
)
@Transactional
fun confirmMission(missionId: UUID) : List<Long>{
val mission = missionRepository.findById(missionId).orElseThrow {
notFound(missionId)
}
mission.status = "CONFIRMED"
missionRepository.save(mission)
val surveyModeIds = surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId)
.map { it.id?:-1 }
.distinct()
if (surveyModeIds.isEmpty()) {
logger.info("Для миссии $missionId нет съемок")
return listOf()
}
val bookedSlotIds = modeBookingRepository.findAllByMode_IdIn(surveyModeIds)
.map { it.slotId }
.filter { it > 0 }
.distinct()
if (bookedSlotIds.isEmpty()) {
logger.info("Для миссии $missionId нет забронированных слотов для подтверждения")
return listOf()
}
bookedSlotsEventPublisher.publish(
bookedSlotIds = bookedSlotIds,
status = BookedSlotStatus.PROCESSED,
missionId = missionId,
satelliteId = mission.satelliteId
)
logger.info("Для миссии $missionId опубликовано событие PROCESSED по {} слотам", bookedSlotIds.size)
return bookedSlotIds
}
@Transactional
fun updateSurveyModeStatus(modeId: Long, rawStatus: SurveyModeStatus) {
val surveyMode = surveyModeRepository.findById(modeId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found")
}
surveyMode.status = rawStatus
surveyModeRepository.save(surveyMode)
val bookedSlotIds = modeBookingRepository.findAllByMode_IdIn(listOf(modeId))
.map { it.slotId }
.filter { it > 0 }
.distinct()
if (bookedSlotIds.isEmpty()) {
logger.info("Survey mode {} updated to {} without booked slots", modeId, rawStatus)
return
}
bookedSlotsEventPublisher.publish(
bookedSlotIds = bookedSlotIds,
status = rawStatus.toBookedSlotStatus(),
missionId = surveyMode.plan?.mission?.missionId,
modeId = modeId,
satelliteId = surveyMode.plan?.mission?.satelliteId
)
logger.info(
"Survey mode {} updated to {}, published booked slot event for {} slots",
modeId,
rawStatus,
bookedSlotIds.size
)
}
@Transactional(readOnly = true)
fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? {
val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1)
val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1)
val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
satelliteNumber = routePassport.kaShort.trim(),
revolution = routePassport.orbitNumber,
timeStartFrom = searchStart,
timeStartTo = searchEnd
)
return candidates.minByOrNull { candidate ->
val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE
abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis())
}
}
private fun normalizeSurveyModeStatus(value: String): SurveyModeStatus =
runCatching { SurveyModeStatus.valueOf(value.trim().uppercase()) }
.getOrElse {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Unsupported survey mode status: $value"
)
}
private fun SurveyModeStatus.toBookedSlotStatus(): BookedSlotStatus = when (this) {
SurveyModeStatus.PROCESSED -> BookedSlotStatus.FINISHED
SurveyModeStatus.CANCELED -> BookedSlotStatus.CANCELED
SurveyModeStatus.FAILED -> BookedSlotStatus.FAILED
SurveyModeStatus.PLANNED, SurveyModeStatus.DROPPED -> throw IllegalStateException("$this survey mode status is not publishable")
}
}
@@ -0,0 +1,73 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Component
class ModeStatusChangedKafkaListener(
objectMapperProvider: ObjectProvider<ObjectMapper>,
private val missionPlaningService: MissionPlaningService,
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
@KafkaListener(
topics = ["\${app.kafka.topics.mode-status-changed:pcp.route.in.v1}"],
groupId = "\${spring.kafka.consumer.group-id}-mode-status-changed"
)
fun consume(message: String) {
try {
val kafkaMessage = objectMapper.readValue(
message,
object : TypeReference<KafkaMessage<RoutePassportDto>>() {},
)
if (kafkaMessage.type != PcpKafkaEvent.ModeStatusChangedEvent) {
logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type)
return
}
val routePassport = kafkaMessage.data
val mode = missionPlaningService.findMode(routePassport)
if (mode?.id == null) {
logger.error(
"Survey mode not found for ModeStatusChangedEvent: routeId={}, satelliteNumber={}, revolution={}, intervalBegin={}, routeStatus={}, traceId={}, source={}",
routePassport.routeId,
routePassport.kaShort,
routePassport.orbitNumber,
routePassport.intervalBegin,
routePassport.routeStatus,
kafkaMessage.traceId,
kafkaMessage.source,
)
return
}
missionPlaningService.updateSurveyModeStatus(mode.id, routePassport.routeStatus)
logger.info(
"Mode status changed event processed: routeId={}, surveyModeId={}, satelliteNumber={}, revolution={}, intervalBegin={}, routeStatus={}, traceId={}, source={}",
routePassport.routeId,
mode.id,
routePassport.kaShort,
routePassport.orbitNumber,
routePassport.intervalBegin,
routePassport.routeStatus,
kafkaMessage.traceId,
kafkaMessage.source,
)
} catch (exception: Exception) {
logger.error("Failed to process ModeStatusChangedEvent Kafka payload", exception)
throw exception
}
}
}
@@ -0,0 +1,72 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDeletedEventDTO
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
private const val SATELLITE_DELETED_CONSUMER_GROUP = "pcp-mission-planing-service-satellite-deleted"
@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false)
@Component
class SatelliteDeletedKafkaListener(
objectMapperProvider: ObjectProvider<ObjectMapper>,
private val satelliteDeletedService: SatelliteDeletedService
) {
private val logger = LoggerFactory.getLogger(this::class.java)
private val objectMapper = objectMapperProvider.ifAvailable ?: jacksonObjectMapper().findAndRegisterModules()
@KafkaListener(
topics = ["\${app.kafka.topics.satellites:pcp.satellites}"],
groupId = SATELLITE_DELETED_CONSUMER_GROUP
)
fun consume(message: String) {
try {
val kafkaMessage = objectMapper.readValue(
message,
object : TypeReference<KafkaMessage<SatelliteDeletedEventDTO>>() {}
)
if (kafkaMessage.type != PcpKafkaEvent.SatelliteDeletedEvent) {
logger.debug("Ignoring Kafka message with unsupported type {}", kafkaMessage.type)
return
}
val event = kafkaMessage.data
logger.info(
"Received SatelliteDeletedEvent in mission-planing-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}",
event.satelliteId,
event.noradId,
kafkaMessage.id,
kafkaMessage.traceId,
kafkaMessage.source,
SATELLITE_DELETED_CONSUMER_GROUP
)
event.deleteIdentifiers().forEach { satelliteId ->
satelliteDeletedService.deleteSatelliteData(satelliteId)
}
logger.info(
"SatelliteDeletedEvent processed in mission-planing-service: satelliteId={}, noradId={}, eventId={}, traceId={}, source={}, consumerGroup={}",
event.satelliteId,
event.noradId,
kafkaMessage.id,
kafkaMessage.traceId,
kafkaMessage.source,
SATELLITE_DELETED_CONSUMER_GROUP
)
} catch (exception: Exception) {
logger.error("Failed to process SatelliteDeletedEvent in mission-planing-service", exception)
throw exception
}
}
private fun SatelliteDeletedEventDTO.deleteIdentifiers(): List<Long> =
listOfNotNull(satelliteId, noradId).distinct()
}
@@ -0,0 +1,48 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
@Service
class SatelliteDeletedService(
private val missionRepository: MissionRepository,
private val planRepository: PlanRepository,
private val modeRepository: ModeRepository
) {
private val logger = LoggerFactory.getLogger(this::class.java)
@Transactional
fun deleteSatelliteData(satelliteId: Long): SatelliteMissionPlanningDeletionSummary {
val missionsBeforeDelete = missionRepository.countBySatelliteId(satelliteId)
val plansDeletedByCascade = planRepository.countByMissionSatelliteId(satelliteId)
val modesDeletedByCascade = modeRepository.countByPlan_Mission_SatelliteId(satelliteId)
val missionsDeleted = missionRepository.deleteAllBySatelliteId(satelliteId)
val summary = SatelliteMissionPlanningDeletionSummary(
satelliteId = satelliteId,
missionsDeleted = missionsDeleted,
plansDeletedByCascade = plansDeletedByCascade,
modesDeletedByCascade = modesDeletedByCascade
)
logger.info(
"Deleted mission-planning data for satellite: satelliteId={}, missionsBeforeDelete={}, missionsDeleted={}, plansDeletedByCascade={}, modesDeletedByCascade={}",
summary.satelliteId,
missionsBeforeDelete,
summary.missionsDeleted,
summary.plansDeletedByCascade,
summary.modesDeletedByCascade
)
return summary
}
}
data class SatelliteMissionPlanningDeletionSummary(
val satelliteId: Long,
val missionsDeleted: Long,
val plansDeletedByCascade: Long,
val modesDeletedByCascade: Long
)
@@ -0,0 +1,49 @@
package space.nstart.pcp.pcp_mission_planing_service.service.catalog
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToMono
import reactor.core.publisher.Mono
import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import tools.jackson.databind.ObjectMapper
@Service
class SatelliteCatalogClient(webClientBuilderProvider: ObjectProvider<WebClient.Builder>) {
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
@Value("\${settings.satellite-catalog-service:pcp-satellite-catalog-service}")
private val url: String = ""
private fun client() = webClientBuilder.baseUrl(url).build()
fun getSatellite(satelliteId: Long): SatelliteDTO =
client()
.get()
.uri("/api/satellites/{satelliteId}", satelliteId)
.retrieve()
.onStatus({ status -> status.isError }) { response ->
response.bodyToMono<String>()
.defaultIfEmpty("error")
.flatMap { body -> Mono.error(CustomErrorException(extractErrorMessage(body))) }
}
.bodyToMono(SatelliteDTO::class.java)
.block()
?: throw CustomErrorException("Satellite $satelliteId not found in satellite-catalog-service")
private fun extractErrorMessage(body: String): String {
val text = body.trim()
if (text.isEmpty()) return "error"
return runCatching {
val node = ObjectMapper().readTree(text)
when {
node.hasNonNull("error") -> node["error"].asText()
node.hasNonNull("message") -> node["message"].asText()
else -> text
}
}.getOrDefault(text)
}
}
@@ -0,0 +1,15 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import org.springframework.stereotype.Component
@Component
class DefaultSatelliteMissionType : LegacySatelliteMissionTypeSupport() {
override val typeCode: String = TYPE_CODE
override fun supports(satelliteTypeCode: String?): Boolean =
satelliteTypeCode.isNullOrBlank() || satelliteTypeCode.trim().equals(TYPE_CODE, ignoreCase = true)
companion object {
const val TYPE_CODE = "DEFAULT"
}
}
@@ -0,0 +1,19 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import org.springframework.stereotype.Component
/**
* Mission planning behavior for Emissio satellites.
*
* At the moment this class intentionally preserves the legacy behavior.
* Type-specific restrictions and formulas should be implemented here when the
* Emissio mission model is formalized.
*/
@Component
class EmissioSatelliteMissionType : LegacySatelliteMissionTypeSupport() {
override val typeCode: String = TYPE_CODE
companion object {
const val TYPE_CODE = "EMISSIO"
}
}
@@ -0,0 +1,38 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import java.time.ZoneOffset
abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType {
override fun prepareSurvey(
satellite: SatelliteDTO,
surv: SatelliteModeResponseDTO,
plan: PlanEntity
): SurveyModeEntity = SurveyModeEntity(
plan = plan,
timeStart = surv.startTime.atOffset(ZoneOffset.UTC),
revolution = surv.revolution,
lat = surv.lat,
long = surv.longitude,
duration = surv.duration,
contourWkt = surv.contourWkt,
roll = surv.roll,
satelliteModeId = surv.id,
status = SurveyModeStatus.PLANNED,
source = SurveyModeSource.valueOf(surv.source)
)
override fun calculateDropDuration(
satellite: SatelliteDTO,
survey: SurveyModeEntity
): Double = survey.duration * DEFAULT_DROP_DURATION_MULTIPLIER
companion object {
const val DEFAULT_DROP_DURATION_MULTIPLIER = 10.1
}
}
@@ -0,0 +1,19 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import org.springframework.stereotype.Component
/**
* Mission planning behavior for Reflixio satellites.
*
* At the moment this class intentionally preserves the legacy behavior.
* Type-specific restrictions and formulas should be implemented here when the
* Reflixio mission model is formalized.
*/
@Component
class ReflixioSatelliteMissionType : LegacySatelliteMissionTypeSupport() {
override val typeCode: String = TYPE_CODE
companion object {
const val TYPE_CODE = "REFLIXIO"
}
}
@@ -0,0 +1,32 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
/**
* Type-specific mission planning behavior for one satellite type.
*
* Implementations encapsulate all rules that may differ by satellite type:
* survey preparation, survey restrictions, drop duration formulas and future
* type-specific constraints. The default implementation must preserve current
* legacy mission planning behavior.
*/
interface SatelliteMissionType {
val typeCode: String
fun supports(satelliteTypeCode: String?): Boolean =
satelliteTypeCode?.trim()?.equals(typeCode, ignoreCase = true) == true
fun prepareSurvey(
satellite: SatelliteDTO,
surv: SatelliteModeResponseDTO,
plan: PlanEntity
): SurveyModeEntity
fun calculateDropDuration(
satellite: SatelliteDTO,
survey: SurveyModeEntity
): Double
}
@@ -0,0 +1,41 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
@Component
class SatelliteMissionTypeFactory(
private val missionTypes: List<SatelliteMissionType>,
private val defaultMissionType: DefaultSatelliteMissionType
) {
private val logger = LoggerFactory.getLogger(SatelliteMissionTypeFactory::class.java)
private val missionTypesByCode: Map<String, SatelliteMissionType> = missionTypes
.associateBy { normalize(it.typeCode) }
fun getBySatellite(satellite: SatelliteDTO): SatelliteMissionType =
getByTypeCode(satellite.typeCode)
fun getByTypeCode(typeCode: String?): SatelliteMissionType {
val normalizedTypeCode = normalize(typeCode)
if (normalizedTypeCode.isBlank()) {
return defaultMissionType
}
val missionType = missionTypesByCode[normalizedTypeCode]
if (missionType != null) {
return missionType
}
logger.warn(
"Mission planner for satellite type '{}' was not found; default mission planner will be used",
typeCode
)
return defaultMissionType
}
fun defaultType(): SatelliteMissionType = defaultMissionType
private fun normalize(value: String?): String = value?.trim()?.uppercase().orEmpty()
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-mission-planing-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:dev}
@@ -0,0 +1,12 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS missions (
mission_id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
satellite_id BIGINT NOT NULL,
station VARCHAR(32),
mission_start TIMESTAMP WITH TIME ZONE,
mission_stop TIMESTAMP WITH TIME ZONE,
status VARCHAR(15) DEFAULT 'NEW'
);
CREATE INDEX idx_missions_mission_id ON missions(mission_id);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS plans (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mission_id UUID NOT NULL REFERENCES missions(mission_id) ON DELETE CASCADE,
plan_start TIMESTAMP WITH TIME ZONE,
plan_stop TIMESTAMP WITH TIME ZONE,
plan_number BIGINT NOT NULL,
revolution BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plans_mission_id ON plans(mission_id);
CREATE INDEX IF NOT EXISTS idx_plans_plan_number ON plans(plan_number);
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS modes (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
plan_id BIGINT NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
time_start TIMESTAMP WITH TIME ZONE,
revolution BIGINT NOT NULL,
type VARCHAR(20) NOT NULL,
CONSTRAINT chk_modes_type CHECK (type IN ('SURVEY', 'DROP'))
);
CREATE INDEX IF NOT EXISTS idx_modes_plan_id ON modes(plan_id);
CREATE INDEX IF NOT EXISTS idx_modes_type ON modes(type);
CREATE TABLE IF NOT EXISTS survey_modes (
id BIGINT PRIMARY KEY REFERENCES modes(id) ON DELETE CASCADE,
lat DOUBLE PRECISION NOT NULL,
long DOUBLE PRECISION NOT NULL,
duration DOUBLE PRECISION NOT NULL,
contour_wkt TEXT,
contour_geometry geometry(Polygon, 4326)
GENERATED ALWAYS AS (ST_GeomFromText(contour_wkt)::geometry(Polygon, 4326)) STORED,
roll DOUBLE PRECISION NOT NULL
);
CREATE TABLE IF NOT EXISTS drop_modes (
id BIGINT PRIMARY KEY REFERENCES modes(id) ON DELETE CASCADE,
station VARCHAR(255),
duration DOUBLE PRECISION NOT NULL
);
@@ -0,0 +1,15 @@
ALTER TABLE survey_modes
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'USER';
ALTER TABLE survey_modes
DROP CONSTRAINT IF EXISTS chk_survey_modes_source;
CREATE TABLE IF NOT EXISTS mode_booking (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mode_id BIGINT NOT NULL REFERENCES survey_modes(id) ON DELETE CASCADE,
slot_id BIGINT NOT NULL,
CONSTRAINT uq_mode_booking_mode_slot UNIQUE(mode_id, slot_id)
);
CREATE INDEX IF NOT EXISTS idx_mode_booking_mode_id ON mode_booking(mode_id);
CREATE INDEX IF NOT EXISTS idx_mode_booking_slot_id ON mode_booking(slot_id);
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS drop_mode_survey (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
drop_mode_id BIGINT NOT NULL REFERENCES drop_modes(id) ON DELETE CASCADE,
survey_mode_id BIGINT NOT NULL REFERENCES survey_modes(id) ON DELETE CASCADE,
CONSTRAINT uq_drop_mode_survey UNIQUE(drop_mode_id, survey_mode_id)
);
CREATE INDEX IF NOT EXISTS idx_drop_mode_survey_drop_id ON drop_mode_survey(drop_mode_id);
CREATE INDEX IF NOT EXISTS idx_drop_mode_survey_survey_id ON drop_mode_survey(survey_mode_id);
@@ -0,0 +1,5 @@
ALTER TABLE survey_modes
ADD COLUMN IF NOT EXISTS satellite_mode_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_survey_modes_satellite_mode_id
ON survey_modes(satellite_mode_id);
@@ -0,0 +1,5 @@
ALTER TABLE survey_modes
ADD COLUMN IF NOT EXISTS satellite_mode_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_survey_modes_satellite_mode_id
ON survey_modes(satellite_mode_id);
@@ -0,0 +1,9 @@
ALTER TABLE survey_modes
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED';
ALTER TABLE survey_modes
DROP CONSTRAINT IF EXISTS chk_survey_modes_status;
ALTER TABLE survey_modes
ADD CONSTRAINT chk_survey_modes_status
CHECK (status IN ('PLANNED', 'FINISHED', 'CANCELED', 'FAILED'));
@@ -0,0 +1,5 @@
ALTER TABLE plans
ADD COLUMN IF NOT EXISTS stage4_snapshot_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_plans_stage4_snapshot_id
ON plans(stage4_snapshot_id);
@@ -0,0 +1,24 @@
package space.nstart.pcp.pcp_mission_planing_service
import io.camunda.client.CamundaClient
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.mockito.Mockito.mock
@SpringBootTest
@Import(PcpBallisticsServiceApplicationTests.TestBeans::class)
class PcpBallisticsServiceApplicationTests {
@Test
fun contextLoads() {
}
@TestConfiguration
class TestBeans {
@Bean
fun camundaClient(): CamundaClient = mock(CamundaClient::class.java)
}
}
@@ -0,0 +1,58 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
class MissionControllerTest {
private val missionPlaningService = mock(MissionPlaningService::class.java)
private val controller = MissionController(missionPlaningService)
@Test
fun `calculate surveys endpoint accepts satellite and interval overrides`() {
val missionId = UUID.randomUUID()
val startedAt = LocalDateTime.of(2026, 4, 2, 10, 0)
val responseBody = MissionSurveyCalculationResponseDTO(
missionId = missionId,
completed = true,
startedAt = startedAt,
finishedAt = startedAt.plusSeconds(2),
durationMs = 2_000,
comPlanSnapshotId = 99L,
surveyModesCount = 3,
manualModesCount = 1,
complanModesCount = 1,
slotsModesCount = 1,
mixedModesCount = 0,
bookedSlotIdsCount = 4
)
doReturn(responseBody).`when`(missionPlaningService).loadSlots(missionId, comPlanSnapshotId = 99L)
val response = controller.calculateSurveys(
id = missionId,
comPlanSnapshotId = 99L
)
assertEquals(responseBody, response)
verify(missionPlaningService).loadSlots(missionId, comPlanSnapshotId = 99L)
}
@Test
fun `update survey mode status endpoint delegates to service`() {
val response = controller.updateSurveyModeStatus(
id = 15L,
body = SurveyModeStatusUpdateRequest(status = SurveyModeStatus.PROCESSED)
)
assertEquals(org.springframework.http.HttpStatus.NO_CONTENT, response.statusCode)
verify(missionPlaningService).updateSurveyModeStatus(15L, SurveyModeStatus.PROCESSED)
}
}
@@ -0,0 +1,20 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import jakarta.validation.Validation
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
class SurveyModeStatusUpdateRequestTest {
private val validator = Validation.buildDefaultValidatorFactory().validator
@Test
fun `validator accepts enum status field`() {
val violations = validator.validate(
SurveyModeStatusUpdateRequest(status = SurveyModeStatus.PROCESSED)
)
assertTrue(violations.isEmpty())
}
}
@@ -0,0 +1,63 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.apache.kafka.clients.producer.ProducerRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.springframework.beans.factory.ObjectProvider
import org.springframework.kafka.core.KafkaTemplate
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.util.UUID
class BookedSlotsEventPublisherTest {
private val objectMapper = jacksonObjectMapper().findAndRegisterModules()
private val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, Any>
private val publisher = BookedSlotsEventPublisher(
kafkaTemplateProvider = SingleObjectProvider(kafkaTemplate),
objectMapperProvider = SingleObjectProvider(objectMapper),
topic = "pcp.slots.status.v1",
applicationName = "pcp-mission-planing-service"
)
@Test
fun `publish sends booked slots lifecycle event to kafka`() {
val missionId = UUID.randomUUID()
publisher.publish(
bookedSlotIds = listOf(10L, 20L, 10L),
status = BookedSlotStatus.PROCESSED,
missionId = missionId,
modeId = 77L,
satelliteId = 88L
)
val captor = ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor<ProducerRecord<String, Any>>
verify(kafkaTemplate).send(captor.capture())
val record = captor.value
assertEquals("pcp.slots.status.v1", record.topic())
assertEquals(missionId.toString(), record.key())
val typeHeader = record.headers().lastHeader("type")
assertEquals(PcpKafkaEvent.BookedSlotsStatusChangedEvent.name, typeHeader.value().decodeToString())
val payload = objectMapper.readTree(record.value() as String)
assertEquals(PcpKafkaEvent.BookedSlotsStatusChangedEvent.name, payload.path("type").asText())
assertEquals("pcp-mission-planing-service", payload.path("data").path("sourceService").asText())
assertEquals("PROCESSED", payload.path("data").path("status").asText())
assertEquals(2, payload.path("data").path("bookedSlotIds").size())
assertTrue(payload.path("data").path("bookedSlotIds").map { it.asLong() }.containsAll(listOf(10L, 20L)))
}
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
override fun getObject(vararg args: Any?): T = value
override fun getIfAvailable(): T = value
override fun getIfUnique(): T = value
override fun getObject(): T = value
}
}
@@ -0,0 +1,459 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyList
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import space.nstart.pcp.pcp_types_lib.configuration.CustomErrorException
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeSnapshotResponseDTO
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository
import space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType
import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
import java.math.BigDecimal
class MissionPlaningServiceTest {
private val missionRepository = mock(MissionRepository::class.java)
private val modeRepository = mock(ModeRepository::class.java)
private val planRepository = mock(PlanRepository::class.java)
private val surveyModeRepository = mock(SurveyModeRepository::class.java)
private val dropModeRepository = mock(DropModeRepository::class.java)
private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java)
private val modeBookingRepository = mock(ModeBookingRepository::class.java)
private val bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java)
private val ballisticsService = mock(BallisticsService::class.java)
private val complexMissionService = mock(ComplexMissionService::class.java)
private val satelliteCatalogClient = mock(SatelliteCatalogClient::class.java)
private val defaultSatelliteMissionType = DefaultSatelliteMissionType()
private val satelliteMissionTypeFactory = SatelliteMissionTypeFactory(
listOf(defaultSatelliteMissionType, EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()),
defaultSatelliteMissionType
)
private val service = MissionPlaningService(
missionRepository = missionRepository,
modeRepository = modeRepository,
planRepository = planRepository,
surveyModeRepository = surveyModeRepository,
dropModeRepository = dropModeRepository,
dropModeSurveyRepository = dropModeSurveyRepository,
modeBookingRepository = modeBookingRepository,
bookedSlotsEventPublisher = bookedSlotsEventPublisher,
ballisticsService = ballisticsService,
complexMissionService = complexMissionService,
satelliteCatalogClient = satelliteCatalogClient,
satelliteMissionTypeFactory = satelliteMissionTypeFactory
)
@Test
fun `modesBySatelliteAndInterval returns only intersecting modes`() {
val mission = MissionEntity(
missionId = UUID.randomUUID(),
satelliteId = 77
)
val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1)
val intervalStart = LocalDateTime.of(2026, 3, 16, 10, 0, 0)
val intervalStop = LocalDateTime.of(2026, 3, 16, 10, 30, 0)
val overlappingSurvey = SurveyModeEntity(
id = 1L,
plan = plan,
timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC),
revolution = 10,
lat = 55.0,
long = 37.0,
duration = 600.0,
contourWkt = "POLYGON((0 0,1 0,1 1,0 0))",
roll = 2.0,
source = SurveyModeSource.SLOT
)
val inWindowSurvey = SurveyModeEntity(
id = 2L,
plan = plan,
timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC),
revolution = 11,
lat = 56.0,
long = 38.0,
duration = 300.0,
contourWkt = "POLYGON((0 0,1 0,1 1,0 0))",
roll = 1.0,
source = SurveyModeSource.SLOT
)
val inWindowDrop = DropModeEntity(
id = 3L,
plan = plan,
timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC),
revolution = 12,
station = "station-1",
duration = 120.0
)
`when`(
modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc(
77,
intervalStop.atOffset(ZoneOffset.UTC)
)
).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop))
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn(
listOf(
ModeBookingEntity(mode = overlappingSurvey, slotId = 1001L),
ModeBookingEntity(mode = overlappingSurvey, slotId = 1001L),
ModeBookingEntity(mode = inWindowSurvey, slotId = 2002L)
)
)
`when`(dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(3L)).thenReturn(
listOf(
DropModeSurveyEntity(
dropMode = inWindowDrop,
surveyMode = inWindowSurvey
)
)
)
val result = service.modesBySatelliteAndInterval(77, intervalStart, intervalStop)
assertEquals(listOf(1L, 2L, 3L), result.map { it.id })
assertEquals(listOf(1001L), (result[0] as SurveyModeResponseDTO).bookedSlotIds)
assertEquals(listOf(2002L), (result[1] as SurveyModeResponseDTO).bookedSlotIds)
assertEquals(listOf(2L), (result[2] as DropModeResponseDTO).surveys)
}
@Test
fun `modesBySatelliteAndInterval rejects inverted interval`() {
val exception = assertThrows(ResponseStatusException::class.java) {
service.modesBySatelliteAndInterval(
satelliteId = 77,
timeStart = LocalDateTime.of(2026, 3, 16, 10, 30, 0),
timeStop = LocalDateTime.of(2026, 3, 16, 10, 0, 0)
)
}
assertEquals(HttpStatus.BAD_REQUEST, exception.statusCode)
}
@Test
fun `findMode searches by satellite number revolution and time start with one second tolerance`() {
val routePassport = RoutePassportDto(
routeId = UUID.randomUUID(),
kaShort = "22",
kaFull = "SATELLITE-22",
routeNameFull = "ROUTE-001",
routeNameShort = "R-001",
orbitNumber = 321,
orbitState = "ASC",
intervalBegin = LocalDateTime.of(2026, 4, 3, 12, 0, 0),
intervalEnd = LocalDateTime.of(2026, 4, 3, 12, 5, 0),
rollAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE),
visirAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE),
resolutionRange = BigDecimal.ONE,
resolutionAzimuth = BigDecimal.ONE,
polarisation = listOf("VV"),
processingLevel = "L1",
geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))",
)
val exactMode = SurveyModeEntity(
id = 10L,
timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC),
revolution = 321
)
val nearMode = SurveyModeEntity(
id = 11L,
timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC),
revolution = 321
)
`when`(
surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween(
"22",
321,
OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC),
OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC)
)
).thenReturn(listOf(nearMode, exactMode))
val result = service.findMode(routePassport)
assertEquals(10L, result?.id)
}
@Test
fun `loadSlots reads persisted stage4 modes and preserves source bookings and traceability`() {
val missionId = UUID.randomUUID()
val missionStart = LocalDateTime.of(2026, 3, 18, 10, 0)
val missionStop = LocalDateTime.of(2026, 3, 18, 12, 0)
val mission = MissionEntity(
missionId = missionId,
satelliteId = 77,
missionStart = missionStart,
missionStop = missionStop
)
val plan = PlanEntity(id = 101L, mission = mission, planStart = missionStart, planStop = missionStop, planNumber = 1)
val stage4Mode = SatelliteModeResponseDTO(
id = 9001L,
satelliteId = 77,
source = "MANUAL",
startTime = missionStart.plusMinutes(5),
endTime = missionStart.plusMinutes(15),
revolution = 321,
lat = 55.0,
longitude = 37.0,
duration = 600.0,
contourWkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
roll = 4.0,
bookedSlotIds = listOf(501L, 502L),
snapshotId = 7001L
)
val snapshot = SatelliteModeSnapshotResponseDTO(
id = 7001L,
satelliteId = 77,
intervalStart = missionStart.minusHours(1),
intervalEnd = missionStop.plusHours(1),
createdAt = missionStart.minusHours(1),
isActive = true
)
var savedSurveyModes: List<SurveyModeEntity> = emptyList()
var savedModeBookings: List<ModeBookingEntity> = emptyList()
`when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission))
`when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)).thenReturn(java.util.Optional.of(plan))
`when`(satelliteCatalogClient.getSatellite(77L)).thenReturn(SatelliteDTO(id = 77L, typeCode = "Emissio"))
`when`(complexMissionService.activeSnapshot(77L)).thenReturn(snapshot)
`when`(complexMissionService.snapshotModes(snapshot.id, missionStart, missionStop)).thenReturn(listOf(stage4Mode))
`when`(surveyModeRepository.saveAll(anyList())).thenAnswer {
savedSurveyModes = it.arguments[0] as List<SurveyModeEntity>
savedSurveyModes
}
`when`(modeBookingRepository.saveAll(anyList())).thenAnswer {
savedModeBookings = it.arguments[0] as List<ModeBookingEntity>
savedModeBookings
}
val response = service.loadSlots(missionId)
verify(complexMissionService).activeSnapshot(77L)
verify(complexMissionService).snapshotModes(snapshot.id, missionStart, missionStop)
verifyNoInteractions(bookedSlotsEventPublisher)
val savedSurvey = savedSurveyModes.single()
assertEquals(SurveyModeSource.MANUAL, savedSurvey.source)
assertEquals(9001L, savedSurvey.satelliteModeId)
assertEquals(SurveyModeStatus.PLANNED, savedSurvey.status)
assertEquals(snapshot.id, plan.stage4SnapshotId)
assertEquals(listOf(501L, 502L), savedModeBookings.map { it.slotId })
assertEquals(true, response.completed)
assertEquals(snapshot.id, response.comPlanSnapshotId)
assertEquals(1, response.surveyModesCount)
assertEquals(1, response.manualModesCount)
assertEquals(0, response.complanModesCount)
assertEquals(0, response.slotsModesCount)
assertEquals(0, response.mixedModesCount)
assertEquals(2, response.bookedSlotIdsCount)
}
@Test
fun `loadSlots uses explicit satellite and interval overrides for stage5 calculation`() {
val missionId = UUID.randomUUID()
val mission = MissionEntity(
missionId = missionId,
satelliteId = 77,
missionStart = LocalDateTime.of(2026, 3, 19, 10, 0),
missionStop = LocalDateTime.of(2026, 3, 19, 12, 0)
)
val overrideStart = LocalDateTime.of(2026, 3, 20, 8, 0)
val overrideStop = LocalDateTime.of(2026, 3, 20, 9, 0)
val overrideSatelliteId = 88L
val plan = PlanEntity(id = 111L, mission = mission, planNumber = 1)
val snapshot = SatelliteModeSnapshotResponseDTO(
id = 7002L,
satelliteId = overrideSatelliteId,
intervalStart = overrideStart,
intervalEnd = overrideStop,
createdAt = overrideStart.minusHours(1),
isActive = true
)
`when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission))
`when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1))
.thenReturn(java.util.Optional.of(plan))
`when`(satelliteCatalogClient.getSatellite(overrideSatelliteId)).thenReturn(SatelliteDTO(id = overrideSatelliteId, typeCode = "Reflixio"))
`when`(complexMissionService.activeSnapshot(overrideSatelliteId)).thenReturn(snapshot)
`when`(complexMissionService.snapshotModes(snapshot.id, overrideStart, overrideStop)).thenReturn(emptyList())
val response = service.loadSlots(missionId, overrideSatelliteId, overrideStart, overrideStop)
verify(complexMissionService).activeSnapshot(overrideSatelliteId)
verify(complexMissionService).snapshotModes(snapshot.id, overrideStart, overrideStop)
verify(missionRepository).save(mission)
assertEquals(overrideSatelliteId, mission.satelliteId)
assertEquals(overrideStart, mission.missionStart)
assertEquals(overrideStop, mission.missionStop)
assertEquals(snapshot.id, plan.stage4SnapshotId)
assertEquals(snapshot.id, response.comPlanSnapshotId)
assertEquals(0, response.surveyModesCount)
verifyNoInteractions(bookedSlotsEventPublisher)
}
@Test
fun `loadSlots uses explicit com plan snapshot as primary source`() {
val missionId = UUID.randomUUID()
val missionStart = LocalDateTime.of(2026, 3, 21, 10, 0)
val missionStop = LocalDateTime.of(2026, 3, 21, 12, 0)
val mission = MissionEntity(
missionId = missionId,
satelliteId = 77,
missionStart = missionStart,
missionStop = missionStop
)
val plan = PlanEntity(id = 112L, mission = mission, planStart = missionStart, planStop = missionStop, planNumber = 1)
val snapshot = SatelliteModeSnapshotResponseDTO(
id = 7003L,
satelliteId = 77,
intervalStart = missionStart,
intervalEnd = missionStop,
createdAt = missionStart.minusHours(1),
isActive = false
)
`when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission))
`when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)).thenReturn(java.util.Optional.of(plan))
`when`(satelliteCatalogClient.getSatellite(77L)).thenReturn(SatelliteDTO(id = 77L, typeCode = ""))
`when`(complexMissionService.snapshot(snapshot.id)).thenReturn(snapshot)
`when`(complexMissionService.snapshotModes(snapshot.id, missionStart, missionStop)).thenReturn(emptyList())
val response = service.loadSlots(missionId, comPlanSnapshotId = snapshot.id)
verify(complexMissionService).snapshot(snapshot.id)
verify(complexMissionService).snapshotModes(snapshot.id, missionStart, missionStop)
verify(complexMissionService, never()).activeSnapshot(77L)
assertEquals(snapshot.id, plan.stage4SnapshotId)
assertEquals(snapshot.id, response.comPlanSnapshotId)
assertEquals(0, response.surveyModesCount)
}
@Test
fun `loadSlots fails when active stage4 snapshot is not found`() {
val missionId = UUID.randomUUID()
val missionStart = LocalDateTime.of(2026, 3, 22, 10, 0)
val missionStop = LocalDateTime.of(2026, 3, 22, 12, 0)
val mission = MissionEntity(
missionId = missionId,
satelliteId = 77,
missionStart = missionStart,
missionStop = missionStop
)
val plan = PlanEntity(id = 113L, mission = mission, planStart = missionStart, planStop = missionStop, planNumber = 1)
`when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission))
`when`(satelliteCatalogClient.getSatellite(77L)).thenReturn(SatelliteDTO(id = 77L, typeCode = "UNKNOWN"))
`when`(planRepository.findFirstByMissionMissionIdAndPlanNumber(missionId, 1)).thenReturn(java.util.Optional.of(plan))
`when`(complexMissionService.activeSnapshot(77L)).thenReturn(null)
val exception = assertThrows(CustomErrorException::class.java) {
service.loadSlots(missionId)
}
assertEquals(
"Active stage4 snapshot not found for satelliteId=77 and interval=[$missionStart - $missionStop]",
exception.message
)
verify(complexMissionService).activeSnapshot(77L)
}
@Test
fun `confirmMission publishes processed booked slots event`() {
val missionId = UUID.randomUUID()
val mission = MissionEntity(missionId = missionId, satelliteId = 77)
val surveyMode = SurveyModeEntity(id = 11L)
`when`(missionRepository.findById(missionId)).thenReturn(java.util.Optional.of(mission))
`when`(missionRepository.save(mission)).thenReturn(mission)
`when`(surveyModeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId))
.thenReturn(listOf(surveyMode))
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(11L)))
.thenReturn(listOf(ModeBookingEntity(mode = surveyMode, slotId = 1001L)))
val result = service.confirmMission(missionId)
assertEquals(listOf(1001L), result)
verify(bookedSlotsEventPublisher).publish(
listOf(1001L),
BookedSlotStatus.PROCESSED,
missionId,
null,
77L
)
}
@Test
fun `updateSurveyModeStatus publishes terminal booked slot event`() {
val missionId = UUID.randomUUID()
val mission = MissionEntity(missionId = missionId, satelliteId = 77)
val plan = PlanEntity(id = 101L, mission = mission, planNumber = 1)
val surveyMode = SurveyModeEntity(id = 99L, plan = plan, status = SurveyModeStatus.PLANNED)
`when`(surveyModeRepository.findById(99L)).thenReturn(java.util.Optional.of(surveyMode))
`when`(surveyModeRepository.save(surveyMode)).thenReturn(surveyMode)
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(99L)))
.thenReturn(listOf(ModeBookingEntity(mode = surveyMode, slotId = 501L)))
service.updateSurveyModeStatus(99L, SurveyModeStatus.PROCESSED)
assertEquals(SurveyModeStatus.PROCESSED, surveyMode.status)
verify(bookedSlotsEventPublisher).publish(
listOf(501L),
BookedSlotStatus.FINISHED,
missionId,
99L,
77L
)
}
@Test
fun `updateSurveyModeStatus without bookings does not publish event`() {
val surveyMode = SurveyModeEntity(id = 77L, status = SurveyModeStatus.PLANNED)
`when`(surveyModeRepository.findById(77L)).thenReturn(java.util.Optional.of(surveyMode))
`when`(surveyModeRepository.save(surveyMode)).thenReturn(surveyMode)
`when`(modeBookingRepository.findAllByMode_IdIn(listOf(77L))).thenReturn(emptyList())
service.updateSurveyModeStatus(77L, SurveyModeStatus.FAILED)
assertEquals(SurveyModeStatus.FAILED, surveyMode.status)
verifyNoInteractions(bookedSlotsEventPublisher)
}
}
@@ -0,0 +1,138 @@
package space.nstart.pcp.pcp_mission_planing_service.service
import ch.qos.logback.classic.Level
import ch.qos.logback.classic.Logger
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.ObjectProvider
import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
import space.nstart.pcp.pcp_types_lib.message.PcpKafkaEvent
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.UUID
class ModeStatusChangedKafkaListenerTest {
private val objectMapper = jacksonObjectMapper().findAndRegisterModules()
private val missionPlaningService = mock(MissionPlaningService::class.java)
private val listener = ModeStatusChangedKafkaListener(
objectMapperProvider = SingleObjectProvider(objectMapper),
missionPlaningService = missionPlaningService,
)
@Test
fun `consume finds mode and updates its status`() {
val routePassport = routePassportDto(routeStatus = SurveyModeStatus.PROCESSED)
val mode = SurveyModeEntity(id = 15L)
val appender = attachAppender()
val payload = objectMapper.writeValueAsString(
KafkaMessage(
type = PcpKafkaEvent.ModeStatusChangedEvent,
data = routePassport,
).apply {
source = "route-processing"
}
)
`when`(missionPlaningService.findMode(routePassport)).thenReturn(mode)
listener.consume(payload)
verify(missionPlaningService).findMode(routePassport)
verify(missionPlaningService).updateSurveyModeStatus(15L, SurveyModeStatus.PROCESSED)
val infoLog = appender.list.last { it.level == Level.INFO }
assertTrue(infoLog.formattedMessage.contains("Mode status changed event processed"))
assertTrue(infoLog.formattedMessage.contains("surveyModeId=15"))
}
@Test
fun `consume logs error when mode is not found`() {
val routePassport = routePassportDto(routeStatus = SurveyModeStatus.FAILED)
val appender = attachAppender()
val payload = objectMapper.writeValueAsString(
KafkaMessage(
type = PcpKafkaEvent.ModeStatusChangedEvent,
data = routePassport,
).apply {
source = "route-processing"
}
)
`when`(missionPlaningService.findMode(routePassport)).thenReturn(null)
listener.consume(payload)
verify(missionPlaningService).findMode(routePassport)
verify(missionPlaningService, never()).updateSurveyModeStatus(15L, SurveyModeStatus.FAILED)
val errorLog = appender.list.last { it.level == Level.ERROR }
assertTrue(errorLog.formattedMessage.contains("Survey mode not found for ModeStatusChangedEvent"))
assertTrue(errorLog.formattedMessage.contains("routeStatus=FAILED"))
}
@Test
fun `consume ignores unsupported kafka event type`() {
val appender = attachAppender()
val payload = objectMapper.writeValueAsString(
KafkaMessage(
type = PcpKafkaEvent.RouteGeoRefEvent,
data = routePassportDto(),
)
)
listener.consume(payload)
val debugLog = appender.list.last { it.level == Level.DEBUG }
assertEquals("Ignoring Kafka message with unsupported type RouteGeoRefEvent", debugLog.formattedMessage)
verifyNoInteractions(missionPlaningService)
}
private fun attachAppender(): ListAppender<ILoggingEvent> {
val logger = LoggerFactory.getLogger(ModeStatusChangedKafkaListener::class.java) as Logger
logger.detachAndStopAllAppenders()
val appender = ListAppender<ILoggingEvent>()
appender.start()
logger.addAppender(appender)
logger.level = Level.DEBUG
return appender
}
private fun routePassportDto(routeStatus: SurveyModeStatus = SurveyModeStatus.PROCESSED): RoutePassportDto =
RoutePassportDto(
routeId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
kaShort = "22",
kaFull = "SATELLITE-22",
routeNameFull = "ROUTE-001",
routeNameShort = "R-001",
orbitNumber = 42,
orbitState = "ASC",
intervalBegin = LocalDateTime.of(2026, 4, 3, 12, 0),
intervalEnd = LocalDateTime.of(2026, 4, 3, 12, 5),
rollAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE),
visirAngle = AngleRangeDto(BigDecimal.ZERO, BigDecimal.ONE),
resolutionRange = BigDecimal.ONE,
resolutionAzimuth = BigDecimal.ONE,
polarisation = listOf("VV"),
processingLevel = "L1",
geometry = "POLYGON ((0 0, 0 1, 1 1, 1 0, 0 0))",
routeStatus = routeStatus,
)
private class SingleObjectProvider<T : Any>(private val value: T) : ObjectProvider<T> {
override fun getObject(vararg args: Any?): T = value
override fun getIfAvailable(): T = value
override fun getIfUnique(): T = value
override fun getObject(): T = value
}
}
@@ -0,0 +1,34 @@
package space.nstart.pcp.pcp_mission_planing_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.pcp_mission_planing_service.repository.MissionRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository
import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository
class SatelliteDeletedServiceTest {
@Test
fun `deleteSatelliteData removes missions and cascaded plans and modes`() {
val missionRepository = mock(MissionRepository::class.java)
val planRepository = mock(PlanRepository::class.java)
val modeRepository = mock(ModeRepository::class.java)
val service = SatelliteDeletedService(missionRepository, planRepository, modeRepository)
`when`(missionRepository.countBySatelliteId(56756L)).thenReturn(2L)
`when`(planRepository.countByMissionSatelliteId(56756L)).thenReturn(3L)
`when`(modeRepository.countByPlan_Mission_SatelliteId(56756L)).thenReturn(5L)
`when`(missionRepository.deleteAllBySatelliteId(56756L)).thenReturn(2L)
val summary = service.deleteSatelliteData(56756L)
assertEquals(56756L, summary.satelliteId)
assertEquals(2L, summary.missionsDeleted)
assertEquals(3L, summary.plansDeletedByCascade)
assertEquals(5L, summary.modesDeletedByCascade)
verify(missionRepository).deleteAllBySatelliteId(56756L)
}
}
@@ -0,0 +1,35 @@
package space.nstart.pcp.pcp_mission_planing_service.service.satellite_type
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO
class SatelliteMissionTypeFactoryTest {
private val defaultType = DefaultSatelliteMissionType()
private val emissioType = EmissioSatelliteMissionType()
private val reflixioType = ReflixioSatelliteMissionType()
private val factory = SatelliteMissionTypeFactory(
missionTypes = listOf(defaultType, emissioType, reflixioType),
defaultMissionType = defaultType
)
@Test
fun `returns Emissio type by satellite dto typeCode ignoring case`() {
val type = factory.getBySatellite(SatelliteDTO(id = 1, typeCode = "Emissio"))
assertSame(emissioType, type)
}
@Test
fun `returns Reflixio type by satellite dto typeCode ignoring case`() {
val type = factory.getBySatellite(SatelliteDTO(id = 2, typeCode = "reflixio"))
assertSame(reflixioType, type)
}
@Test
fun `returns default type for blank or unknown typeCode`() {
assertSame(defaultType, factory.getBySatellite(SatelliteDTO(id = 3, typeCode = "")))
assertSame(defaultType, factory.getBySatellite(SatelliteDTO(id = 4, typeCode = "unknown")))
}
}
@@ -0,0 +1,29 @@
spring:
config:
import: "optional:configserver:"
cloud:
config:
enabled: false
datasource:
url: jdbc:h2:mem:pcp_mission_planing;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
flyway:
enabled: false
camunda:
client:
enabled: false
settings:
ballistics-service: http://localhost:7003
complex-mission-service: http://localhost:7002
satellite-catalog-service: http://localhost:7013
stations-service: http://localhost:7009