Ручное сохранение маршрутов съёмки и сбросов в плане
Эндпоинты по 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:
+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.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()
|
||||
|
||||
+14
@@ -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)
|
||||
|
||||
+309
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user