Ручное сохранение маршрутов съёмки и сбросов в плане
Эндпоинты по id выбранного плана (= mission_id):
- POST /api/missions/{plan_id}/survey-route — строит контур и сохраняет
SurveyModeEntity (source=MANUAL, status=PLANNED);
- POST /api/missions/{plan_id}/drop-route — ручной сброс по выбранной
зоне радиовидимости: валидирует, что съёмки принадлежат плану и
заканчиваются строго до начала зоны, а сумма длительностей не
превышает длительность зоны; пишет DropModeEntity + связи
DropModeSurveyEntity.
Миссия/план (planNumber=1) создаются на лету, если их ещё нет, с
интервалом из planStart/planEnd запроса. loadSlots чистит только
пересчитываемые маршруты (deleteRecalculableSurveyModesByPlanId),
поэтому ручные маршруты съёмки переживают пересчёт. Новые DTO —
DropRouteSaveRequestDTO и SurveyRouteBuildDTOs в pcp-types-lib.
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запрос на ручное добавление сброса в план по выбранной зоне радиовидимости (ЗРВ).
|
||||||
|
*
|
||||||
|
* Оператор выбирает ЗРВ станции и набор уже запланированных маршрутов съёмки, время конца
|
||||||
|
* которых строго меньше начала зоны (т.е. съёмка успевает завершиться до сеанса сброса).
|
||||||
|
* Сброс сохраняется как [space.nstart.pcp.pcp_mission_planing_service]-режим DROP под основным
|
||||||
|
* планом миссии (planNumber = 1) с теми же связями съёмка↔сброс, что и при штатном расчёте.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Запрос на ручное добавление сброса по выбранной зоне радиовидимости")
|
||||||
|
data class DropRouteSaveRequestDTO(
|
||||||
|
@field:Schema(description = "Идентификатор КА (NORAD)")
|
||||||
|
val satelliteId: Long = 0,
|
||||||
|
|
||||||
|
@field:Schema(description = "Идентификатор/номер станции приёма (из ЗРВ)")
|
||||||
|
val station: String = "",
|
||||||
|
|
||||||
|
@field:Schema(description = "Номер витка зоны радиовидимости")
|
||||||
|
val revolution: Long = 0,
|
||||||
|
|
||||||
|
@field:Schema(description = "Начало зоны радиовидимости (локальное время без зоны)")
|
||||||
|
val zoneStart: LocalDateTime = LocalDateTime.now(),
|
||||||
|
|
||||||
|
@field:Schema(description = "Конец зоны радиовидимости (локальное время без зоны)")
|
||||||
|
val zoneStop: LocalDateTime = LocalDateTime.now(),
|
||||||
|
|
||||||
|
@field:Schema(description = "Идентификаторы режимов съёмки (survey_modes), которые сбрасываются в этой зоне")
|
||||||
|
val surveyModeIds: List<Long> = emptyList(),
|
||||||
|
|
||||||
|
@field:Schema(
|
||||||
|
description = "Начало интервала плана (локальное время без зоны). Используется только при " +
|
||||||
|
"сохранении, если миссии/плана с таким id ещё нет — чтобы создать их с корректным окном.",
|
||||||
|
nullable = true
|
||||||
|
)
|
||||||
|
val planStart: LocalDateTime? = null,
|
||||||
|
|
||||||
|
@field:Schema(
|
||||||
|
description = "Конец интервала плана (локальное время без зоны). См. planStart.",
|
||||||
|
nullable = true
|
||||||
|
)
|
||||||
|
val planEnd: LocalDateTime? = null
|
||||||
|
)
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema
|
||||||
|
import jakarta.validation.constraints.DecimalMin
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Способ построения временного окна маршрута относительно момента видимости.
|
||||||
|
*/
|
||||||
|
enum class SurveyRouteAnchor {
|
||||||
|
/** Окно центрируется на моменте видимости: [time - duration/2, time + duration/2]. */
|
||||||
|
CENTERED,
|
||||||
|
|
||||||
|
/** Момент видимости — начало съёмки: [time, time + duration]. */
|
||||||
|
FORWARD
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(description = "Запрос на построение одного маршрута съёмки по параметрам видимости точки")
|
||||||
|
data class SurveyRouteBuildRequestDTO(
|
||||||
|
@field:Schema(description = "Идентификатор КА (тот же, что используется в obj-view/mpl-point — NORAD)")
|
||||||
|
val satelliteId: Long = 0,
|
||||||
|
|
||||||
|
@field:Schema(description = "Момент видимости (локальное время без зоны)")
|
||||||
|
val time: LocalDateTime = LocalDateTime.now(),
|
||||||
|
|
||||||
|
@field:DecimalMin(value = "1", message = "Длительность съёмки должна быть не меньше 1 секунды")
|
||||||
|
@field:Schema(description = "Длительность съёмки, секунды")
|
||||||
|
val durationSeconds: Double = 60.0,
|
||||||
|
|
||||||
|
@field:Schema(description = "Крен (gamma), градусы")
|
||||||
|
val roll: Double = 0.0,
|
||||||
|
|
||||||
|
@field:Schema(description = "Угол захвата (полуширина полосы), градусы")
|
||||||
|
val captureAngle: Double = 1.5,
|
||||||
|
|
||||||
|
@field:Schema(description = "Номер витка (0 — определить по орбитальным точкам)")
|
||||||
|
val revolution: Long = 0,
|
||||||
|
|
||||||
|
@field:Schema(description = "Способ построения окна относительно момента видимости")
|
||||||
|
val anchor: SurveyRouteAnchor = SurveyRouteAnchor.CENTERED,
|
||||||
|
|
||||||
|
@field:Schema(
|
||||||
|
description = "Начало интервала плана (локальное время без зоны). Используется только при " +
|
||||||
|
"сохранении маршрута, если миссии/плана с таким id ещё нет — чтобы создать их с " +
|
||||||
|
"корректным окном (для расчёта пересечений и наложений). Игнорируется в /build.",
|
||||||
|
nullable = true
|
||||||
|
)
|
||||||
|
val planStart: LocalDateTime? = null,
|
||||||
|
|
||||||
|
@field:Schema(
|
||||||
|
description = "Конец интервала плана (локальное время без зоны). См. planStart.",
|
||||||
|
nullable = true
|
||||||
|
)
|
||||||
|
val planEnd: LocalDateTime? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Schema(description = "Построенный маршрут съёмки")
|
||||||
|
data class SurveyRouteBuildResponseDTO(
|
||||||
|
val satelliteId: Long,
|
||||||
|
@field:Schema(description = "Начало съёмки (локальное время без зоны)")
|
||||||
|
val timeStart: LocalDateTime,
|
||||||
|
@field:Schema(description = "Длительность съёмки, секунды")
|
||||||
|
val duration: Double,
|
||||||
|
val revolution: Long,
|
||||||
|
@field:Schema(description = "Крен (gamma), градусы")
|
||||||
|
val roll: Double,
|
||||||
|
@field:Schema(description = "Угол захвата, градусы")
|
||||||
|
val captureAngle: Double,
|
||||||
|
@field:Schema(description = "Широта центра контура, градусы")
|
||||||
|
val lat: Double,
|
||||||
|
@field:Schema(description = "Долгота центра контура, градусы")
|
||||||
|
val longitude: Double,
|
||||||
|
@field:Schema(description = "Контур полосы съёмки, WKT POLYGON (lon lat, ...)")
|
||||||
|
val contourWkt: String,
|
||||||
|
@field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)")
|
||||||
|
val surveyModeId: Long? = null
|
||||||
|
)
|
||||||
+50
@@ -20,8 +20,10 @@ import org.springframework.web.bind.annotation.RequestMapping
|
|||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
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.ModeResponseDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
|
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_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
|
||||||
import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService
|
import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -34,6 +36,54 @@ class MissionController(
|
|||||||
private val missionPlaningService: MissionPlaningService
|
private val missionPlaningService: MissionPlaningService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Построить один маршрут съёмки",
|
||||||
|
description = "Строит контур полосы съёмки по параметрам видимости точки (момент, крен, угол захвата). " +
|
||||||
|
"Орбитальные точки берутся из pcp-ballistics-service, контур — тем же алгоритмом, что и при расчёте плана съёмки. " +
|
||||||
|
"Маршрут не сохраняется в БД, только возвращается клиенту (live-превью)."
|
||||||
|
)
|
||||||
|
@PostMapping("/survey-route/build")
|
||||||
|
fun buildSurveyRoute(@Valid @RequestBody body: SurveyRouteBuildRequestDTO) =
|
||||||
|
missionPlaningService.buildSurveyRoute(body)
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Построить и сохранить маршрут съёмки по выбранному плану",
|
||||||
|
description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " +
|
||||||
|
"и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " +
|
||||||
|
"planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " +
|
||||||
|
"Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса."
|
||||||
|
)
|
||||||
|
@ApiResponses(
|
||||||
|
value = [
|
||||||
|
ApiResponse(responseCode = "200", description = "Маршрут построен и сохранён")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@PostMapping("/{plan_id}/survey-route")
|
||||||
|
fun saveSurveyRoute(
|
||||||
|
@Parameter(description = "Идентификатор выбранного плана (= mission_id)")
|
||||||
|
@PathVariable("plan_id") planId: UUID,
|
||||||
|
@Valid @RequestBody body: SurveyRouteBuildRequestDTO
|
||||||
|
) = missionPlaningService.saveSurveyRoute(planId, body)
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Добавить сброс вручную по выбранной зоне радиовидимости",
|
||||||
|
description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " +
|
||||||
|
"и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " +
|
||||||
|
"planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " +
|
||||||
|
"Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса."
|
||||||
|
)
|
||||||
|
@ApiResponses(
|
||||||
|
value = [
|
||||||
|
ApiResponse(responseCode = "200", description = "Сброс добавлен")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@PostMapping("/{plan_id}/drop-route")
|
||||||
|
fun saveDropRoute(
|
||||||
|
@Parameter(description = "Идентификатор выбранного плана (= mission_id)")
|
||||||
|
@PathVariable("plan_id") planId: UUID,
|
||||||
|
@Valid @RequestBody body: DropRouteSaveRequestDTO
|
||||||
|
) = missionPlaningService.saveDropRoute(planId, body)
|
||||||
|
|
||||||
@Operation(summary = "Получить список миссий")
|
@Operation(summary = "Получить список миссий")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
fun all() = missionPlaningService.all()
|
fun all() = missionPlaningService.all()
|
||||||
|
|||||||
+14
@@ -24,6 +24,20 @@ interface ModeRepository : JpaRepository<ModeEntity, Long> {
|
|||||||
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true)
|
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true)
|
||||||
fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int
|
fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет только пересчитываемые маршруты съёмки плана, сохраняя ручные
|
||||||
|
* (source = 'MANUAL'). Используется при пересчёте плана (этап 5, loadSlots),
|
||||||
|
* чтобы не затирать добавленные оператором съёмки.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query(
|
||||||
|
"DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY' " +
|
||||||
|
"AND id NOT IN (SELECT id FROM survey_modes WHERE source = 'MANUAL')",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
fun deleteRecalculableSurveyModesByPlanId(@Param("planId") planId: Long): Int
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
@Transactional
|
@Transactional
|
||||||
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true)
|
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true)
|
||||||
|
|||||||
+309
-1
@@ -20,11 +20,15 @@ import org.springframework.stereotype.Service
|
|||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import org.springframework.web.server.ResponseStatusException
|
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.DropModeResponseDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO
|
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.MissionResponseDTO
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO
|
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.ModeResponseDTO
|
||||||
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO
|
||||||
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
|
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.DropModeSurveyEntity
|
||||||
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
|
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
|
||||||
@@ -349,7 +353,12 @@ class MissionPlaningService(
|
|||||||
survs.count { it.bookedSlotIds.isNotEmpty() }
|
survs.count { it.bookedSlotIds.isNotEmpty() }
|
||||||
)
|
)
|
||||||
|
|
||||||
modeRepository.deleteSurveyModesByPlanId(planId)
|
val removedRecalculable = modeRepository.deleteRecalculableSurveyModesByPlanId(planId)
|
||||||
|
logger.info(
|
||||||
|
"Пересчёт плана {}: удалено {} пересчитываемых маршрутов съёмки, ручные (source=MANUAL) сохранены",
|
||||||
|
planId,
|
||||||
|
removedRecalculable
|
||||||
|
)
|
||||||
|
|
||||||
if (survs.isEmpty()) {
|
if (survs.isEmpty()) {
|
||||||
val finishedAt = LocalDateTime.now()
|
val finishedAt = LocalDateTime.now()
|
||||||
@@ -499,6 +508,305 @@ class MissionPlaningService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Построение одного маршрута съёмки по параметрам видимости точки
|
||||||
|
* (используется вкладкой «Редактор» в pcp-tgu-ops-ui для построения включений съёмок).
|
||||||
|
*
|
||||||
|
* Stateless: орбитальные точки берутся из pcp-ballistics-service (extract-time), контур
|
||||||
|
* полосы строится тем же алгоритмом [contour], что и при штатном расчёте маршрутов
|
||||||
|
* (этап 5, [loadSlots] → snapshot этапа 4). Маршрут только возвращается клиенту для
|
||||||
|
* отображения на картооснове (live-превью при выборе момента видимости), без персистенции.
|
||||||
|
* Сохранение построенного маршрута выполняет [saveSurveyRoute].
|
||||||
|
*/
|
||||||
|
fun buildSurveyRoute(req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO =
|
||||||
|
buildContour(req).toResponse(surveyModeId = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Построение и сохранение одного маршрута съёмки в БД по идентификатору выбранного плана
|
||||||
|
* в сервисе pcp-tgu-service.
|
||||||
|
*
|
||||||
|
* Идентификатор плана (`planId` из PlannedPlanEntity в pcp-tgu-service) совпадает с
|
||||||
|
* `MissionEntity.missionId` в этом сервисе (см. BPMN createSatelliteMission: переменная
|
||||||
|
* `satellitePlanId` → `MissionRequestDTO.missionId`). Маршрут сохраняется как
|
||||||
|
* [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии
|
||||||
|
* (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана.
|
||||||
|
*
|
||||||
|
* Если миссии/плана с таким идентификатором ещё нет (план не рассчитывался), они
|
||||||
|
* создаются на лету по параметрам запроса: `missionId = planId`, `satelliteId` из запроса
|
||||||
|
* (NORAD), интервал — окно построенного маршрута. При последующем штатном расчёте
|
||||||
|
* [loadSlots] интервал миссии будет уточнён.
|
||||||
|
*
|
||||||
|
* Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только
|
||||||
|
* пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
|
||||||
|
val built = buildContour(req)
|
||||||
|
|
||||||
|
val mission = missionRepository.findById(planId).orElseGet {
|
||||||
|
// Интервал плана берём из запроса (фронт знает окно выбранного плана). Если не передан —
|
||||||
|
// фолбэк на окно самого маршрута; при штатном расчёте [loadSlots] интервал будет уточнён.
|
||||||
|
val routeStop = built.timeStart.plusNanos((built.durationSeconds * 1_000_000_000L).toLong())
|
||||||
|
val missionStart = req.planStart ?: built.timeStart
|
||||||
|
val missionStop = req.planEnd ?: routeStop
|
||||||
|
logger.info(
|
||||||
|
"Миссия/план {} не найдены — создаём под ручной маршрут: satelliteId={}, interval=[{} - {}] (источник интервала: {})",
|
||||||
|
planId,
|
||||||
|
req.satelliteId,
|
||||||
|
missionStart,
|
||||||
|
missionStop,
|
||||||
|
if (req.planStart != null && req.planEnd != null) "план" else "окно маршрута"
|
||||||
|
)
|
||||||
|
missionRepository.save(
|
||||||
|
MissionEntity(
|
||||||
|
missionId = planId,
|
||||||
|
satelliteId = req.satelliteId,
|
||||||
|
missionStart = missionStart,
|
||||||
|
missionStop = missionStop,
|
||||||
|
status = "NEW"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
|
||||||
|
.orElseGet {
|
||||||
|
planRepository.save(
|
||||||
|
PlanEntity(
|
||||||
|
mission = mission,
|
||||||
|
planStart = mission.missionStart,
|
||||||
|
planStop = mission.missionStop,
|
||||||
|
planNumber = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val saved = surveyModeRepository.save(
|
||||||
|
SurveyModeEntity(
|
||||||
|
plan = plan,
|
||||||
|
timeStart = built.timeStart.atOffset(ZoneOffset.UTC),
|
||||||
|
revolution = built.revolution,
|
||||||
|
lat = built.lat,
|
||||||
|
long = built.longitude,
|
||||||
|
duration = built.durationSeconds,
|
||||||
|
contourWkt = built.contourWkt,
|
||||||
|
roll = built.roll,
|
||||||
|
status = SurveyModeStatus.PLANNED,
|
||||||
|
source = SurveyModeSource.MANUAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Сохранён ручной маршрут съёмки (survey_mode id={}) для плана/миссии {}: revolution={}, timeStart={}, duration={}",
|
||||||
|
saved.id,
|
||||||
|
planId,
|
||||||
|
built.revolution,
|
||||||
|
built.timeStart,
|
||||||
|
built.durationSeconds
|
||||||
|
)
|
||||||
|
|
||||||
|
return built.toResponse(surveyModeId = saved.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ручное добавление сброса в план по выбранной оператором зоне радиовидимости (ЗРВ).
|
||||||
|
*
|
||||||
|
* Оператор на вкладке «Редактор» выбирает ЗРВ станции (из pcp-ballistics) и набор уже
|
||||||
|
* запланированных маршрутов съёмки, время конца которых строго меньше начала зоны. Сброс
|
||||||
|
* сохраняется как [DropModeEntity] под основным планом миссии (planNumber = 1) со связями
|
||||||
|
* съёмка↔сброс ([DropModeSurveyEntity]) — той же моделью, что и при штатном расчёте
|
||||||
|
* ([calculateDrops]).
|
||||||
|
*
|
||||||
|
* Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute).
|
||||||
|
* Если миссии/плана с таким id ещё нет — создаются на лету по параметрам запроса (окно плана
|
||||||
|
* из `planStart/planEnd`, фолбэк — окно зоны).
|
||||||
|
*
|
||||||
|
* Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все
|
||||||
|
* сбросы плана ([ModeRepository.deleteDropModesByPlanId]).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun saveDropRoute(planId: UUID, req: DropRouteSaveRequestDTO): DropModeResponseDTO {
|
||||||
|
if (req.surveyModeIds.isEmpty()) {
|
||||||
|
throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса")
|
||||||
|
}
|
||||||
|
|
||||||
|
val mission = missionRepository.findById(planId).orElseGet {
|
||||||
|
val missionStart = req.planStart ?: req.zoneStart
|
||||||
|
val missionStop = req.planEnd ?: req.zoneStop
|
||||||
|
logger.info(
|
||||||
|
"Миссия/план {} не найдены — создаём под ручной сброс: satelliteId={}, interval=[{} - {}] (источник интервала: {})",
|
||||||
|
planId,
|
||||||
|
req.satelliteId,
|
||||||
|
missionStart,
|
||||||
|
missionStop,
|
||||||
|
if (req.planStart != null && req.planEnd != null) "план" else "окно зоны"
|
||||||
|
)
|
||||||
|
missionRepository.save(
|
||||||
|
MissionEntity(
|
||||||
|
missionId = planId,
|
||||||
|
satelliteId = req.satelliteId,
|
||||||
|
missionStart = missionStart,
|
||||||
|
missionStop = missionStop,
|
||||||
|
status = "NEW"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
|
||||||
|
.orElseGet {
|
||||||
|
planRepository.save(
|
||||||
|
PlanEntity(
|
||||||
|
mission = mission,
|
||||||
|
planStart = mission.missionStart,
|
||||||
|
planStop = mission.missionStop,
|
||||||
|
planNumber = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Грузим выбранные съёмки и валидируем: принадлежат этому плану и заканчиваются строго
|
||||||
|
// до начала зоны (съёмка успевает завершиться к сеансу сброса).
|
||||||
|
val surveys = surveyModeRepository.findAllById(req.surveyModeIds)
|
||||||
|
val foundIds = surveys.mapNotNull { it.id }.toSet()
|
||||||
|
val missing = req.surveyModeIds.filterNot { it in foundIds }
|
||||||
|
if (missing.isNotEmpty()) {
|
||||||
|
throw CustomErrorException("Маршруты съёмки не найдены: $missing")
|
||||||
|
}
|
||||||
|
surveys.forEach { survey ->
|
||||||
|
if (survey.plan?.id != plan.id) {
|
||||||
|
throw CustomErrorException("Маршрут съёмки ${survey.id} не относится к плану $planId")
|
||||||
|
}
|
||||||
|
val start = survey.timeStart
|
||||||
|
?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала")
|
||||||
|
val surveyEnd = start.toLocalDateTime()
|
||||||
|
.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong())
|
||||||
|
if (!surveyEnd.isBefore(req.zoneStart)) {
|
||||||
|
throw CustomErrorException(
|
||||||
|
"Маршрут съёмки ${survey.id} заканчивается ($surveyEnd) не раньше начала зоны (${req.zoneStart}) — сброс невозможен"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Длительность сброса — сумма длительностей сброса по каждой съёмке (как в calculateDrops).
|
||||||
|
val usedDuration = surveys.sumOf { calculateDropDuration(it).coerceAtLeast(0.0) }
|
||||||
|
val zoneDuration = Duration.between(req.zoneStart, req.zoneStop).toMillis().toDouble() / 1000.0
|
||||||
|
if (zoneDuration > 0.0 && usedDuration > zoneDuration) {
|
||||||
|
throw CustomErrorException(
|
||||||
|
"Суммарная длительность сброса ($usedDuration с) превышает длительность зоны ($zoneDuration с)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dropMode = dropModeRepository.save(
|
||||||
|
DropModeEntity(
|
||||||
|
plan = plan,
|
||||||
|
timeStart = req.zoneStart.atOffset(ZoneOffset.UTC),
|
||||||
|
revolution = req.revolution,
|
||||||
|
station = req.station,
|
||||||
|
duration = usedDuration
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val links = surveys.map { survey -> DropModeSurveyEntity(dropMode = dropMode, surveyMode = survey) }
|
||||||
|
dropModeSurveyRepository.saveAll(links)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Сохранён ручной сброс (drop_mode id={}) для плана/миссии {}: станция={}, виток={}, зона={}, съёмок={}, длительность={}",
|
||||||
|
dropMode.id, planId, req.station, req.revolution, req.zoneStart, surveys.size, usedDuration
|
||||||
|
)
|
||||||
|
|
||||||
|
return DropModeResponseDTO(
|
||||||
|
id = dropMode.id ?: 0,
|
||||||
|
planId = plan.id ?: 0,
|
||||||
|
timeStart = dropMode.timeStart,
|
||||||
|
revolution = dropMode.revolution,
|
||||||
|
station = dropMode.station,
|
||||||
|
duration = dropMode.duration,
|
||||||
|
surveys = surveys.mapNotNull { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит контур полосы съёмки по параметрам видимости точки.
|
||||||
|
* Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute].
|
||||||
|
*/
|
||||||
|
private fun buildContour(req: SurveyRouteBuildRequestDTO): BuiltSurveyRoute {
|
||||||
|
if (req.durationSeconds <= 0.0) {
|
||||||
|
throw CustomErrorException("Длительность съёмки должна быть положительной")
|
||||||
|
}
|
||||||
|
|
||||||
|
val halfNanos = (req.durationSeconds / 2.0 * 1_000_000_000L).toLong()
|
||||||
|
val fullNanos = (req.durationSeconds * 1_000_000_000L).toLong()
|
||||||
|
val (tn, tk) = when (req.anchor) {
|
||||||
|
SurveyRouteAnchor.CENTERED -> req.time.minusNanos(halfNanos) to req.time.plusNanos(halfNanos)
|
||||||
|
SurveyRouteAnchor.FORWARD -> req.time to req.time.plusNanos(fullNanos)
|
||||||
|
}
|
||||||
|
|
||||||
|
val orbPoints = ballisticsService.exactTime(
|
||||||
|
req.satelliteId,
|
||||||
|
ExactTimePositionRequestDTO(time = tn, timeStop = tk, stepMs = 5000)
|
||||||
|
).toList()
|
||||||
|
|
||||||
|
if (orbPoints.isEmpty()) {
|
||||||
|
throw CustomErrorException(
|
||||||
|
"Нет орбитальных точек для КА ${req.satelliteId} на интервале [$tn - $tk]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val stepper = RungeStepper(
|
||||||
|
orbPoints.map { p ->
|
||||||
|
OrbitalPoint(
|
||||||
|
t = fromDateTime(p.time),
|
||||||
|
vit = p.revolution.toInt(),
|
||||||
|
r = Vector3D(p.x, p.y, p.z),
|
||||||
|
v = Vector3D(p.vx, p.vy, p.vz)
|
||||||
|
)
|
||||||
|
}.toMutableList(),
|
||||||
|
EarthType.PZ90d02
|
||||||
|
)
|
||||||
|
|
||||||
|
val wkt = contour(stepper, tn, tk, req.roll, req.captureAngle)
|
||||||
|
val (lat, lon) = extractCenterLatLon(wkt)
|
||||||
|
val revolution = if (req.revolution != 0L) req.revolution else orbPoints.first().revolution
|
||||||
|
|
||||||
|
return BuiltSurveyRoute(
|
||||||
|
satelliteId = req.satelliteId,
|
||||||
|
timeStart = tn,
|
||||||
|
durationSeconds = req.durationSeconds,
|
||||||
|
revolution = revolution,
|
||||||
|
roll = req.roll,
|
||||||
|
captureAngle = req.captureAngle,
|
||||||
|
lat = lat,
|
||||||
|
longitude = lon,
|
||||||
|
contourWkt = wkt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Результат расчёта контура одного маршрута съёмки (до сохранения в БД). */
|
||||||
|
private data class BuiltSurveyRoute(
|
||||||
|
val satelliteId: Long,
|
||||||
|
val timeStart: LocalDateTime,
|
||||||
|
val durationSeconds: Double,
|
||||||
|
val revolution: Long,
|
||||||
|
val roll: Double,
|
||||||
|
val captureAngle: Double,
|
||||||
|
val lat: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val contourWkt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun BuiltSurveyRoute.toResponse(surveyModeId: Long?): SurveyRouteBuildResponseDTO =
|
||||||
|
SurveyRouteBuildResponseDTO(
|
||||||
|
satelliteId = satelliteId,
|
||||||
|
timeStart = timeStart,
|
||||||
|
duration = durationSeconds,
|
||||||
|
revolution = revolution,
|
||||||
|
roll = roll,
|
||||||
|
captureAngle = captureAngle,
|
||||||
|
lat = lat,
|
||||||
|
longitude = longitude,
|
||||||
|
contourWkt = contourWkt,
|
||||||
|
surveyModeId = surveyModeId
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
private fun contour(
|
private fun contour(
|
||||||
stepper: AbstractStepper,
|
stepper: AbstractStepper,
|
||||||
tn: LocalDateTime,
|
tn: LocalDateTime,
|
||||||
|
|||||||
Reference in New Issue
Block a user