pcp: Ф2 — доработки по ревью (валидация ошибок клона, идемпотентность DRAFT, тесты)

- MissionPlaningClient: маппинг ошибок mission-planing (4xx как есть, 5xx → 502) + таймаут 30с
- CreateDraftUseCase: гард идемпотентности — второй открытый DRAFT на тот же план → 409
- cloneMission: явная валидация mode (неизвестный → 400 вместо тихого полного клона)
- константы DraftMode/CloneMode вместо строковых литералов
- kdoc: семантика fork-forward (границы-слот vs времена включений; съёмка без сброса)
- тесты: CreateDraftUseCaseTest (13 кейсов), MissionCloneTest (+съёмка без сброса)
This commit is contained in:
Дмитрий Соловьев
2026-06-03 13:09:52 +03:00
parent 0d69617420
commit 85ce238f52
8 changed files with 347 additions and 11 deletions
@@ -3,8 +3,14 @@ package space.nstart.pcp.pcp_mission_planing_service.controller
import java.time.LocalDateTime
import java.util.UUID
/** Допустимые значения [MissionCloneRequest.mode]. */
object CloneMode {
const val REPLACE = "replace"
const val FORK_FORWARD = "fork-forward"
}
data class MissionCloneRequest(
/** "replace" — полный клон; "fork-forward" — только включения, перекрывающиеся с окном. */
/** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */
val mode: String,
/** Начало окна нового плана (UTC naive). Обязательно для fork-forward. */
val windowStart: LocalDateTime? = null,
@@ -29,6 +29,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDT
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.controller.CloneMode
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
@@ -135,12 +136,22 @@ class MissionPlaningService(
* пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна
* из его связанных съёмок тоже попала в клон (иначе сброс становится пустым).
*
* Намеренные особенности fork-forward:
* - Времена включений переносятся как есть. Включение, начавшееся до `windowStart` и закончившееся
* внутри окна, клонируется со своим исходным `timeStart` — он может быть раньше `missionStart`
* клона. Границы миссии описывают слот целевой ЗРВ, а не оболочку времён её включений.
* - Съёмка переносится, даже если её сброс не попал в окно: сброс будет перевыдан в новом плане.
* Обратное неверно — сброс без единой перенесённой съёмки отбрасывается.
*
* Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service
* (инвариант planId == missionId сохраняется).
*/
@Transactional
fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse {
val isForkForward = req.mode == "fork-forward"
if (req.mode != CloneMode.REPLACE && req.mode != CloneMode.FORK_FORWARD) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown clone mode: ${req.mode}")
}
val isForkForward = req.mode == CloneMode.FORK_FORWARD
if (isForkForward && (req.windowStart == null || req.windowStop == null)) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
@@ -217,6 +217,28 @@ class MissionCloneTest {
assertTrue(clonedDrops.isEmpty())
}
@Test
fun `fork-forward keeps a survey even when its drop falls outside the window`() {
// Съёмка внутри окна [t(1), t(2)), но её сброс — до окна. Сброс не клонируется,
// съёмка остаётся (будет пересброшена в новом плане) — намеренная семантика fork-forward.
val source = MissionEntity(satelliteId = 100)
missionStore[source.missionId] = source
val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1)
planStore[plan.id!!] = plan
val inSurvey = survey(plan, start = t(1).plusMinutes(10), duration = 600.0)
val outDrop = drop(plan, start = t(0))
addDropLink(outDrop, inSurvey)
val result = service.cloneMission(
source.missionId,
MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2))
)
val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId }
assertEquals(1, clonedSurveys.size)
assertTrue(clonedDrops.isEmpty())
}
@Test
fun `fork-forward requires windowStart and windowStop`() {
val (missionId) = buildSourceMission(t(0), t(1))