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:
+9
@@ -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
|
||||
)
|
||||
+19
-2
@@ -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-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки,
|
||||
|
||||
+17
@@ -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)
|
||||
+7
@@ -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,
|
||||
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
+205
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
|
||||
+17
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user