diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 46208ed..9aec2ab 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -97,6 +97,10 @@ spring: external-api: base-url: ${pcp.services.ballistics} + +mission-planing: + base-url: ${pcp.services.mission} + server: port: ${pcp.ports.tgu:7011} diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md index 7e3f0f7..3007b87 100644 --- a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -231,8 +231,16 @@ P2 = DRAFT (base_plan_id=P0, окно=W) - **Приёмка:** будущий не-`LAID_IN` план > порога → attempt создаётся, BPMN стартует; `now ≥ startTime−10мин` → отказ; `LAID_IN` → отказ; активная attempt по КА → отказ. - **Тесты:** unit-таблица граничных случаев (время × статус) по образцу `IssueDuePlanWorkerOnDemandTest`. -### Фаза 2 — Черновик-клон (replace + fork-forward) +### Фаза 2 — Черновик-клон (replace + fork-forward) — ✅ СДЕЛАНО **Модель: Sonnet+ревью Opus** (механика clone простая, но атомарность двух осей смотрит Opus). + +> **Реализовано:** +> - `POST /api/missions/{id}/clone` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt): replace — полный клон; fork-forward — только включения, перекрывающиеся с окном `(modeStop > windowStart && modeStart < windowStop)`. DropMode клонируется только если хотя бы одна из его съёмок вошла в клон (иначе пустой drop пропускается). Перекрытие: открытый правый конец `[start, stop)`. +> - `PlannedPlanStatus.DRAFT` в [PlannedPlanStatus](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt) — персистируется, не входит в проекцию и read-путь. +> - `POST /api/plans/{planId}/draft` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt): создаёт DRAFT-план с `planId = newMissionId` (инвариант planId==missionId сохраняется); при сбоге save — компенсация (delete клон-миссии). Для fork-forward валидирует, что окно есть в проекции КА. +> - DRAFT отфильтрован в [PlanQueryService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) (оба метода). +> - [MissionPlaningClient](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt) + `MissionPlaningProperties` + `mission-planing.base-url` в yaml. +> - Тесты: 6 кейсов в `MissionCloneTest` (граничные случаи предиката перекрытия); 1 кейс в `PlanQueryServiceTest` (DRAFT не виден). Оба модуля зелёные. - `pcp-mission-planing-service`: `POST /api/missions/{id}/clone` body `{ mode, windowStart?, windowStop? }` → `{ newMissionId }`. `replace` — полный клон `Plan`+`Mode`; `fork-forward` — только `Mode`, пересёкшиеся с окном `[start,stop)`. Валидировать, что окно `fork-forward` = существующая будущая ЗРВ. - `pcp-tgu-service`: `POST /api/plans/{planId}/draft` body `{ mode, windowStart?, windowStop? }` → создаёт `planned_plan(DRAFT, base_plan_id)`, триггерит clone миссии, возвращает `{ draftPlanId }`. **Атомарность:** оба создания координируются; при сбое clone — компенсация (не оставлять осиротевший `planned_plan`). - `PlannedPlanStatus.DRAFT`; [PlanProjectionService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt) и `PlanQueryService` — `DRAFT` не голова цепочки и не в проекции. diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt new file mode 100644 index 0000000..9e0573c --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import java.time.LocalDateTime +import java.util.UUID + +data class MissionCloneRequest( + /** "replace" — полный клон; "fork-forward" — только включения, перекрывающиеся с окном. */ + val mode: String, + /** Начало окна нового плана (UTC naive). Обязательно для fork-forward. */ + val windowStart: LocalDateTime? = null, + /** Конец окна нового плана (UTC naive). Обязательно для fork-forward. */ + val windowStop: LocalDateTime? = null +) + +data class MissionCloneResponse(val newMissionId: UUID) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index 241e79c..defe2fc 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -228,4 +228,16 @@ class MissionController( missionPlaningService.delete(id) return ResponseEntity.noContent().build() } + + @Operation( + summary = "Клонировать миссию", + description = "Создаёт полный (replace) или частичный (fork-forward) клон миссии с новым missionId. " + + "Для fork-forward клонируются только включения, перекрывающиеся с окном [windowStart, windowStop). " + + "Возвращает newMissionId, который tgu-service использует как planId черновика." + ) + @PostMapping("/{mission_id}/clone") + fun cloneMission( + @PathVariable("mission_id") id: UUID, + @RequestBody body: MissionCloneRequest + ): MissionCloneResponse = missionPlaningService.cloneMission(id, body) } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index cfd8185..616a024 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -29,6 +29,8 @@ 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.MissionCloneRequest +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse 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 @@ -125,6 +127,112 @@ class MissionPlaningService( missionRepository.deleteById(id) } + /** + * Клонирует миссию и все её включения в новый `missionId`. + * + * Режим `replace` — полный глубокий клон: Mission + Plan + все Mode (SurveyMode/DropMode). + * Режим `fork-forward` — клонирует только включения, чей интервал `[timeStart, timeStart+duration)` + * пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна + * из его связанных съёмок тоже попала в клон (иначе сброс становится пустым). + * + * Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service + * (инвариант planId == missionId сохраняется). + */ + @Transactional + fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse { + val isForkForward = req.mode == "fork-forward" + if (isForkForward && (req.windowStart == null || req.windowStop == null)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "windowStart and windowStop are required for fork-forward clone" + ) + } + + val source = missionRepository.findById(sourceMissionId).orElseThrow { notFound(sourceMissionId) } + val allModes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(sourceMissionId) + + val modesToClone: List = if (isForkForward) { + allModes.filter { mode -> mode.intersects(req.windowStart!!, req.windowStop!!) } + } else { + allModes + } + + val (newMissionStart, newMissionStop) = if (isForkForward) { + req.windowStart!! to req.windowStop!! + } else { + source.missionStart to source.missionStop + } + + val newMission = missionRepository.save( + MissionEntity( + satelliteId = source.satelliteId, + station = source.station, + missionStart = newMissionStart, + missionStop = newMissionStop, + status = "NEW" + ) + ) + + val newPlan = planRepository.save( + PlanEntity( + mission = newMission, + planStart = newMissionStart, + planStop = newMissionStop, + planNumber = 1 + ) + ) + + // Клонируем съёмки и строим маппинг старый id → новая сущность для перевязки сбросов. + val surveyIdMapping = mutableMapOf() + modesToClone.filterIsInstance().forEach { src -> + val cloned = surveyModeRepository.save( + SurveyModeEntity( + plan = newPlan, + timeStart = src.timeStart, + revolution = src.revolution, + lat = src.lat, + long = src.long, + duration = src.duration, + contourWkt = src.contourWkt, + roll = src.roll, + status = SurveyModeStatus.PLANNED, + source = src.source + ) + ) + src.id?.let { surveyIdMapping[it] = cloned } + } + + // Клонируем сбросы; пропускаем если все их связанные съёмки вышли за окно (пустой сброс). + modesToClone.filterIsInstance().forEach { src -> + val srcId = src.id ?: return@forEach + val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(srcId) + val remappedSurveys = links.mapNotNull { link -> link.surveyMode?.id?.let { surveyIdMapping[it] } } + if (remappedSurveys.isEmpty()) { + return@forEach + } + val newDrop = dropModeRepository.save( + DropModeEntity( + plan = newPlan, + timeStart = src.timeStart, + revolution = src.revolution, + station = src.station, + duration = src.duration + ) + ) + remappedSurveys.forEach { survey -> + dropModeSurveyRepository.save(DropModeSurveyEntity(dropMode = newDrop, surveyMode = survey)) + } + } + + val clonedDropCount = modesToClone.count { it is DropModeEntity } + logger.info( + "Клонирована миссия {}: mode={}, новый id={}, съёмок={}, сбросов={}", + sourceMissionId, req.mode, newMission.missionId, surveyIdMapping.size, clonedDropCount + ) + + return MissionCloneResponse(newMissionId = newMission.missionId) + } + fun modesByMission(id: UUID): List { if (!missionRepository.existsById(id)) { throw notFound(id) @@ -199,10 +307,14 @@ class MissionPlaningService( private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() + /** + * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт). + * Применяется при fork-forward-клоне: включения строго вне окна не клонируются. + */ private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { val modeStart = timeStart?.toUtcLocalDateTime() ?: return false val modeStop = modeStop() ?: return false - return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop) + return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt new file mode 100644 index 0000000..ef6f9ea --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -0,0 +1,280 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest +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 +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 java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.Optional +import java.util.UUID + +class MissionCloneTest { + + private val missionStore = linkedMapOf() + private val planStore = linkedMapOf() + private val surveyStore = linkedMapOf() + private val dropStore = linkedMapOf() + private val dropSurveyStore = mutableListOf() + + private var missionAutoId = 1L + private var planAutoId = 100L + private var modeAutoId = 10L + + private val missionRepository = mock(MissionRepository::class.java).also { repo -> + `when`(repo.findById(anyUUID())).thenAnswer { inv -> + Optional.ofNullable(missionStore[inv.arguments[0]]) + } + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val m = inv.arguments[0] as MissionEntity + missionStore[m.missionId] = m + m + } + } + + private val planRepository = mock(PlanRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val p = inv.arguments[0] as PlanEntity + val id = ++planAutoId + val saved = PlanEntity(id = id, mission = p.mission, planNumber = p.planNumber, planStart = p.planStart, planStop = p.planStop) + planStore[id] = saved + saved + } + } + + private val modeRepository = mock(ModeRepository::class.java).also { repo -> + `when`(repo.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(anyUUID())).thenAnswer { inv -> + val missionId = inv.arguments[0] as UUID + (surveyStore.values.filter { it.plan?.mission?.missionId == missionId } + + dropStore.values.filter { it.plan?.mission?.missionId == missionId }) + } + } + + private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val s = inv.arguments[0] as SurveyModeEntity + val id = ++modeAutoId + val saved = SurveyModeEntity(id = id, plan = s.plan, timeStart = s.timeStart, revolution = s.revolution, + lat = s.lat, long = s.long, duration = s.duration, contourWkt = s.contourWkt, roll = s.roll, + status = s.status, source = s.source) + surveyStore[id] = saved + saved + } + } + + private val dropModeRepository = mock(DropModeRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val d = inv.arguments[0] as DropModeEntity + val id = ++modeAutoId + val saved = DropModeEntity(id = id, plan = d.plan, timeStart = d.timeStart, revolution = d.revolution, + station = d.station, duration = d.duration) + dropStore[id] = saved + saved + } + } + + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo -> + `when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv -> + val dropId = inv.arguments[0] as Long + dropSurveyStore.filter { it.dropMode?.id == dropId } + } + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val link = inv.arguments[0] as DropModeSurveyEntity + dropSurveyStore.add(link) + link + } + } + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = planRepository, + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = mock(ModeBookingRepository::class.java), + 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() + ) + ) + + // --- replace: полный клон --- + + @Test + fun `replace clone copies all survey and drop modes with remapped survey links`() { + val (missionId, _, survey1, survey2, drop) = buildSourceMission(t(1), t(5)) + addDropLink(drop, survey1) + addDropLink(drop, survey2) + + val result = service.cloneMission(missionId, MissionCloneRequest(mode = "replace")) + + val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, clonedSurveys.size) + assertEquals(1, clonedDrops.size) + // Ссылки переведены на новые survey IDs + val clonedLinks = dropSurveyStore.filter { it.dropMode?.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, clonedLinks.size) + assertTrue(clonedLinks.all { it.surveyMode?.plan?.mission?.missionId == result.newMissionId }) + } + + // --- fork-forward: фильтрация по окну --- + + @Test + fun `fork-forward clone includes mode that starts before window but ends inside`() { + // Включение начинается за 5 мин до окна и заканчивается внутри → пересекается. + // buildSourceMission создаёт 2 survey (оба перекрывают окно) + 1 drop. + val (missionId) = buildSourceMission(t(0).minusMinutes(5), t(0).plusMinutes(10)) + val result = service.cloneMission( + missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, cloned.size) + } + + @Test + fun `fork-forward clone excludes mode whose stop equals window start (closed left is open right semantics)`() { + // Включение заканчивается ровно в windowStart → не пересекается (modeStop > windowStart is false). + // Явно создаём одну съёмку длительностью 30 минут, заканчивающуюся в t(0). + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + // duration 1800s = 30 min; начало: t(0) - 30min = строго до окна, конец ровно в t(0). + survey(plan, t(0).minusMinutes(30), duration = 1800.0) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(cloned.isEmpty()) + } + + @Test + fun `fork-forward clone excludes mode starting exactly at window stop (open upper bound)`() { + // Включение начинается ровно в windowStop → не пересекается (open right bound [start, stop)). + // Явно создаём одну съёмку: start = t(1) (= windowStop), duration 10min. + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + survey(plan, t(1), duration = 600.0) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(cloned.isEmpty()) + } + + @Test + fun `fork-forward drops drop mode when all its surveys are outside window`() { + // Две съёмки вне окна; сброс на них тоже не клонируется (пустой drop). + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val sourcePlan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[sourcePlan.id!!] = sourcePlan + val outSurvey = survey(sourcePlan, start = t(0).minusHours(2), duration = 60.0) + val drop = drop(sourcePlan, start = t(0).minusMinutes(30)) + addDropLink(drop, outSurvey) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2)) + ) + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(clonedDrops.isEmpty()) + } + + @Test + fun `fork-forward requires windowStart and windowStop`() { + val (missionId) = buildSourceMission(t(0), t(1)) + assertThrows { + service.cloneMission(missionId, MissionCloneRequest(mode = "fork-forward")) + } + } + + // --- helpers --- + + data class SourceFixture( + val missionId: UUID, + val plan: PlanEntity, + val survey1: SurveyModeEntity, + val survey2: SurveyModeEntity, + val drop: DropModeEntity + ) + + private fun buildSourceMission(surveyStart: LocalDateTime, surveyStop: LocalDateTime): SourceFixture { + val mission = MissionEntity(satelliteId = 100) + missionStore[mission.missionId] = mission + val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1) + planStore[plan.id!!] = plan + val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0 + val s1 = survey(plan, surveyStart.atOffset(ZoneOffset.UTC), duration) + val s2 = survey(plan, surveyStart.plusMinutes(1).atOffset(ZoneOffset.UTC), duration) + val d = drop(plan, surveyStart.plusMinutes(5)) + return SourceFixture(mission.missionId, plan, s1, s2, d) + } + + private fun survey(plan: PlanEntity, startOffset: OffsetDateTime, duration: Double = 300.0): SurveyModeEntity { + val id = ++modeAutoId + val s = SurveyModeEntity(id = id, plan = plan, timeStart = startOffset, revolution = id, + lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL, + status = SurveyModeStatus.PLANNED) + surveyStore[id] = s + return s + } + + private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0) = + survey(plan, start.atOffset(ZoneOffset.UTC), duration) + + private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity { + val id = ++modeAutoId + val d = DropModeEntity(id = id, plan = plan, timeStart = start.atOffset(ZoneOffset.UTC), + revolution = id, station = "102", duration = 60.0) + dropStore[id] = d + return d + } + + private fun addDropLink(drop: DropModeEntity, survey: SurveyModeEntity) { + dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey)) + } + + private fun t(hour: Int): LocalDateTime = LocalDateTime.of(2026, 6, 10, hour, 0, 0) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { org.mockito.ArgumentMatchers.any(); return null as T } + private fun anyUUID(): UUID { org.mockito.ArgumentMatchers.any(); return UUID.randomUUID() } + private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt new file mode 100644 index 0000000..7b6d145 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "mission-planing") +data class MissionPlaningProperties( + /** Базовый URL pcp-mission-planing-service. */ + val baseUrl: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index 6439b41..35d79bb 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -1,14 +1,18 @@ package space.nstart.pcp_tgu_service.controller -/** Содержит методы API для чтения текущих планов и ручной выдачи плана. */ +/** Содержит методы API для чтения текущих планов, создания черновика и ручной выдачи плана. */ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody 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_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.CreateDraftResponse import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.service.CreateDraftUseCase import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker import space.nstart.pcp_tgu_service.service.PlanQueryService import java.util.UUID @@ -17,7 +21,8 @@ import java.util.UUID @RequestMapping("/api/plans") class PlanController( private val planQueryService: PlanQueryService, - private val issueDuePlanWorker: IssueDuePlanWorker + private val issueDuePlanWorker: IssueDuePlanWorker, + private val createDraftUseCase: CreateDraftUseCase ) { /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @@ -33,6 +38,18 @@ class PlanController( ): List = planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) + /** + * Создаёт черновик плана (DRAFT) на основе basePlanId. + * Режим replace — черновик в том же слоте (база ещё правима). + * Режим fork-forward — черновик на следующей ЗРВ (база невозвратна). + * Возвращает draftPlanId, с которым работают Ф3–Ф5. + */ + @PostMapping("/{planId}/draft") + fun createDraft( + @PathVariable planId: UUID, + @RequestBody body: CreateDraftRequest + ): CreateDraftResponse = createDraftUseCase.createDraft(planId, body) + /** * Ручная выдача плана оператором («Применить / Отправить вручную»). Стартует новую attempt * и Camunda-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt new file mode 100644 index 0000000..0b02827 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt @@ -0,0 +1,17 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +data class CreateDraftRequest( + /** "replace" — черновик в том же слоте; "fork-forward" — черновик на следующей ЗРВ. */ + val mode: String, + /** Начало окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ + val windowStart: LocalDateTime? = null, + /** Конец окна целевой ЗРВ (UTC naive). Обязательно для fork-forward. */ + val windowStop: LocalDateTime? = null, + /** КПП целевой ЗРВ. Обязательно для fork-forward. */ + val windowKppId: String? = null +) + +data class CreateDraftResponse(val draftPlanId: UUID) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt index a974961..622384d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt @@ -6,6 +6,13 @@ package space.nstart.pcp_tgu_service.entity enum class PlannedPlanStatus { /** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */ PLANNED, + + /** + * Черновик: план в стадии правки оператором. Персистируется, но НЕ является головой цепочки + * и НЕ попадает в проекцию/read-путь как запланированный слот. Не выдаётся авто-воркером. + * Становится активным только после promote (Ф5) — смена статуса на PLANNED/ISSUING. + */ + DRAFT, ISSUING, WAITING_DECISION, ACCEPTED, diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt new file mode 100644 index 0000000..4e3f963 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt @@ -0,0 +1,41 @@ +package space.nstart.pcp_tgu_service.integration.api + +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp_tgu_service.config.MissionPlaningProperties +import java.time.LocalDateTime +import java.util.UUID + +data class MissionCloneRequest( + val mode: String, + val windowStart: LocalDateTime? = null, + val windowStop: LocalDateTime? = null +) + +data class MissionCloneResponse(val newMissionId: UUID) + +@Component +class MissionPlaningClient(properties: MissionPlaningProperties) { + + private val webClient: WebClient = WebClient.builder() + .baseUrl(properties.baseUrl) + .build() + + fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse = + webClient.post() + .uri("/api/missions/{id}/clone", missionId) + .bodyValue(req) + .retrieve() + .bodyToMono(MissionCloneResponse::class.java) + .block() + ?: throw IllegalStateException("Empty response from mission-planing clone for missionId=$missionId") + + /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. */ + fun deleteMission(missionId: UUID) { + webClient.delete() + .uri("/api/missions/{id}", missionId) + .retrieve() + .toBodilessEntity() + .block() + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt new file mode 100644 index 0000000..44c3673 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -0,0 +1,205 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.CreateDraftResponse +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.UUID + +/** + * Создаёт черновик плана (DRAFT) на основе существующего базового плана. + * + * Режим **replace**: базовый план ещё правим (до точки невозврата) — черновик наследует + * его окно (startTime/endTime/kppId) и предзаполняется клоном его миссии целиком. + * + * Режим **fork-forward**: базовый план невозвратен (заложен или прошло время) — черновик + * создаётся на выбранной оператором будущей ЗРВ; клон миссии содержит только включения, + * пересёкшиеся с окном новой ЗРВ. + * + * Атомарность: DRAFT-план создаётся только после успешного клона миссии; при сбое клона + * план не сохраняется — осиротевших строк нет. Если сохранение плана падает после клона — + * выполняется компенсация (удаление клона миссии). + */ +@Service +class CreateDraftUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val planProjectionService: PlanProjectionService, + private val missionPlaningClient: MissionPlaningClient, + private val planningProperties: PlanningProperties +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun createDraft(basePlanId: UUID, req: CreateDraftRequest): CreateDraftResponse { + val base = plannedPlanRepository.findById(basePlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") } + + val now = LocalDateTime.now(ZoneOffset.UTC) + + return when (req.mode) { + "replace" -> createReplaceDraft(base, now) + "fork-forward" -> createForkForwardDraft(base, req, now) + else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown draft mode: ${req.mode}") + } + } + + private fun createReplaceDraft(base: PlannedPlanEntity, now: LocalDateTime): CreateDraftResponse { + if (base.status == PlannedPlanStatus.DRAFT) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Нельзя создать черновик от черновика") + } + if (base.status == PlannedPlanStatus.LAID_IN) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План ${base.planId} заложен (LAID_IN) — используйте fork-forward" + ) + } + val freezeAt = base.startTime.minusMinutes(planningProperties.layinFreezeMinutes) + if (!now.isBefore(freezeAt)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "До старта плана ${base.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин " + + "— окно выдачи заморожено, используйте fork-forward" + ) + } + + val cloneResponse = missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest(mode = "replace") + ) + + return saveDraftWithCompensation( + draftPlanId = cloneResponse.newMissionId, + base = base, + startTime = base.startTime, + endTime = base.endTime, + kppId = base.kppId, + clonedMissionId = cloneResponse.newMissionId + ) + } + + private fun createForkForwardDraft( + base: PlannedPlanEntity, + req: CreateDraftRequest, + now: LocalDateTime + ): CreateDraftResponse { + val windowStart = req.windowStart + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowStart обязателен для fork-forward") + val windowStop = req.windowStop + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowStop обязателен для fork-forward") + val windowKppId = req.windowKppId + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowKppId обязателен для fork-forward") + + if (!windowStart.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStart ($windowStart) должен быть строго в будущем" + ) + } + if (!windowStop.isAfter(windowStart)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStop должен быть позже windowStart" + ) + } + + // Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции. + assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId) + + val cloneResponse = missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest(mode = "fork-forward", windowStart = windowStart, windowStop = windowStop) + ) + + return saveDraftWithCompensation( + draftPlanId = cloneResponse.newMissionId, + base = base, + startTime = windowStart, + endTime = windowStop, + kppId = windowKppId, + clonedMissionId = cloneResponse.newMissionId + ) + } + + /** + * Проверяет, что тройка (windowStart, windowStop, kppId) соответствует хотя бы одному + * плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность + * сериализации ISO-строк на фронте). + */ + private fun assertWindowMatchesProjection( + spacecraftId: String, + windowStart: LocalDateTime, + windowStop: LocalDateTime, + kppId: String + ) { + val projection = planProjectionService.project(spacecraftId) + val matched = projection.any { plan -> + plan.kppId == kppId && + diffSeconds(plan.startTime, windowStart) < 1 && + diffSeconds(plan.endTime, windowStop) < 1 + } + if (!matched) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно [$windowStart – $windowStop, kpp=$kppId] не соответствует ни одной будущей ЗРВ из проекции КА $spacecraftId" + ) + } + } + + private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = + Math.abs(java.time.Duration.between(a, b).toSeconds()) + + /** + * Сохраняет DRAFT-план с переданным planId (= clonedMissionId, инвариант planId==missionId). + * При сбое сохранения логирует компенсацию; удаление клона миссии — best-effort (сбой логируется). + */ + private fun saveDraftWithCompensation( + draftPlanId: UUID, + base: PlannedPlanEntity, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String, + clonedMissionId: UUID + ): CreateDraftResponse { + return try { + val draft = plannedPlanRepository.save( + PlannedPlanEntity( + planId = draftPlanId, + spacecraftId = base.spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + status = PlannedPlanStatus.DRAFT, + basePlanId = base.planId + ) + ) + log.info( + "Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}", + draft.planId, base.planId, base.spacecraftId + ) + CreateDraftResponse(draftPlanId = draft.planId) + } catch (ex: Exception) { + log.error( + "Сбой сохранения DRAFT-плана {} после клона миссии {}; попытка компенсации (удалить клон миссии)", + draftPlanId, clonedMissionId, ex + ) + try { + missionPlaningClient.deleteMission(clonedMissionId) + } catch (compEx: Exception) { + log.error( + "Компенсация не удалась: клон миссии {} остался осиротевшим", + clonedMissionId, compEx + ) + } + throw ex + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index 4a2da61..eb69581 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -7,6 +7,7 @@ import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime @@ -28,7 +29,7 @@ class PlanQueryService( fun getAllPlans(historyDays: Long): List { val historySince = historySince(historyDays) val persisted = plannedPlanRepository.findAllByOrderByStartTime() - .filter { plan -> plan.endTime >= historySince } + .filter { plan -> plan.endTime >= historySince && plan.status != PlannedPlanStatus.DRAFT } val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } val projected = spacecraftSnapshotRepository.findAllByActiveTrue() @@ -43,7 +44,7 @@ class PlanQueryService( fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { val historySince = historySince(historyDays) val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) - .filter { plan -> plan.endTime >= historySince } + .filter { plan -> plan.endTime >= historySince && plan.status != PlannedPlanStatus.DRAFT } val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } val projected = planProjectionService.project(spacecraftId) diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index da1e015..de0966c 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -34,6 +34,9 @@ topics: plan-not-accepted: pcp.tgu.plan-not-accepted.v1 station-params-changed: pcp.tgu.station-params-changed.v1 +mission-planing: + base-url: ${pcp.services.mission:http://localhost:7010} + tgu: test-controller: enabled: false diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt index 355b86b..9adb4e7 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -63,6 +63,23 @@ class PlanQueryServiceTest { assertTrue(result.isEmpty()) } + @Test + fun `getAllPlans excludes DRAFT plans from the result`() { + val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT) + val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(emptyList()) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertEquals(1, result.size) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status) + } + private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(),