pcp: создание плана новой цепочки из вкладки «Планы» (база/без истории, окно из двух ЗРВ)
Оператор берёт за основу прошлый план (предыстория) или начинает цепочку с нуля,
набирает окно из двух реальных ЗРВ и создаёт план-кандидат; сразу показывается прогноз
будущей цепочки от него. Рабочим (якорем) план становится после ручной отправки (promote)
и приёмки — отдельного механизма якорения не вводим.
pcp-tgu-service:
- сырые ЗРВ: PlanCalculationService.rawWindows + PlanProjectionService.rawZrvWindows,
GET /api/plans/{spacecraftId}/zrv (исходные окна снапшота, не cooked-проекция);
- CreateChainPlanUseCase + POST /api/plans/new-chain: опциональная база (клон включений
в окно) либо пустая миссия без истории; прогноз цепочки через projectAfterWindow;
- ZrvWindowMatcher (старт+КПП одной зоны, конец — другой); assertDraftSlotStillValid
переведён на него — единая проверка для replace/fork-forward и окна из двух ЗРВ;
- тесты: CreateChainPlanUseCaseTest (8), ZrvWindowMatcherTest (7), правки promote-тестов.
pcp-mission-planing-service:
- POST /api/missions/blank — пустая миссия для сценария «без истории» (+ 2 теста).
pcp-tgu-ops-ui:
- API fetchZrvWindows/createChainPlan; диалог «Создать план» во вкладке «Планы»
(выбор базы, стартовой и конечной ЗРВ) с показом спрогнозированной цепочки и ручной
отправкой; подсветка ЗРВ выбранного КА на таймлайне;
- «Целевая ЗРВ» убрана из редактора (он остаётся под replace-правку); удалён ставший
мёртвым editorForkForward; createChainWindows + тесты.
This commit is contained in:
+26
-1
@@ -10,13 +10,18 @@ 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.CreateChainPlanRequest
|
||||
import space.nstart.pcp_tgu_service.dto.CreateChainPlanResponse
|
||||
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.dto.ZrvWindowResponse
|
||||
import space.nstart.pcp_tgu_service.service.CreateChainPlanUseCase
|
||||
import space.nstart.pcp_tgu_service.service.CreateDraftUseCase
|
||||
import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase
|
||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
||||
import space.nstart.pcp_tgu_service.service.PlanProjectionService
|
||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||
import java.util.UUID
|
||||
|
||||
@@ -26,7 +31,9 @@ class PlanController(
|
||||
private val planQueryService: PlanQueryService,
|
||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
||||
private val createDraftUseCase: CreateDraftUseCase,
|
||||
private val deleteDraftUseCase: DeleteDraftUseCase
|
||||
private val deleteDraftUseCase: DeleteDraftUseCase,
|
||||
private val createChainPlanUseCase: CreateChainPlanUseCase,
|
||||
private val planProjectionService: PlanProjectionService
|
||||
) {
|
||||
|
||||
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
||||
@@ -42,6 +49,24 @@ class PlanController(
|
||||
): List<PlanResponse> =
|
||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
||||
|
||||
/**
|
||||
* «Сырые» ЗРВ КА из последнего снапшота (границы реальных зон) — из них оператор набирает окно
|
||||
* нового плана во вкладке «Планы». Пусто, если снапшота нет.
|
||||
*/
|
||||
@GetMapping("/{spacecraftId}/zrv")
|
||||
fun getZrvWindows(@PathVariable spacecraftId: String): List<ZrvWindowResponse> =
|
||||
planProjectionService.rawZrvWindows(spacecraftId).map(ZrvWindowResponse::from)
|
||||
|
||||
/**
|
||||
* Создаёт план новой цепочки (действие «Создать план» во вкладке «Планы»): окно из двух ЗРВ,
|
||||
* опциональная база-предыстория (или цепочка без истории). Возвращает план-кандидат (DRAFT) и
|
||||
* прогноз будущей цепочки от него. План становится рабочим после ручной отправки (promote) и
|
||||
* приёмки. 404 — базы нет; 422 — окно не из ЗРВ / база чужого КА / нечисловой spacecraftId.
|
||||
*/
|
||||
@PostMapping("/new-chain")
|
||||
fun createChainPlan(@RequestBody body: CreateChainPlanRequest): CreateChainPlanResponse =
|
||||
createChainPlanUseCase.createChainPlan(body)
|
||||
|
||||
/**
|
||||
* Создаёт черновик плана (DRAFT) на основе basePlanId.
|
||||
* Режим replace — черновик в том же слоте (база ещё правима).
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package space.nstart.pcp_tgu_service.dto
|
||||
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — UTC ISO с `Z`
|
||||
* (как [PlanResponse]); внутри домена это naive `LocalDateTime` (= UTC).
|
||||
*/
|
||||
data class ZrvWindowResponse(
|
||||
val startTime: OffsetDateTime,
|
||||
val endTime: OffsetDateTime,
|
||||
val kppId: String
|
||||
) {
|
||||
companion object {
|
||||
fun from(window: CalculatedPlan): ZrvWindowResponse =
|
||||
ZrvWindowResponse(
|
||||
startTime = window.startTime.atOffset(ZoneOffset.UTC),
|
||||
endTime = window.endTime.atOffset(ZoneOffset.UTC),
|
||||
kppId = window.kppId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрос создания плана новой цепочки (вкладка «Планы», действие оператора «Создать план»).
|
||||
*
|
||||
* Окно набирается из двух реальных ЗРВ: [windowStart]/[windowKppId] — начало (старт и КПП берутся
|
||||
* у одной зоны), [windowStop] — конец (конец другой, не раньше начала зоны). [basePlanId] —
|
||||
* «предыстория»: прошлый план КА, чьё содержимое клонируется в окно и от которого тянется история
|
||||
* (`base_plan_id`). Если `null` — цепочка без истории: создаётся пустой план, наполняемый с нуля.
|
||||
*/
|
||||
data class CreateChainPlanRequest(
|
||||
/** КА (= noradId строкой); обязателен для сценария «без базы». */
|
||||
val spacecraftId: String,
|
||||
/** Прошлый план-предыстория или null для цепочки без истории. */
|
||||
val basePlanId: UUID? = null,
|
||||
/** Начало окна (UTC naive) — старт выбранной стартовой ЗРВ. */
|
||||
val windowStart: LocalDateTime,
|
||||
/** Конец окна (UTC naive) — конец выбранной конечной ЗРВ. */
|
||||
val windowStop: LocalDateTime,
|
||||
/** КПП стартовой ЗРВ. */
|
||||
val windowKppId: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Ответ создания плана новой цепочки: id плана-кандидата (DRAFT, применяется через `promote`) и
|
||||
* прогноз будущей цепочки от его окна (what-if, состояние КА не тронуто).
|
||||
*/
|
||||
data class CreateChainPlanResponse(
|
||||
val draftPlanId: UUID,
|
||||
val projectedChain: List<PlanResponse>
|
||||
)
|
||||
+26
@@ -17,6 +17,12 @@ data class MissionCloneRequest(
|
||||
|
||||
data class MissionCloneResponse(val newMissionId: UUID)
|
||||
|
||||
data class MissionBlankRequest(
|
||||
val satelliteId: Long,
|
||||
val missionStart: LocalDateTime,
|
||||
val missionStop: LocalDateTime
|
||||
)
|
||||
|
||||
@Component
|
||||
class MissionPlaningClient(properties: MissionPlaningProperties) {
|
||||
|
||||
@@ -47,6 +53,26 @@ class MissionPlaningClient(properties: MissionPlaningProperties) {
|
||||
"Пустой ответ mission-planing на клон миссии $missionId"
|
||||
)
|
||||
|
||||
/** Создаёт пустую миссию на окно (план новой цепочки «без истории»). */
|
||||
fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse =
|
||||
webClient.post()
|
||||
.uri("/api/missions/blank")
|
||||
.bodyValue(req)
|
||||
.retrieve()
|
||||
.onStatus({ it.isError }) { resp ->
|
||||
resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body ->
|
||||
val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY
|
||||
else HttpStatus.valueOf(resp.statusCode().value())
|
||||
ResponseStatusException(status, "Создание пустой миссии не удалось (${resp.statusCode()}): $body")
|
||||
}
|
||||
}
|
||||
.bodyToMono(MissionCloneResponse::class.java)
|
||||
.block(timeout)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"Пустой ответ mission-planing на создание пустой миссии"
|
||||
)
|
||||
|
||||
/** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */
|
||||
fun deleteMission(missionId: UUID) {
|
||||
webClient.delete()
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
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.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest
|
||||
import space.nstart.pcp_tgu_service.dto.CreateChainPlanResponse
|
||||
import space.nstart.pcp_tgu_service.dto.DraftMode
|
||||
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.integration.api.MissionBlankRequest
|
||||
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 space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Создаёт план новой цепочки (вкладка «Планы», действие оператора «Создать план») и сразу считает
|
||||
* прогноз будущей цепочки от него.
|
||||
*
|
||||
* Сценарий: КА что-то случилось / нужно перепланировать — оператор берёт за основу прошлый план
|
||||
* («предыстория») или начинает с нуля, выбирает окно из двух реальных ЗРВ и получает план-кандидат
|
||||
* (DRAFT). Кандидат не меняет состояние КА: он становится рабочим только после ручной отправки
|
||||
* ([IssueDuePlanWorker.promoteDraftNow]) и приёмки (якорение цепочки в acceptPlan).
|
||||
*
|
||||
* Два режима наполнения:
|
||||
* - **с базой** (`basePlanId != null`): клон включений прошлого плана, пересёкшихся с окном
|
||||
* (тот же fork-forward-клон), `base_plan_id = basePlanId` (история тянется);
|
||||
* - **без базы**: пустая миссия, `base_plan_id = null` (новая история с нуля).
|
||||
*
|
||||
* Атомарность как в [CreateDraftUseCase]: DRAFT сохраняется только после успешного создания миссии;
|
||||
* при сбое сохранения — компенсация (удаление миссии).
|
||||
*/
|
||||
@Service
|
||||
class CreateChainPlanUseCase(
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val planProjectionService: PlanProjectionService,
|
||||
private val missionPlaningClient: MissionPlaningClient,
|
||||
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse {
|
||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||
|
||||
if (!req.windowStop.isAfter(req.windowStart)) {
|
||||
throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "windowStop должен быть позже windowStart")
|
||||
}
|
||||
if (!req.windowStart.isAfter(now)) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"windowStart (${req.windowStart}) должен быть строго в будущем"
|
||||
)
|
||||
}
|
||||
|
||||
// База (если задана) должна принадлежать тому же КА — иначе клон чужого содержимого.
|
||||
val base: PlannedPlanEntity? = req.basePlanId?.let { basePlanId ->
|
||||
val plan = plannedPlanRepository.findById(basePlanId)
|
||||
.orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Базовый план $basePlanId не найден") }
|
||||
if (plan.spacecraftId != req.spacecraftId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"Базовый план $basePlanId принадлежит другому КА (${plan.spacecraftId}, ожидался ${req.spacecraftId})"
|
||||
)
|
||||
}
|
||||
if (plan.status == PlannedPlanStatus.DRAFT) {
|
||||
throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Нельзя взять за основу черновик")
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
// Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой).
|
||||
val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId)
|
||||
if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"Окно [${req.windowStart}–${req.windowStop}, kpp=${req.windowKppId}] не набирается из реальных ЗРВ КА ${req.spacecraftId}"
|
||||
)
|
||||
}
|
||||
|
||||
// Создаём миссию-носитель содержимого: клон базы в окно либо пустая миссия.
|
||||
val newMissionId = if (base != null) {
|
||||
missionPlaningClient.cloneMission(
|
||||
missionId = base.planId,
|
||||
req = MissionCloneRequest(
|
||||
mode = DraftMode.FORK_FORWARD,
|
||||
windowStart = req.windowStart,
|
||||
windowStop = req.windowStop
|
||||
)
|
||||
).newMissionId
|
||||
} else {
|
||||
missionPlaningClient.createBlankMission(
|
||||
MissionBlankRequest(
|
||||
satelliteId = parseSatelliteId(req.spacecraftId),
|
||||
missionStart = req.windowStart,
|
||||
missionStop = req.windowStop
|
||||
)
|
||||
).newMissionId
|
||||
}
|
||||
|
||||
val draft = saveDraftWithCompensation(req, base, newMissionId)
|
||||
|
||||
// Прогноз будущей цепочки от окна нового плана (what-if, состояние КА не тронуто).
|
||||
val projected = planProjectionService
|
||||
.projectAfterWindow(req.spacecraftId, draft.toCalculatedPlan())
|
||||
.map(PlanResponse::projected)
|
||||
|
||||
return CreateChainPlanResponse(draftPlanId = draft.planId, projectedChain = projected)
|
||||
}
|
||||
|
||||
private fun saveDraftWithCompensation(
|
||||
req: CreateChainPlanRequest,
|
||||
base: PlannedPlanEntity?,
|
||||
newMissionId: UUID
|
||||
): PlannedPlanEntity {
|
||||
// Снимок версии цепочки — оптимистичный токен для promote (как в CreateDraftUseCase).
|
||||
val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(req.spacecraftId)
|
||||
.map { it.chainVersion }
|
||||
.orElse(0L)
|
||||
|
||||
return try {
|
||||
val draft = plannedPlanRepository.save(
|
||||
PlannedPlanEntity(
|
||||
planId = newMissionId,
|
||||
spacecraftId = req.spacecraftId,
|
||||
startTime = req.windowStart,
|
||||
endTime = req.windowStop,
|
||||
kppId = req.windowKppId,
|
||||
status = PlannedPlanStatus.DRAFT,
|
||||
basePlanId = base?.planId,
|
||||
chainVersion = baseChainVersion
|
||||
)
|
||||
)
|
||||
log.info(
|
||||
"Создан план новой цепочки: draftPlanId={}, basePlanId={}, spacecraftId={}, окно [{}–{}], chainVersion={}",
|
||||
draft.planId, base?.planId, req.spacecraftId, req.windowStart, req.windowStop, baseChainVersion
|
||||
)
|
||||
draft
|
||||
} catch (ex: Exception) {
|
||||
log.error(
|
||||
"Сбой сохранения плана новой цепочки {} после создания миссии; компенсация (удалить миссию)",
|
||||
newMissionId, ex
|
||||
)
|
||||
try {
|
||||
missionPlaningClient.deleteMission(newMissionId)
|
||||
} catch (compEx: Exception) {
|
||||
log.error("Компенсация не удалась: миссия {} осталась осиротевшей", newMissionId, compEx)
|
||||
}
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSatelliteId(spacecraftId: String): Long =
|
||||
spacecraftId.toLongOrNull()
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"spacecraftId '$spacecraftId' не числовой — нельзя создать пустую миссию без базы"
|
||||
)
|
||||
|
||||
private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan =
|
||||
CalculatedPlan(spacecraftId = spacecraftId, startTime = startTime, endTime = endTime, kppId = kppId)
|
||||
}
|
||||
+7
-21
@@ -230,36 +230,22 @@ class IssueDuePlanWorker(
|
||||
|
||||
/**
|
||||
* Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно
|
||||
* по-прежнему соответствовать реальной будущей ЗРВ. Для fork-forward слот есть в проекции;
|
||||
* для replace слот = окно заменяемого базового плана, которое проекция (заякоренная ПОСЛЕ
|
||||
* плана в полёте) не возвращает, поэтому окно базы учитывается явно. Сверка по КПП и времени
|
||||
* с точностью до секунды (как при создании черновика). Не совпало → снапшот сместился → 422.
|
||||
* по-прежнему набираться из реальных ЗРВ снапшота. Сверка против «сырых» окон ЗРВ единым
|
||||
* правилом [ZrvWindowMatcher] (начало+КПП одной зоны, конец — другой) — оно покрывает и окно
|
||||
* из одной ЗРВ (replace/fork-forward), и окно из двух ЗРВ (план новой цепочки). Не набирается →
|
||||
* снапшот сместился → 422.
|
||||
*/
|
||||
private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) {
|
||||
val candidates = planProjectionService.project(plan.spacecraftId)
|
||||
.map { Triple(it.kppId, it.startTime, it.endTime) }
|
||||
.toMutableList()
|
||||
plan.basePlanId
|
||||
?.let { plannedPlanRepository.findById(it).orElse(null) }
|
||||
?.let { candidates.add(Triple(it.kppId, it.startTime, it.endTime)) }
|
||||
|
||||
val matches = candidates.any { (kppId, start, end) ->
|
||||
kppId == plan.kppId &&
|
||||
diffSeconds(start, plan.startTime) < 1 &&
|
||||
diffSeconds(end, plan.endTime) < 1
|
||||
}
|
||||
if (!matches) {
|
||||
val rawWindows = planProjectionService.rawZrvWindows(plan.spacecraftId)
|
||||
if (!ZrvWindowMatcher.matches(rawWindows, plan.startTime, plan.endTime, plan.kppId)) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
"Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " +
|
||||
"больше не соответствует ни одной будущей ЗРВ — снапшот сместился, пересоздайте черновик"
|
||||
"больше не набирается из реальных ЗРВ — снапшот сместился, пересоздайте черновик"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long =
|
||||
Math.abs(java.time.Duration.between(a, b).toSeconds())
|
||||
|
||||
/**
|
||||
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
|
||||
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
||||
|
||||
+11
@@ -24,6 +24,17 @@ class PlanCalculationService(
|
||||
private val planningProperties: PlanningProperties
|
||||
) {
|
||||
|
||||
/**
|
||||
* Возвращает «сырые» окна ЗРВ КА (наблюдательные окна точек закладки как есть), без построения
|
||||
* цепочки и без подбора целевой длительности: одно окно = один [CalculatedPlan] `[startTime, endTime]`
|
||||
* с `kppId = pointId`. Из этих окон оператор вручную набирает план новой цепочки (старт и конец —
|
||||
* границы реальных ЗРВ), и против них же валидируется выбранное окно.
|
||||
*/
|
||||
fun rawWindows(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> =
|
||||
buildCandidates(spacecraftPoints).map { candidate ->
|
||||
candidate.toCalculatedPlan(spacecraftPoints.spacecraftId, candidate.endTime)
|
||||
}
|
||||
|
||||
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
|
||||
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
|
||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||
|
||||
+27
@@ -42,6 +42,33 @@ class PlanProjectionService(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* «Сырые» окна ЗРВ КА из последнего снапшота — границы реальных зон, из которых оператор
|
||||
* вручную набирает окно нового плана. Пусто, если снапшота нет. В отличие от [project] это
|
||||
* не цепочка, а исходные окна (без подбора длительности и нахлёста).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun rawZrvWindows(spacecraftId: String): List<CalculatedPlan> {
|
||||
val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null)
|
||||
?: return emptyList()
|
||||
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
||||
return planCalculationService.rawWindows(points)
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогноз будущей цепочки, если новый план занял окно [afterPlan]: считается тем же
|
||||
* [PlanCalculationService.calculatePlansAfter] поверх текущего снапшота ЗРВ, но якорь — не
|
||||
* состояние КА, а переданное окно. Используется при создании плана новой цепочки, чтобы сразу
|
||||
* показать оператору, как ляжет цепочка «от него». Состояние КА не трогается (what-if).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
fun projectAfterWindow(spacecraftId: String, afterPlan: CalculatedPlan): List<CalculatedPlan> {
|
||||
val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null)
|
||||
?: return emptyList()
|
||||
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
||||
return planCalculationService.calculatePlansAfter(points, afterPlan)
|
||||
}
|
||||
|
||||
/**
|
||||
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка.
|
||||
*
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* Сверка набранного оператором окна плана с «сырыми» ЗРВ КА.
|
||||
*
|
||||
* Окно собирается из двух реальных зон: старт (и КПП) берётся у одной ЗРВ, конец — у другой (не
|
||||
* раньше старта). Поэтому окно валидно, если:
|
||||
* - начало совпадает (с точностью до секунды) с началом какой-то ЗРВ **этого же КПП**, и
|
||||
* - конец совпадает с концом какой-то ЗРВ, и
|
||||
* - конец строго позже начала.
|
||||
*
|
||||
* Случай «одна ЗРВ» (текущие replace/fork-forward, где старт и конец — границы одной зоны) — частный
|
||||
* случай этого правила, поэтому проверка единая для создания плана и для контроля слота на promote.
|
||||
*/
|
||||
object ZrvWindowMatcher {
|
||||
|
||||
/** Допуск сравнения времён — компенсирует погрешность сериализации ISO-строк на фронте. */
|
||||
private const val TOLERANCE_SECONDS = 1L
|
||||
|
||||
fun matches(
|
||||
windows: List<CalculatedPlan>,
|
||||
windowStart: LocalDateTime,
|
||||
windowStop: LocalDateTime,
|
||||
windowKppId: String
|
||||
): Boolean {
|
||||
if (!windowStop.isAfter(windowStart)) {
|
||||
return false
|
||||
}
|
||||
val startMatches = windows.any { zrv ->
|
||||
zrv.kppId == windowKppId && diffSeconds(zrv.startTime, windowStart) < TOLERANCE_SECONDS
|
||||
}
|
||||
val stopMatches = windows.any { zrv ->
|
||||
diffSeconds(zrv.endTime, windowStop) < TOLERANCE_SECONDS
|
||||
}
|
||||
return startMatches && stopMatches
|
||||
}
|
||||
|
||||
private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long =
|
||||
Math.abs(Duration.between(a, b).toSeconds())
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.mockito.ArgumentMatchers
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.times
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionBlankRequest
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse
|
||||
import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class CreateChainPlanUseCaseTest {
|
||||
|
||||
private val spacecraftId = "25544"
|
||||
private val kppId = "KPP-1"
|
||||
|
||||
@Test
|
||||
fun `with base clones mission into the window and creates a DRAFT with basePlanId and chain preview`() {
|
||||
val base = plan(time(1), time(2), PlannedPlanStatus.LAID_IN)
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val windowStart = time(5)
|
||||
val windowStop = time(6)
|
||||
val chainTail = listOf(CalculatedPlan(spacecraftId, time(7), time(8), kppId))
|
||||
val fixture = fixture(base = base, cloneResult = newMissionId, projectedTail = chainTail, window = windowStart to windowStop)
|
||||
|
||||
val response = fixture.useCase.createChainPlan(
|
||||
CreateChainPlanRequest(spacecraftId, base.planId, windowStart, windowStop, kppId)
|
||||
)
|
||||
|
||||
assertEquals(newMissionId, response.draftPlanId)
|
||||
// Клон по окну (fork-forward-стиль), пустая миссия не создаётся.
|
||||
verify(fixture.client).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||
val saved = fixture.lastSaved!!
|
||||
assertEquals(newMissionId, saved.planId)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, saved.status)
|
||||
assertEquals(base.planId, saved.basePlanId)
|
||||
assertEquals(windowStart, saved.startTime)
|
||||
assertEquals(windowStop, saved.endTime)
|
||||
assertEquals(kppId, saved.kppId)
|
||||
// Прогноз цепочки от окна вернулся.
|
||||
assertEquals(1, response.projectedChain.size)
|
||||
assertEquals(PlannedPlanStatus.PLANNED, response.projectedChain.first().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without base creates a blank mission and a DRAFT with null basePlanId`() {
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val fixture = fixture(base = null, blankResult = newMissionId)
|
||||
|
||||
val response = fixture.useCase.createChainPlan(
|
||||
CreateChainPlanRequest(spacecraftId, basePlanId = null, time(5), time(6), kppId)
|
||||
)
|
||||
|
||||
assertEquals(newMissionId, response.draftPlanId)
|
||||
verify(fixture.client).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||
val saved = fixture.lastSaved!!
|
||||
assertEquals(newMissionId, saved.planId)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, saved.status)
|
||||
assertNull(saved.basePlanId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `window not matching any raw ZRV yields 422 and no mission is created`() {
|
||||
val fixture = fixture(base = null, rawWindows = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId)))
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `base belonging to another spacecraft yields 422`() {
|
||||
val base = plan(time(1), time(2), PlannedPlanStatus.LAID_IN).apply { spacecraftId = "99999" }
|
||||
val fixture = fixture(base = base)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, base.planId, time(5), time(6), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `windowStop not after windowStart yields 422`() {
|
||||
val fixture = fixture(base = null)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(6), time(6), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `windowStart in the past yields 422`() {
|
||||
val fixture = fixture(base = null)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(-1), time(1), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown base plan yields 404`() {
|
||||
val fixture = fixture(base = null)
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, UUID.randomUUID(), time(5), time(6), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-numeric spacecraftId without base yields 422`() {
|
||||
val fixture = fixture(base = null, spacecraftIdOverride = "SAT-A")
|
||||
val ex = assertThrows<ResponseStatusException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest("SAT-A", null, time(5), time(6), kppId))
|
||||
}
|
||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() {
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val fixture = fixture(base = null, blankResult = newMissionId, failSave = true)
|
||||
assertThrows<RuntimeException> {
|
||||
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId))
|
||||
}
|
||||
verify(fixture.client, times(1)).deleteMission(newMissionId)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private class Fixture(
|
||||
val useCase: CreateChainPlanUseCase,
|
||||
val client: MissionPlaningClient,
|
||||
private val savedHolder: Array<PlannedPlanEntity?>
|
||||
) {
|
||||
val lastSaved: PlannedPlanEntity? get() = savedHolder[0]
|
||||
}
|
||||
|
||||
private fun fixture(
|
||||
base: PlannedPlanEntity?,
|
||||
cloneResult: UUID = UUID.randomUUID(),
|
||||
blankResult: UUID = UUID.randomUUID(),
|
||||
rawWindows: List<CalculatedPlan>? = null,
|
||||
projectedTail: List<CalculatedPlan> = emptyList(),
|
||||
failSave: Boolean = false,
|
||||
spacecraftIdOverride: String = spacecraftId,
|
||||
window: Pair<LocalDateTime, LocalDateTime> = time(5) to time(6)
|
||||
): Fixture {
|
||||
val repo = mock(PlannedPlanRepository::class.java)
|
||||
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
||||
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
||||
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
|
||||
if (failSave) throw RuntimeException("DB down")
|
||||
val e = inv.arguments[0] as PlannedPlanEntity
|
||||
saved[0] = e
|
||||
e
|
||||
}
|
||||
|
||||
val projectionService = mock(PlanProjectionService::class.java)
|
||||
// По умолчанию сырые ЗРВ совпадают с запрашиваемым окном → валидно.
|
||||
val defaultRaw = listOf(CalculatedPlan(spacecraftIdOverride, window.first, window.second, kppId))
|
||||
`when`(projectionService.rawZrvWindows(spacecraftIdOverride)).thenReturn(rawWindows ?: defaultRaw)
|
||||
`when`(projectionService.projectAfterWindow(anyNonNull(), anyNonNull())).thenReturn(projectedTail)
|
||||
|
||||
val client = mock(MissionPlaningClient::class.java)
|
||||
`when`(client.cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>()))
|
||||
.thenReturn(MissionCloneResponse(newMissionId = cloneResult))
|
||||
`when`(client.createBlankMission(anyNonNull<MissionBlankRequest>()))
|
||||
.thenReturn(MissionCloneResponse(newMissionId = blankResult))
|
||||
|
||||
val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java)
|
||||
`when`(stateRepo.findById(spacecraftIdOverride))
|
||||
.thenReturn(Optional.of(SpacecraftPlanningStateEntity(spacecraftId = spacecraftIdOverride, chainVersion = 0)))
|
||||
|
||||
val useCase = CreateChainPlanUseCase(repo, projectionService, client, stateRepo)
|
||||
return Fixture(useCase, client, saved)
|
||||
}
|
||||
|
||||
private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
spacecraftId = spacecraftId,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
kppId = kppId,
|
||||
status = status
|
||||
)
|
||||
|
||||
// Фиксированная база времени на инстанс теста: time() детерминирован, окно в запросе и в
|
||||
// моке сырых ЗРВ совпадает байт-в-байт (без дрейфа now() между вызовами).
|
||||
private val t0: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||
private fun time(hour: Int): LocalDateTime = t0.plusHours(hour.toLong())
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun <T> anyNonNull(): T { ArgumentMatchers.any<T>(); return null as T }
|
||||
private fun anyUuid(): UUID { ArgumentMatchers.any<UUID>(); return UUID.randomUUID() }
|
||||
}
|
||||
+16
-5
@@ -297,7 +297,7 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))
|
||||
|
||||
worker.promoteDraftNow(draft.planId)
|
||||
|
||||
@@ -365,7 +365,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
// Слот валиден (окно совпадает с ЗРВ) — отказ именно из-за порога заморозки, а не слота.
|
||||
val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
|
||||
@@ -389,7 +390,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
activePlanId = UUID.randomUUID(),
|
||||
activeAttemptId = UUID.randomUUID()
|
||||
)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
// Слот валиден — отказ именно 409 (чужая выдача), а не 422 (слот).
|
||||
val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
|
||||
@@ -440,7 +442,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
attemptStore: MutableMap<UUID, PlanIssueAttemptEntity>,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
projection: List<CalculatedPlan> = emptyList(),
|
||||
projectionService: PlanProjectionService = projectionReturning(projection)
|
||||
rawWindows: List<CalculatedPlan> = emptyList(),
|
||||
projectionService: PlanProjectionService = projectionReturning(projection, rawWindows)
|
||||
): IssueDuePlanWorker {
|
||||
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
||||
`when`(snapshotRepository.findAllByActiveTrue()).thenReturn(
|
||||
@@ -464,12 +467,20 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun projectionReturning(projection: List<CalculatedPlan>): PlanProjectionService {
|
||||
private fun projectionReturning(
|
||||
projection: List<CalculatedPlan>,
|
||||
rawWindows: List<CalculatedPlan> = emptyList()
|
||||
): PlanProjectionService {
|
||||
val service = mock(PlanProjectionService::class.java)
|
||||
`when`(service.project(spacecraftId)).thenReturn(projection)
|
||||
`when`(service.rawZrvWindows(spacecraftId)).thenReturn(rawWindows)
|
||||
return service
|
||||
}
|
||||
|
||||
/** Сырое окно ЗРВ, совпадающее со слотом черновика (старт+КПП и конец) — слот проходит валидацию. */
|
||||
private fun rawWindowFor(plan: PlannedPlanEntity): CalculatedPlan =
|
||||
CalculatedPlan(spacecraftId = spacecraftId, startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId)
|
||||
|
||||
private fun plannedRepository(store: MutableMap<UUID, PlannedPlanEntity>): PlannedPlanRepository {
|
||||
val repository = mock(PlannedPlanRepository::class.java)
|
||||
`when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) }
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package space.nstart.pcp_tgu_service.service
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class ZrvWindowMatcherTest {
|
||||
|
||||
private val sc = "25544"
|
||||
private val t0 = LocalDateTime.now(ZoneOffset.UTC).withNano(0)
|
||||
private fun at(min: Long) = t0.plusMinutes(min)
|
||||
|
||||
private val zrvA = CalculatedPlan(sc, at(0), at(10), "KPP-1")
|
||||
private val zrvB = CalculatedPlan(sc, at(100), at(110), "KPP-2")
|
||||
private val windows = listOf(zrvA, zrvB)
|
||||
|
||||
@Test
|
||||
fun `window spanning two different ZRV (start of A, end of B) matches`() {
|
||||
assertTrue(ZrvWindowMatcher.matches(windows, at(0), at(110), "KPP-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single-ZRV window (start and end of the same zone) matches`() {
|
||||
assertTrue(ZrvWindowMatcher.matches(windows, at(0), at(10), "KPP-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start not aligned to any ZRV start does not match`() {
|
||||
assertFalse(ZrvWindowMatcher.matches(windows, at(3), at(110), "KPP-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start kpp mismatch does not match even if time aligns`() {
|
||||
assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(110), "KPP-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `end not aligned to any ZRV end does not match`() {
|
||||
assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(105), "KPP-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop not after start does not match`() {
|
||||
assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(0), "KPP-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty windows never match`() {
|
||||
assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user