Ручное сохранение маршрутов съёмки и сбросов в плане

Эндпоинты по 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:
Дмитрий Соловьев
2026-06-01 22:17:20 +03:00
parent fafaeed418
commit ef97ebbcf7
5 changed files with 496 additions and 1 deletions
@@ -20,8 +20,10 @@ 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.DropRouteSaveRequestDTO
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.SurveyRouteBuildRequestDTO
import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService
import java.time.LocalDateTime
import java.util.UUID
@@ -34,6 +36,54 @@ class MissionController(
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 = "Получить список миссий")
@GetMapping
fun all() = missionPlaningService.all()
@@ -24,6 +24,20 @@ interface ModeRepository : JpaRepository<ModeEntity, Long> {
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true)
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
@Transactional
@Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true)
@@ -20,11 +20,15 @@ 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.DropRouteSaveRequestDTO
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_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.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
@@ -349,7 +353,12 @@ class MissionPlaningService(
survs.count { it.bookedSlotIds.isNotEmpty() }
)
modeRepository.deleteSurveyModesByPlanId(planId)
val removedRecalculable = modeRepository.deleteRecalculableSurveyModesByPlanId(planId)
logger.info(
"Пересчёт плана {}: удалено {} пересчитываемых маршрутов съёмки, ручные (source=MANUAL) сохранены",
planId,
removedRecalculable
)
if (survs.isEmpty()) {
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(
stepper: AbstractStepper,
tn: LocalDateTime,