pcp: Ф2 — черновик-клон миссии (replace + fork-forward)

mission-planing: POST /api/missions/{id}/clone — полный клон (replace)
или по перекрытию с окном (fork-forward, [start,stop) открытый правый конец);
пустые drop-mode (все съёмки вне окна) не клонируются

tgu-service: PlannedPlanStatus.DRAFT; POST /api/plans/{planId}/draft
(CreateDraftUseCase: guard replace/fork-forward, clone mission → planId=newMissionId,
компенсация при сбое); DRAFT отфильтрован из PlanQueryService read-пути;
MissionPlaningClient + MissionPlaningProperties + mission-planing.base-url в yaml

тесты: MissionCloneTest (6 граничных кейсов предиката перекрытия),
PlanQueryServiceTest (DRAFT не видна в ответе)
This commit is contained in:
Дмитрий Соловьев
2026-06-03 12:53:57 +03:00
parent 80e281dd6d
commit 0d69617420
15 changed files with 754 additions and 6 deletions
+4
View File
@@ -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}
@@ -231,8 +231,16 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
- **Приёмка:** будущий не-`LAID_IN` план > порога → attempt создаётся, BPMN стартует; `now ≥ startTime10мин` → отказ; `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` не голова цепочки и не в проекции.
@@ -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)
@@ -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)
}
@@ -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<ModeEntity> = 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<Long, SurveyModeEntity>()
modesToClone.filterIsInstance<SurveyModeEntity>().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<DropModeEntity>().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<ModeResponseDTO> {
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? {
@@ -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<UUID, MissionEntity>()
private val planStore = linkedMapOf<Long, PlanEntity>()
private val surveyStore = linkedMapOf<Long, SurveyModeEntity>()
private val dropStore = linkedMapOf<Long, DropModeEntity>()
private val dropSurveyStore = mutableListOf<DropModeSurveyEntity>()
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<MissionEntity>())).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<PlanEntity>())).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<SurveyModeEntity>())).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<DropModeEntity>())).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<DropModeSurveyEntity>())).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<ResponseStatusException> {
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 <T> anyNonNull(): T { org.mockito.ArgumentMatchers.any<T>(); return null as T }
private fun anyUUID(): UUID { org.mockito.ArgumentMatchers.any<UUID>(); return UUID.randomUUID() }
private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L }
}
@@ -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
)
@@ -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<PlanResponse> =
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-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки,
@@ -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)
@@ -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,
@@ -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()
}
}
@@ -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
}
}
}
@@ -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<PlanResponse> {
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<PlanResponse> {
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)
@@ -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
@@ -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(),