pcp-mission-planing: Ф3 — правки контента в черновик (PATCH съёмки, DELETE включения, без авто-создания живой миссии)
This commit is contained in:
@@ -248,8 +248,15 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
|
|||||||
- **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша.
|
- **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша.
|
||||||
- **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе.
|
- **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе.
|
||||||
|
|
||||||
### Фаза 3 — Правки контента в черновик
|
### Фаза 3 — Правки контента в черновик — ✅ СДЕЛАНО
|
||||||
**Модель: Sonnet.**
|
**Модель: Sonnet.**
|
||||||
|
|
||||||
|
> **Реализовано:**
|
||||||
|
> - `PATCH /api/missions/survey-modes/{modeId}` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [updateSurveyRoute](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): перестраивает контур существующей съёмки тем же `buildContour`, что и `/survey-route/build`, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в `MANUAL` (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит `SurveyRouteBuildRequestDTO`.
|
||||||
|
> - `DELETE /api/missions/modes/{modeId}` → [deleteMode](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): диспатч SURVEY/DROP. Съёмка — снимаются брони слотов (`mode_booking`) и связи `drop_mode_survey`; сброс, оставшийся без единой съёмки, удаляется (осиротевший); другие съёмки того же сброса не трогаются. Сброс — удаляются только его связи и он сам, съёмки независимы. Нет режима → 404.
|
||||||
|
> - `saveSurveyRoute`/`saveDropRoute` больше **не создают живую миссию на лету** — адресуется только существующая миссия-черновик; нет миссии → 404. В `saveSurveyRoute` проверка миссии вынесена **до** дорогого `buildContour` (fail-fast). Swagger-описания и kdoc обновлены.
|
||||||
|
> - Репозиторий: `DropModeSurveyRepository.findAllBySurveyMode_Id` (поиск связей по съёмке).
|
||||||
|
> - Тесты: `MissionEditModesTest` (6) — каскады DELETE (осиротевший сброс удаляется; общий сброс с другой съёмкой остаётся; удаление сброса не трогает съёмки; 404 на неизвестный режим) + 404 на `saveSurveyRoute`/`saveDropRoute` без миссии. Весь модуль зелёный.
|
||||||
- `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`.
|
- `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`.
|
||||||
- Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария.
|
- Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария.
|
||||||
- **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы.
|
- **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы.
|
||||||
|
|||||||
+43
-2
@@ -12,6 +12,7 @@ import jakarta.validation.Valid
|
|||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
import org.springframework.web.bind.annotation.PostMapping
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.PutMapping
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
@@ -51,7 +52,7 @@ class MissionController(
|
|||||||
description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " +
|
description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " +
|
||||||
"и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " +
|
"и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " +
|
||||||
"planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " +
|
"planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " +
|
||||||
"Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса."
|
"Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404."
|
||||||
)
|
)
|
||||||
@ApiResponses(
|
@ApiResponses(
|
||||||
value = [
|
value = [
|
||||||
@@ -70,7 +71,7 @@ class MissionController(
|
|||||||
description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " +
|
description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " +
|
||||||
"и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " +
|
"и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " +
|
||||||
"planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " +
|
"planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " +
|
||||||
"Если миссии/плана с таким id ещё нет, они создаются на лету по параметрам запроса."
|
"Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404."
|
||||||
)
|
)
|
||||||
@ApiResponses(
|
@ApiResponses(
|
||||||
value = [
|
value = [
|
||||||
@@ -211,6 +212,46 @@ class MissionController(
|
|||||||
return ResponseEntity.noContent().build()
|
return ResponseEntity.noContent().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Перестроить маршрут съёмки",
|
||||||
|
description = "Перестраивает контур существующего режима съёмки по новым параметрам (окно/крен/" +
|
||||||
|
"угол захвата) тем же алгоритмом, что и /survey-route/build, и сохраняет результат на месте. " +
|
||||||
|
"Используется для правки включения в плане-черновике."
|
||||||
|
)
|
||||||
|
@ApiResponses(
|
||||||
|
value = [
|
||||||
|
ApiResponse(responseCode = "200", description = "Маршрут перестроен"),
|
||||||
|
ApiResponse(responseCode = "404", description = "Режим съёмки не найден")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@PatchMapping("/survey-modes/{mode_id}")
|
||||||
|
fun updateSurveyRoute(
|
||||||
|
@Parameter(description = "Идентификатор режима съёмки")
|
||||||
|
@PathVariable("mode_id") modeId: Long,
|
||||||
|
@Valid @RequestBody body: SurveyRouteBuildRequestDTO
|
||||||
|
) = missionPlaningService.updateSurveyRoute(modeId, body)
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Удалить включение (съёмку или сброс)",
|
||||||
|
description = "Удаляет режим (SURVEY или DROP) из плана-черновика по его id. Для съёмки снимаются " +
|
||||||
|
"брони слотов и связи со сбросами, осиротевший сброс (без единой съёмки) тоже удаляется. " +
|
||||||
|
"Для сброса удаляются только его связи; съёмки сохраняются."
|
||||||
|
)
|
||||||
|
@ApiResponses(
|
||||||
|
value = [
|
||||||
|
ApiResponse(responseCode = "204", description = "Включение удалено"),
|
||||||
|
ApiResponse(responseCode = "404", description = "Режим не найден")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@DeleteMapping("/modes/{mode_id}")
|
||||||
|
fun deleteMode(
|
||||||
|
@Parameter(description = "Идентификатор режима (survey или drop)")
|
||||||
|
@PathVariable("mode_id") modeId: Long
|
||||||
|
): ResponseEntity<Void> {
|
||||||
|
missionPlaningService.deleteMode(modeId)
|
||||||
|
return ResponseEntity.noContent().build()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Operation(summary = "Создать миссию")
|
@Operation(summary = "Создать миссию")
|
||||||
|
|||||||
+3
@@ -7,4 +7,7 @@ import java.util.UUID
|
|||||||
interface DropModeSurveyRepository : JpaRepository<DropModeSurveyEntity, Long> {
|
interface DropModeSurveyRepository : JpaRepository<DropModeSurveyEntity, Long> {
|
||||||
fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List<DropModeSurveyEntity>
|
fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List<DropModeSurveyEntity>
|
||||||
fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List<DropModeSurveyEntity>
|
fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List<DropModeSurveyEntity>
|
||||||
|
|
||||||
|
/** Связи сброс↔съёмка по конкретной съёмке (для удаления съёмки и поиска осиротевших сбросов). */
|
||||||
|
fun findAllBySurveyMode_Id(surveyModeId: Long): List<DropModeSurveyEntity>
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-53
@@ -664,42 +664,18 @@ class MissionPlaningService(
|
|||||||
* [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии
|
* [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии
|
||||||
* (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана.
|
* (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана.
|
||||||
*
|
*
|
||||||
* Если миссии/плана с таким идентификатором ещё нет (план не рассчитывался), они
|
* Адресуется только **существующая** миссия-черновик: правки редактора идут в DRAFT-клон,
|
||||||
* создаются на лету по параметрам запроса: `missionId = planId`, `satelliteId` из запроса
|
* созданный заранее (`POST /api/missions/{id}/clone`). Авто-создание живой миссии на лету
|
||||||
* (NORAD), интервал — окно построенного маршрута. При последующем штатном расчёте
|
* убрано — нельзя молча писать правки в несуществующий/живой план. Если миссии с таким id
|
||||||
* [loadSlots] интервал миссии будет уточнён.
|
* нет — 404.
|
||||||
*
|
*
|
||||||
* Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только
|
* Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только
|
||||||
* пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]).
|
* пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]).
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
|
fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
|
||||||
val built = buildContour(req)
|
// Проверяем существование миссии-черновика до дорогого расчёта контура (fail-fast на 404).
|
||||||
|
val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) }
|
||||||
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)
|
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
|
||||||
.orElseGet {
|
.orElseGet {
|
||||||
@@ -713,6 +689,8 @@ class MissionPlaningService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val built = buildContour(req)
|
||||||
|
|
||||||
val saved = surveyModeRepository.save(
|
val saved = surveyModeRepository.save(
|
||||||
SurveyModeEntity(
|
SurveyModeEntity(
|
||||||
plan = plan,
|
plan = plan,
|
||||||
@@ -750,8 +728,8 @@ class MissionPlaningService(
|
|||||||
* ([calculateDrops]).
|
* ([calculateDrops]).
|
||||||
*
|
*
|
||||||
* Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute).
|
* Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute).
|
||||||
* Если миссии/плана с таким id ещё нет — создаются на лету по параметрам запроса (окно плана
|
* Адресуется только **существующая** миссия-черновик (DRAFT-клон, созданный заранее). Авто-создание
|
||||||
* из `planStart/planEnd`, фолбэк — окно зоны).
|
* живой миссии на лету убрано — если миссии с таким id нет, возвращается 404.
|
||||||
*
|
*
|
||||||
* Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все
|
* Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все
|
||||||
* сбросы плана ([ModeRepository.deleteDropModesByPlanId]).
|
* сбросы плана ([ModeRepository.deleteDropModesByPlanId]).
|
||||||
@@ -762,27 +740,7 @@ class MissionPlaningService(
|
|||||||
throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса")
|
throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса")
|
||||||
}
|
}
|
||||||
|
|
||||||
val mission = missionRepository.findById(planId).orElseGet {
|
val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) }
|
||||||
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)
|
val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1)
|
||||||
.orElseGet {
|
.orElseGet {
|
||||||
@@ -856,6 +814,102 @@ class MissionPlaningService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перестраивает контур существующего маршрута съёмки по новым параметрам (окно/крен/угол захвата)
|
||||||
|
* тем же алгоритмом [buildContour], что и [buildSurveyRoute]/[saveSurveyRoute], и сохраняет
|
||||||
|
* результат на месте (правка включения в черновике).
|
||||||
|
*
|
||||||
|
* Меняются: время начала, виток, центр (lat/long), длительность, контур (WKT) и крен. Статус
|
||||||
|
* сохраняется. Источник переводится в [SurveyModeSource.MANUAL] — отредактированный оператором
|
||||||
|
* маршрут переживает пересчёт плана ([loadSlots] удаляет только пересчитываемые, не MANUAL).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun updateSurveyRoute(modeId: Long, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO {
|
||||||
|
val survey = surveyModeRepository.findById(modeId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val built = buildContour(req)
|
||||||
|
survey.timeStart = built.timeStart.atOffset(ZoneOffset.UTC)
|
||||||
|
survey.revolution = built.revolution
|
||||||
|
survey.lat = built.lat
|
||||||
|
survey.long = built.longitude
|
||||||
|
survey.duration = built.durationSeconds
|
||||||
|
survey.contourWkt = built.contourWkt
|
||||||
|
survey.roll = built.roll
|
||||||
|
survey.source = SurveyModeSource.MANUAL
|
||||||
|
val saved = surveyModeRepository.save(survey)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Перестроен маршрут съёмки (survey_mode id={}): revolution={}, timeStart={}, duration={}, roll={}",
|
||||||
|
saved.id, built.revolution, built.timeStart, built.durationSeconds, built.roll
|
||||||
|
)
|
||||||
|
|
||||||
|
return built.toResponse(surveyModeId = saved.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет включение (SURVEY или DROP) из плана-черновика по его id.
|
||||||
|
*
|
||||||
|
* Съёмка: снимаются её брони слотов ([ModeBookingEntity]) и связи съёмка↔сброс
|
||||||
|
* ([DropModeSurveyEntity]); сброс, оставшийся без единой съёмки, тоже удаляется (осиротевший
|
||||||
|
* сеанс не имеет смысла). Другие съёмки того же сброса не трогаются.
|
||||||
|
*
|
||||||
|
* Сброс: удаляются только его связи и сам сброс; съёмки независимы и сохраняются.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
fun deleteMode(modeId: Long) {
|
||||||
|
val mode = modeRepository.findById(modeId).orElseThrow {
|
||||||
|
ResponseStatusException(HttpStatus.NOT_FOUND, "Mode with id $modeId not found")
|
||||||
|
}
|
||||||
|
when (mode) {
|
||||||
|
is SurveyModeEntity -> deleteSurveyMode(mode)
|
||||||
|
is DropModeEntity -> deleteDropMode(mode)
|
||||||
|
else -> throw ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Unsupported mode type for id $modeId: ${mode::class.qualifiedName}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteSurveyMode(survey: SurveyModeEntity) {
|
||||||
|
val surveyId = survey.id ?: throw IllegalStateException("Survey mode id is null")
|
||||||
|
|
||||||
|
val bookings = modeBookingRepository.findAllByMode_IdIn(listOf(surveyId))
|
||||||
|
if (bookings.isNotEmpty()) {
|
||||||
|
modeBookingRepository.deleteAll(bookings)
|
||||||
|
}
|
||||||
|
|
||||||
|
val links = dropModeSurveyRepository.findAllBySurveyMode_Id(surveyId)
|
||||||
|
val affectedDropIds = links.mapNotNull { it.dropMode?.id }.distinct()
|
||||||
|
if (links.isNotEmpty()) {
|
||||||
|
dropModeSurveyRepository.deleteAll(links)
|
||||||
|
}
|
||||||
|
// Сброс, у которого после удаления этой съёмки не осталось ни одной другой, — осиротел.
|
||||||
|
affectedDropIds.forEach { dropId ->
|
||||||
|
val hasOtherSurvey = dropModeSurveyRepository
|
||||||
|
.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId)
|
||||||
|
.any { it.surveyMode?.id != null && it.surveyMode?.id != surveyId }
|
||||||
|
if (!hasOtherSurvey) {
|
||||||
|
dropModeRepository.deleteById(dropId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
surveyModeRepository.delete(survey)
|
||||||
|
logger.info("Удалена съёмка (survey_mode id={}), снято броней={}, осиротевших сбросов проверено={}",
|
||||||
|
surveyId, bookings.size, affectedDropIds.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDropMode(drop: DropModeEntity) {
|
||||||
|
val dropId = drop.id ?: throw IllegalStateException("Drop mode id is null")
|
||||||
|
val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId)
|
||||||
|
if (links.isNotEmpty()) {
|
||||||
|
dropModeSurveyRepository.deleteAll(links)
|
||||||
|
}
|
||||||
|
dropModeRepository.delete(drop)
|
||||||
|
logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Строит контур полосы съёмки по параметрам видимости точки.
|
* Строит контур полосы съёмки по параметрам видимости точки.
|
||||||
* Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute].
|
* Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute].
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
package space.nstart.pcp.pcp_mission_planing_service.service
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import org.mockito.Mockito.doAnswer
|
||||||
|
import org.mockito.Mockito.mock
|
||||||
|
import org.mockito.Mockito.`when`
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
|
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.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_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.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.routes.SurveyModeStatus
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO
|
||||||
|
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class MissionEditModesTest {
|
||||||
|
|
||||||
|
private val surveyStore = linkedMapOf<Long, SurveyModeEntity>()
|
||||||
|
private val dropStore = linkedMapOf<Long, DropModeEntity>()
|
||||||
|
private val dropSurveyStore = mutableListOf<DropModeSurveyEntity>()
|
||||||
|
private val bookingStore = mutableListOf<ModeBookingEntity>()
|
||||||
|
|
||||||
|
private var autoId = 10L
|
||||||
|
|
||||||
|
private val missionRepository = mock(MissionRepository::class.java)
|
||||||
|
|
||||||
|
private val modeRepository = mock(ModeRepository::class.java).also { repo ->
|
||||||
|
`when`(repo.findById(anyLong())).thenAnswer { inv ->
|
||||||
|
val id = inv.arguments[0] as Long
|
||||||
|
java.util.Optional.ofNullable<ModeEntity>(surveyStore[id] ?: dropStore[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo ->
|
||||||
|
doAnswer { inv ->
|
||||||
|
val s = inv.arguments[0] as SurveyModeEntity
|
||||||
|
surveyStore.remove(s.id)
|
||||||
|
null
|
||||||
|
}.`when`(repo).delete(anyNonNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dropModeRepository = mock(DropModeRepository::class.java).also { repo ->
|
||||||
|
doAnswer { inv ->
|
||||||
|
val d = inv.arguments[0] as DropModeEntity
|
||||||
|
dropStore.remove(d.id)
|
||||||
|
null
|
||||||
|
}.`when`(repo).delete(anyNonNull())
|
||||||
|
doAnswer { inv ->
|
||||||
|
dropStore.remove(inv.arguments[0] as Long)
|
||||||
|
null
|
||||||
|
}.`when`(repo).deleteById(anyLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo ->
|
||||||
|
`when`(repo.findAllBySurveyMode_Id(anyLong())).thenAnswer { inv ->
|
||||||
|
val sid = inv.arguments[0] as Long
|
||||||
|
dropSurveyStore.filter { it.surveyMode?.id == sid }
|
||||||
|
}
|
||||||
|
`when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv ->
|
||||||
|
val did = inv.arguments[0] as Long
|
||||||
|
dropSurveyStore.filter { it.dropMode?.id == did }
|
||||||
|
}
|
||||||
|
doAnswer { inv ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val links = inv.arguments[0] as Iterable<DropModeSurveyEntity>
|
||||||
|
dropSurveyStore.removeAll(links.toSet())
|
||||||
|
null
|
||||||
|
}.`when`(repo).deleteAll(anyNonNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val modeBookingRepository = mock(ModeBookingRepository::class.java).also { repo ->
|
||||||
|
`when`(repo.findAllByMode_IdIn(anyNonNull())).thenAnswer { inv ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val ids = inv.arguments[0] as Collection<Long>
|
||||||
|
bookingStore.filter { it.mode?.id in ids }
|
||||||
|
}
|
||||||
|
doAnswer { inv ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val bs = inv.arguments[0] as Iterable<ModeBookingEntity>
|
||||||
|
bookingStore.removeAll(bs.toSet())
|
||||||
|
null
|
||||||
|
}.`when`(repo).deleteAll(anyNonNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val service = MissionPlaningService(
|
||||||
|
missionRepository = missionRepository,
|
||||||
|
modeRepository = modeRepository,
|
||||||
|
planRepository = mock(PlanRepository::class.java),
|
||||||
|
surveyModeRepository = surveyModeRepository,
|
||||||
|
dropModeRepository = dropModeRepository,
|
||||||
|
dropModeSurveyRepository = dropModeSurveyRepository,
|
||||||
|
modeBookingRepository = modeBookingRepository,
|
||||||
|
bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java),
|
||||||
|
ballisticsService = mock(BallisticsService::class.java),
|
||||||
|
complexMissionService = mock(ComplexMissionService::class.java),
|
||||||
|
satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java),
|
||||||
|
satelliteMissionTypeFactory = SatelliteMissionTypeFactory(
|
||||||
|
listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()),
|
||||||
|
DefaultSatelliteMissionType()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val plan = PlanEntity(id = 1L, planNumber = 1)
|
||||||
|
|
||||||
|
// --- DELETE mode ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting a survey removes its bookings, links and the orphaned drop`() {
|
||||||
|
val survey = survey()
|
||||||
|
val drop = drop()
|
||||||
|
link(drop, survey)
|
||||||
|
booking(survey)
|
||||||
|
|
||||||
|
service.deleteMode(survey.id!!)
|
||||||
|
|
||||||
|
assertFalse(surveyStore.containsKey(survey.id))
|
||||||
|
assertTrue(bookingStore.isEmpty())
|
||||||
|
assertFalse(dropStore.containsKey(drop.id), "сброс без единственной съёмки должен быть удалён")
|
||||||
|
assertTrue(dropSurveyStore.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting a survey keeps a drop still referenced by another survey`() {
|
||||||
|
val survey1 = survey()
|
||||||
|
val survey2 = survey()
|
||||||
|
val drop = drop()
|
||||||
|
link(drop, survey1)
|
||||||
|
link(drop, survey2)
|
||||||
|
|
||||||
|
service.deleteMode(survey1.id!!)
|
||||||
|
|
||||||
|
assertFalse(surveyStore.containsKey(survey1.id))
|
||||||
|
assertTrue(surveyStore.containsKey(survey2.id))
|
||||||
|
assertTrue(dropStore.containsKey(drop.id), "сброс с оставшейся съёмкой не удаляется")
|
||||||
|
assertEquals(1, dropSurveyStore.size)
|
||||||
|
assertEquals(survey2.id, dropSurveyStore.single().surveyMode?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting a drop removes the drop and its links but keeps surveys`() {
|
||||||
|
val survey = survey()
|
||||||
|
val drop = drop()
|
||||||
|
link(drop, survey)
|
||||||
|
|
||||||
|
service.deleteMode(drop.id!!)
|
||||||
|
|
||||||
|
assertFalse(dropStore.containsKey(drop.id))
|
||||||
|
assertTrue(dropSurveyStore.isEmpty())
|
||||||
|
assertTrue(surveyStore.containsKey(survey.id), "съёмка независима от сброса и сохраняется")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleting an unknown mode yields 404`() {
|
||||||
|
val ex = assertThrows<ResponseStatusException> { service.deleteMode(999L) }
|
||||||
|
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- save routes больше не создают живую миссию ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveSurveyRoute on a missing mission yields 404 instead of auto-creating`() {
|
||||||
|
// missionRepository не застаблен → findById возвращает пустой Optional.
|
||||||
|
val ex = assertThrows<ResponseStatusException> {
|
||||||
|
service.saveSurveyRoute(UUID.randomUUID(), SurveyRouteBuildRequestDTO(satelliteId = 25544))
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saveDropRoute on a missing mission yields 404 instead of auto-creating`() {
|
||||||
|
val ex = assertThrows<ResponseStatusException> {
|
||||||
|
service.saveDropRoute(
|
||||||
|
UUID.randomUUID(),
|
||||||
|
DropRouteSaveRequestDTO(satelliteId = 25544, surveyModeIds = listOf(1L))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
private fun survey(): SurveyModeEntity {
|
||||||
|
val id = ++autoId
|
||||||
|
val s = SurveyModeEntity(
|
||||||
|
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0).atOffset(ZoneOffset.UTC),
|
||||||
|
revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0,
|
||||||
|
status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL
|
||||||
|
)
|
||||||
|
surveyStore[id] = s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drop(): DropModeEntity {
|
||||||
|
val id = ++autoId
|
||||||
|
val d = DropModeEntity(
|
||||||
|
id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0).atOffset(ZoneOffset.UTC),
|
||||||
|
revolution = id, station = "102", duration = 60.0
|
||||||
|
)
|
||||||
|
dropStore[id] = d
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun link(drop: DropModeEntity, survey: SurveyModeEntity) {
|
||||||
|
dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun booking(survey: SurveyModeEntity) {
|
||||||
|
bookingStore.add(ModeBookingEntity(id = ++autoId, mode = survey, slotId = autoId))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> anyNonNull(): T { org.mockito.ArgumentMatchers.any<T>(); return null as T }
|
||||||
|
private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user