Init
This commit is contained in:
@@ -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()
|
||||
}
|
||||
+13
@@ -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)
|
||||
}
|
||||
+88
@@ -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())
|
||||
}
|
||||
+181
@@ -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()
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
)
|
||||
+23
@@ -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)
|
||||
+26
@@ -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
|
||||
)
|
||||
+31
@@ -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"
|
||||
)
|
||||
+26
@@ -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
|
||||
)
|
||||
+36
@@ -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
|
||||
)
|
||||
+39
@@ -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
|
||||
)
|
||||
+49
@@ -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)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package space.nstart.pcp.pcp_mission_planing_service.entity
|
||||
|
||||
enum class SurveyModeSource {
|
||||
USER,
|
||||
SLOT,
|
||||
HEAT_MAP,
|
||||
SLOTS,
|
||||
COMPLAN,
|
||||
MIXED,
|
||||
MANUAL
|
||||
}
|
||||
+8
@@ -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>
|
||||
}
|
||||
+10
@@ -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>
|
||||
}
|
||||
+10
@@ -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
|
||||
}
|
||||
+10
@@ -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>
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
+13
@@ -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
|
||||
}
|
||||
+32
@@ -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>
|
||||
}
|
||||
+85
@@ -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()
|
||||
|
||||
|
||||
}
|
||||
+93
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+100
@@ -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)
|
||||
}
|
||||
}
|
||||
+135
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+781
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
+73
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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()
|
||||
}
|
||||
+48
@@ -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
|
||||
)
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
+19
@@ -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"
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
+41
@@ -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
|
||||
);
|
||||
+15
@@ -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);
|
||||
+9
@@ -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);
|
||||
+5
@@ -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);
|
||||
+5
@@ -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);
|
||||
+9
@@ -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'));
|
||||
+5
@@ -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);
|
||||
+24
@@ -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)
|
||||
}
|
||||
}
|
||||
+58
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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())
|
||||
}
|
||||
}
|
||||
+63
@@ -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
|
||||
}
|
||||
}
|
||||
+459
@@ -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)
|
||||
}
|
||||
}
|
||||
+138
@@ -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
|
||||
}
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
Reference in New Issue
Block a user