pcp-tgu: якорь цепочки на LAID_IN, параллельные выдачи, SUPERSEDED
Базой цепочки план становится на ЗАКЛАДКЕ (LAID_IN), а не на приёмке.
После принятого плана можно отправить коррекцию — она тоже примется;
несколько выдач «в полёте» по одному КА теперь норма, а не конфликт.
- ACCEPT больше не двигает currentBasePlanId/chainVersion и не запирает
слот — только переводит план в AWAITING_LAYIN.
- Новый позитивный топик layin-confirmed + LayinConfirmedConsumer →
handleLayinConfirmed: AWAITING_LAYIN → LAID_IN, план становится базой
(chainVersion++), конкуренты на пересекающемся окне гасятся в новый
терминальный статус SUPERSEDED. Гард ±10 мин (LAYIN_MIN_SEPARATION_MINUTES):
нельзя заложить план со стартом в пределах 10 мин от уже заложенного (409).
- «В полёте» считается из статусов планов (ISSUING/WAITING_DECISION), а не
из единственного active-слота; снят гард «идёт выдача другого плана».
IssueDuePlanWorker гейтится hasBlockingInFlight, базу нового плана берёт
как latestCommittedPlanId; grace больше не двигает базу.
- PlanProjectionService якорит по самому дальнему committed-окну
{ISSUING, WAITING_DECISION, AWAITING_LAYIN, LAID_IN}.
- REJECT ставит паузу только если по КА не осталось живых кандидатов.
- CreateDraftUseCase: replace-черновик наследует ПРЕДШЕСТВЕННИКА исходного
плана (base.basePlanId), а не сам план — коррекция занимает тот же слот;
fork-forward по-прежнему ссылается на базу. Идемпотентность черновиков —
по слоту (КА + окно), а не по basePlanId.
- Тест-контроллер: POST /test/tgu/plans/{id}/confirm-layin (через BFF
pcp-ui-service) — имитация события «план заложен».
- Док PCP_TGU_PLAN_STATUS_LIFECYCLE обновлён (LAID_IN-переход, SUPERSEDED).
This commit is contained in:
+3
@@ -14,6 +14,9 @@ data class TopicsProperties(
|
||||
/** Topic-событие «не заложен» по принятому плану. */
|
||||
val layinFailed: String,
|
||||
|
||||
/** Topic-событие «план заложен» (позитивный фидбек закладки) — переводит план в LAID_IN. */
|
||||
val layinConfirmed: String,
|
||||
|
||||
/** Topic-событие «не принят» по принятому плану. */
|
||||
val planNotAccepted: String,
|
||||
|
||||
|
||||
+9
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
|
||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
|
||||
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
||||
@@ -68,6 +69,14 @@ class TestTguController(
|
||||
): PlanDecisionMessage =
|
||||
testPlanDecisionService.generateDecision(planId, decision, reason)
|
||||
|
||||
/** Генерирует событие «план заложен» (AWAITING_LAYIN → LAID_IN) для указанного persisted plan. */
|
||||
@PostMapping("/plans/{planId}/confirm-layin")
|
||||
fun confirmLayin(
|
||||
@PathVariable planId: UUID,
|
||||
@RequestParam(required = false) reason: String?
|
||||
): PlanExecutionFeedbackMessage =
|
||||
testPlanDecisionService.generateLayinConfirmed(planId, reason)
|
||||
|
||||
/** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */
|
||||
@PostMapping("/platforms/load-from-file")
|
||||
fun loadPlatformsFromFile(@RequestParam("path") filePath: String): TestPlatformsLoadResponse {
|
||||
|
||||
+7
@@ -29,6 +29,13 @@ enum class PlannedPlanStatus {
|
||||
/** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */
|
||||
NOT_ACCEPTED,
|
||||
|
||||
/**
|
||||
* Вытеснён: по тому же окну КА другой план ушёл в LAID_IN (заложен), а этот — конкурирующая
|
||||
* выдача/коррекция (ISSUING/WAITING_DECISION/AWAITING_LAYIN) на пересекающееся окно. Терминал,
|
||||
* цепочку не двигает. Решение по запоздавшей attempt такого плана записывается, но игнорируется.
|
||||
*/
|
||||
SUPERSEDED,
|
||||
|
||||
REJECTED,
|
||||
START_AMBIGUOUS
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package space.nstart.pcp_tgu_service.integration.kafka
|
||||
|
||||
import org.springframework.kafka.annotation.KafkaListener
|
||||
import org.springframework.stereotype.Component
|
||||
import space.nstart.pcp_tgu_service.config.TopicsProperties
|
||||
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
|
||||
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
||||
|
||||
/**
|
||||
* Kafka consumer события «план заложен» (позитивный фидбек закладки); делегирует перевод
|
||||
* принятого плана в LAID_IN (новая база цепочки) в use case.
|
||||
*/
|
||||
@Component
|
||||
class LayinConfirmedConsumer(
|
||||
val topicsProperties: TopicsProperties,
|
||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
|
||||
) {
|
||||
@KafkaListener(
|
||||
topics = ["#{__listener.topicsProperties.layinConfirmed}"],
|
||||
groupId = "\${spring.kafka.consumer.group-id}"
|
||||
)
|
||||
fun consume(message: PlanExecutionFeedbackMessage) {
|
||||
handlePlanDecisionUseCase.handleLayinConfirmed(message)
|
||||
}
|
||||
}
|
||||
+9
@@ -16,6 +16,15 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
||||
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
|
||||
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
|
||||
|
||||
/**
|
||||
* Планы КА в одном из статусов — для вычисления «в полёте» (ISSUING/WAITING_DECISION) и набора
|
||||
* конкурентов при закладке (supersede). Заменяет опору на единственный active-слот состояния.
|
||||
*/
|
||||
fun findAllBySpacecraftIdAndStatusIn(
|
||||
spacecraftId: String,
|
||||
statuses: Collection<PlannedPlanStatus>
|
||||
): List<PlannedPlanEntity>
|
||||
|
||||
/**
|
||||
* Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков:
|
||||
* сочетание (КА + начало + конец) уникально среди DRAFT.
|
||||
|
||||
+35
-15
@@ -47,18 +47,6 @@ class CreateDraftUseCase(
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
// Идемпотентность: один открытый черновик на базовый план. Повторный клик «создать черновик»
|
||||
// не плодит вторую миссию-клон и вторую DRAFT-строку.
|
||||
val existingDraft = plannedPlanRepository
|
||||
.findAllByBasePlanIdAndStatus(base.planId, PlannedPlanStatus.DRAFT)
|
||||
.firstOrNull()
|
||||
if (existingDraft != null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"У плана ${base.planId} уже есть черновик ${existingDraft.planId} — завершите или отмените его"
|
||||
)
|
||||
}
|
||||
|
||||
return when (req.mode) {
|
||||
DraftMode.REPLACE -> createReplaceDraft(base, now)
|
||||
DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, now)
|
||||
@@ -85,14 +73,23 @@ class CreateDraftUseCase(
|
||||
)
|
||||
}
|
||||
|
||||
// Идемпотентность: один открытый черновик на слот (КА + окно). Повторный клик не плодит
|
||||
// вторую миссию-клон и вторую DRAFT-строку. Окно replace-черновика 1:1 совпадает с базой.
|
||||
assertNoOpenDraftForWindow(base.spacecraftId, base.startTime, base.endTime)
|
||||
|
||||
val cloneResponse = missionPlaningClient.cloneMission(
|
||||
missionId = base.planId,
|
||||
req = MissionCloneRequest(mode = DraftMode.REPLACE)
|
||||
)
|
||||
|
||||
// Replace-черновик — это КОРРЕКЦИЯ исходного плана: он занимает тот же слот цепочки, что и
|
||||
// база, поэтому его предшественник (basePlanId) = предшественник базы (base.basePlanId), а
|
||||
// НЕ сама база. Так после закладки коррекция вытеснит исходный план на том же окне, а на
|
||||
// таймлайне стрелка коррекции ведёт к тому же предыдущему плану, что и у исходного.
|
||||
return saveDraftWithCompensation(
|
||||
draftPlanId = cloneResponse.newMissionId,
|
||||
base = base,
|
||||
draftBasePlanId = base.basePlanId,
|
||||
startTime = base.startTime,
|
||||
endTime = base.endTime,
|
||||
kppId = base.kppId,
|
||||
@@ -128,14 +125,20 @@ class CreateDraftUseCase(
|
||||
// Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции.
|
||||
assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId)
|
||||
|
||||
// Идемпотентность: один открытый черновик на слот (КА + окно будущей ЗРВ).
|
||||
assertNoOpenDraftForWindow(base.spacecraftId, windowStart, windowStop)
|
||||
|
||||
val cloneResponse = missionPlaningClient.cloneMission(
|
||||
missionId = base.planId,
|
||||
req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop)
|
||||
)
|
||||
|
||||
// Fork-forward — это ПРОДОЛЖЕНИЕ цепочки на будущую ЗРВ ПОСЛЕ базы (база заложена/в прошлом),
|
||||
// поэтому предшественник нового плана = сама база (base.planId).
|
||||
return saveDraftWithCompensation(
|
||||
draftPlanId = cloneResponse.newMissionId,
|
||||
base = base,
|
||||
draftBasePlanId = base.planId,
|
||||
startTime = windowStart,
|
||||
endTime = windowStop,
|
||||
kppId = windowKppId,
|
||||
@@ -143,6 +146,22 @@ class CreateDraftUseCase(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Гард дублей: на (КА + окно) допускается не более одного открытого черновика. Заменяет прежний
|
||||
* гард «один черновик на базовый план» — теперь ключ слота это окно, а не исходный план.
|
||||
*/
|
||||
private fun assertNoOpenDraftForWindow(spacecraftId: String, start: LocalDateTime, end: LocalDateTime) {
|
||||
val existing = plannedPlanRepository
|
||||
.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(spacecraftId, start, end, PlannedPlanStatus.DRAFT)
|
||||
.firstOrNull()
|
||||
if (existing != null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"По КА $spacecraftId уже есть черновик ${existing.planId} на это окно — завершите или отмените его"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, что тройка (windowStart, windowStop, kppId) соответствует хотя бы одному
|
||||
* плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность
|
||||
@@ -178,6 +197,7 @@ class CreateDraftUseCase(
|
||||
private fun saveDraftWithCompensation(
|
||||
draftPlanId: UUID,
|
||||
base: PlannedPlanEntity,
|
||||
draftBasePlanId: UUID?,
|
||||
startTime: LocalDateTime,
|
||||
endTime: LocalDateTime,
|
||||
kppId: String,
|
||||
@@ -199,13 +219,13 @@ class CreateDraftUseCase(
|
||||
endTime = endTime,
|
||||
kppId = kppId,
|
||||
status = PlannedPlanStatus.DRAFT,
|
||||
basePlanId = base.planId,
|
||||
basePlanId = draftBasePlanId,
|
||||
chainVersion = baseChainVersion
|
||||
)
|
||||
)
|
||||
log.info(
|
||||
"Создан черновик плана: draftPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
|
||||
draft.planId, base.planId, base.spacecraftId, baseChainVersion
|
||||
"Создан черновик плана: draftPlanId={}, srcPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}",
|
||||
draft.planId, base.planId, draftBasePlanId, base.spacecraftId, baseChainVersion
|
||||
)
|
||||
CreateDraftResponse(draftPlanId = draft.planId)
|
||||
} catch (ex: Exception) {
|
||||
|
||||
+119
-24
@@ -18,6 +18,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
@@ -70,24 +71,107 @@ class HandlePlanDecisionUseCase(
|
||||
if (attempt.status.isTerminal()) {
|
||||
return
|
||||
}
|
||||
if (state.activeAttemptId != attempt.attemptId) {
|
||||
// Несколько выдач по КА могут лететь одновременно (исходный план + коррекция того же окна),
|
||||
// поэтому решение применяем к своему плану по его статусу, а не сверяя с единственным
|
||||
// active-слотом. Если план уже терминальный (вытеснен заложенной коррекцией / отклонён) —
|
||||
// запоздавшее решение просто пишем на attempt, цепочку им не двигаем.
|
||||
if (plan.status !in IN_FLIGHT_STATUSES) {
|
||||
recordDecisionOnAttempt(message, attempt)
|
||||
planIssueAttemptRepository.save(attempt)
|
||||
return
|
||||
}
|
||||
|
||||
when (message.decision) {
|
||||
PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan, state)
|
||||
PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan)
|
||||
PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(layin-confirmed): реализовать приём события «план заложен» (положительный фидбек по закладке).
|
||||
// Сейчас принятый план остаётся в AWAITING_LAYIN (оптимистично «заложен») и в LAID_IN никогда не
|
||||
// переходит — нет потребителя этого события. Нужно: Kafka-consumer (по аналогии с LayinFailedConsumer)
|
||||
// + handleLayinConfirmed, который по текущему базовому плану в AWAITING_LAYIN переводит его в
|
||||
// PlannedPlanStatus.LAID_IN (терминальный успех, «точка невозврата»: дальше только fork-forward).
|
||||
// См. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md.
|
||||
/**
|
||||
* Событие «план заложен» (позитивный фидбек закладки): принятый план переходит в LAID_IN и
|
||||
* становится базой цепочки. Именно закладка — а не ACCEPT — двигает якорь и chainVersion,
|
||||
* поэтому до неё по одному окну могут одновременно «лететь» исходный план и его коррекция.
|
||||
* Все ещё не заложенные конкуренты на пересекающееся окно гасятся в SUPERSEDED.
|
||||
*/
|
||||
@Transactional
|
||||
fun handleLayinConfirmed(message: PlanExecutionFeedbackMessage) {
|
||||
if (!claimEvent(message.eventId)) {
|
||||
return
|
||||
}
|
||||
|
||||
val plan = plannedPlanRepository.lockByPlanId(message.planId)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found")
|
||||
if (plan.spacecraftId != message.spacecraftId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Layin spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}"
|
||||
)
|
||||
}
|
||||
|
||||
val state = getOrCreateLockedState(message.spacecraftId)
|
||||
if (plan.status != PlannedPlanStatus.AWAITING_LAYIN) {
|
||||
// Закладка по плану не в статусе «ждёт закладки» (устаревшее/дубль/уже вытеснен) — игнор.
|
||||
log.info("Layin-confirmed ignored: plan {} is not AWAITING_LAYIN (status={}).", plan.planId, plan.status)
|
||||
return
|
||||
}
|
||||
|
||||
// Нельзя заложить план, если по КА уже есть заложенный (LAID_IN) со стартом в пределах
|
||||
// ±[LAYIN_MIN_SEPARATION_MINUTES] минут — две закладки на практически одно и то же время
|
||||
// недопустимы (один заложенный слот на окно).
|
||||
val nearbyLaidIn = plannedPlanRepository
|
||||
.findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, setOf(PlannedPlanStatus.LAID_IN))
|
||||
.firstOrNull { laid ->
|
||||
laid.planId != plan.planId &&
|
||||
Duration.between(laid.startTime, plan.startTime).abs().toMinutes() < LAYIN_MIN_SEPARATION_MINUTES
|
||||
}
|
||||
if (nearbyLaidIn != null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"По КА ${plan.spacecraftId} уже заложен план ${nearbyLaidIn.planId} со стартом в пределах " +
|
||||
"$LAYIN_MIN_SEPARATION_MINUTES мин — закладка на то же время недопустима"
|
||||
)
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
plan.status = PlannedPlanStatus.LAID_IN
|
||||
plan.updatedAt = now
|
||||
|
||||
// База/версия двигаются только здесь. Снимаем паузу и срочную перевыдачу — окно закрыто.
|
||||
state.currentBasePlanId = plan.planId
|
||||
state.chainVersion += 1
|
||||
state.urgentReissueFrom = null
|
||||
state.pausedAfterReject = false
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.updatedAt = now
|
||||
|
||||
supersedeCompetingPlans(plan, now)
|
||||
|
||||
plannedPlanRepository.save(plan)
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
}
|
||||
|
||||
/** Гасит в SUPERSEDED ещё не заложенные планы КА, чьё окно пересекается с заложенным. */
|
||||
private fun supersedeCompetingPlans(laidIn: PlannedPlanEntity, now: LocalDateTime) {
|
||||
val competitors = plannedPlanRepository
|
||||
.findAllBySpacecraftIdAndStatusIn(laidIn.spacecraftId, LIVE_UNLAID_STATUSES)
|
||||
.filter { it.planId != laidIn.planId && overlaps(it, laidIn) }
|
||||
if (competitors.isEmpty()) {
|
||||
return
|
||||
}
|
||||
for (competitor in competitors) {
|
||||
competitor.status = PlannedPlanStatus.SUPERSEDED
|
||||
competitor.updatedAt = now
|
||||
}
|
||||
plannedPlanRepository.saveAll(competitors)
|
||||
log.info(
|
||||
"Plan {} laid in; superseded {} competing plan(s) on overlapping window.",
|
||||
laidIn.planId, competitors.size
|
||||
)
|
||||
}
|
||||
|
||||
private fun overlaps(a: PlannedPlanEntity, b: PlannedPlanEntity): Boolean =
|
||||
a.startTime.isBefore(b.endTime) && b.startTime.isBefore(a.endTime)
|
||||
|
||||
/** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */
|
||||
@Transactional
|
||||
@@ -151,8 +235,7 @@ class HandlePlanDecisionUseCase(
|
||||
private fun acceptPlan(
|
||||
message: PlanDecisionMessage,
|
||||
attempt: PlanIssueAttemptEntity,
|
||||
plan: PlannedPlanEntity,
|
||||
state: SpacecraftPlanningStateEntity
|
||||
plan: PlannedPlanEntity
|
||||
) {
|
||||
val now = LocalDateTime.now()
|
||||
recordDecisionOnAttempt(message, attempt)
|
||||
@@ -161,17 +244,11 @@ class HandlePlanDecisionUseCase(
|
||||
plan.acceptedAt = message.decisionTime
|
||||
plan.updatedAt = now
|
||||
|
||||
state.currentBasePlanId = plan.planId
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = null
|
||||
state.pausedAfterReject = false
|
||||
state.chainVersion += 1
|
||||
state.updatedAt = now
|
||||
|
||||
// ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт
|
||||
// закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN
|
||||
// ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна.
|
||||
planIssueAttemptRepository.save(attempt)
|
||||
plannedPlanRepository.save(plan)
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
|
||||
}
|
||||
|
||||
@@ -187,14 +264,17 @@ class HandlePlanDecisionUseCase(
|
||||
plan.status = PlannedPlanStatus.REJECTED
|
||||
plan.updatedAt = now
|
||||
|
||||
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный», и повторно выдавать тот же
|
||||
// план бессмысленно. Снимаем активную выдачу, гасим срочную перевыдачу и ставим цепочку на
|
||||
// паузу (pausedAfterReject) — проекция станет пустой, воркер ничего не выдаст, пока оператор
|
||||
// вручную не создаст исправленный план (его promote/ручная выдача снимут паузу). Базу не трогаем.
|
||||
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный». Но при нескольких выдачах
|
||||
// по КА отказ одной не должен глушить цепочку, если живёт другой кандидат (в полёте/принят,
|
||||
// но не заложен) — он ещё может закрыть окно. Паузу (pausedAfterReject) ставим, только если
|
||||
// других живых кандидатов не осталось: тогда проекция пуста, и оператор создаёт план вручную.
|
||||
val hasOtherLiveCandidate = plannedPlanRepository
|
||||
.findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, LIVE_UNLAID_STATUSES)
|
||||
.any { it.planId != plan.planId }
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = null
|
||||
state.pausedAfterReject = true
|
||||
state.pausedAfterReject = !hasOtherLiveCandidate
|
||||
state.updatedAt = now
|
||||
|
||||
planIssueAttemptRepository.save(attempt)
|
||||
@@ -234,4 +314,19 @@ class HandlePlanDecisionUseCase(
|
||||
|
||||
private fun PlanIssueAttemptStatus.isTerminal(): Boolean =
|
||||
this != PlanIssueAttemptStatus.STARTING && this != PlanIssueAttemptStatus.STARTED
|
||||
|
||||
private companion object {
|
||||
/** Минимальный разнос по старту между двумя заложенными планами КА (минуты). */
|
||||
const val LAYIN_MIN_SEPARATION_MINUTES = 10L
|
||||
|
||||
/** Выданный, но ещё не решённый план — его решение двигает цепочку. */
|
||||
val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION)
|
||||
|
||||
/** Живые, но ещё не заложенные планы КА: кандидаты на supersede и на «есть ли ещё кандидат». */
|
||||
val LIVE_UNLAID_STATUSES = setOf(
|
||||
PlannedPlanStatus.ISSUING,
|
||||
PlannedPlanStatus.WAITING_DECISION,
|
||||
PlannedPlanStatus.AWAITING_LAYIN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+52
-51
@@ -85,7 +85,10 @@ class IssueDuePlanWorker(
|
||||
private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val state = getOrCreateLockedState(spacecraftId)
|
||||
if (state.activeAttemptId != null && !tryAdvancePastUnconfirmedPlan(state, now)) {
|
||||
// Форвард-цепочку не выдаём, пока по КА есть выдача «в работе»: ISSUING или WAITING_DECISION
|
||||
// в пределах grace после старта. Прошедший grace больше не блокирует — молчаливо продолжаем
|
||||
// (базу/версию это НЕ двигает, их двигает только LAID_IN).
|
||||
if (hasBlockingInFlight(spacecraftId, now)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -114,7 +117,8 @@ class IssueDuePlanWorker(
|
||||
return null
|
||||
}
|
||||
|
||||
val plan = materializeIssuingPlan(head, state, issueNotBefore, now)
|
||||
val basePlanId = latestCommittedPlanId(spacecraftId, state.currentBasePlanId)
|
||||
val plan = materializeIssuingPlan(head, basePlanId, state.chainVersion, issueNotBefore, now)
|
||||
val attempt = createStartingAttempt(plan.planId, now)
|
||||
|
||||
state.activePlanId = plan.planId
|
||||
@@ -127,45 +131,33 @@ class IssueDuePlanWorker(
|
||||
}
|
||||
|
||||
/**
|
||||
* Если активная выдача — это план в WAITING_DECISION, по которому уже прошло
|
||||
* [PlanningProperties.waitingDecisionGraceMinutes] минут после старта и решения так и нет,
|
||||
* перестаём ждать подтверждение: снимаем блокировку (activePlanId/activeAttemptId),
|
||||
* двигаем якорь цепочки на этот план и идём дальше. Статус плана НЕ меняем — он остаётся
|
||||
* WAITING_DECISION (молчаливое продолжение, а не ACCEPTED). Attempt оставляем как есть:
|
||||
* запоздалое решение по нему просто запишется на attempt, не трогая ушедшую вперёд цепочку.
|
||||
*
|
||||
* @return true, если блокировка снята и можно выдавать следующий план в этом же тике.
|
||||
* По КА есть выдача «в работе», блокирующая авто-выдачу следующего окна форвард-цепочки:
|
||||
* ISSUING (только что выдан, решения ещё нет) или WAITING_DECISION в пределах
|
||||
* [PlanningProperties.waitingDecisionGraceMinutes] после старта. WAITING_DECISION за grace
|
||||
* больше не блокирует — молчаливо продолжаем цепочку (статус плана не меняем; базу/версию
|
||||
* двигает только LAID_IN). Несколько выдач по КА (исходный план + коррекция) допустимы —
|
||||
* блокирует наличие хотя бы одной «в работе».
|
||||
*/
|
||||
private fun tryAdvancePastUnconfirmedPlan(state: SpacecraftPlanningStateEntity, now: LocalDateTime): Boolean {
|
||||
val activePlanId = state.activePlanId ?: return false
|
||||
val plan = plannedPlanRepository.findById(activePlanId).orElse(null) ?: return false
|
||||
if (plan.status != PlannedPlanStatus.WAITING_DECISION) {
|
||||
return false
|
||||
}
|
||||
if (now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))) {
|
||||
return false
|
||||
private fun hasBlockingInFlight(spacecraftId: String, now: LocalDateTime): Boolean =
|
||||
plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, BLOCKING_INFLIGHT_STATUSES).any { plan ->
|
||||
plan.status == PlannedPlanStatus.ISSUING ||
|
||||
now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes))
|
||||
}
|
||||
|
||||
state.currentBasePlanId = plan.planId
|
||||
state.activePlanId = null
|
||||
state.activeAttemptId = null
|
||||
state.urgentReissueFrom = null
|
||||
state.chainVersion += 1
|
||||
state.updatedAt = now
|
||||
spacecraftPlanningStateJpaRepository.save(state)
|
||||
|
||||
log.info(
|
||||
"Plan {} (spacecraft {}) stayed WAITING_DECISION past grace; continuing chain without confirmation.",
|
||||
plan.planId, plan.spacecraftId
|
||||
)
|
||||
return true
|
||||
}
|
||||
/**
|
||||
* planId самого дальнего (по endTime) зафиксированного окна КА — провенанс (basePlanId) для
|
||||
* следующего выданного плана форвард-цепочки. Включает ещё не заложенные выдачи, чтобы цепочка
|
||||
* связывалась с непосредственно предыдущим окном, а не с последней заложенной базой.
|
||||
*/
|
||||
private fun latestCommittedPlanId(spacecraftId: String, fallback: UUID?): UUID? =
|
||||
plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
|
||||
.maxByOrNull { plan -> plan.endTime }?.planId ?: fallback
|
||||
|
||||
private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? {
|
||||
val now = LocalDateTime.now()
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||
assertReissuableInSlot(plan, state, now)
|
||||
assertReissuableInSlot(plan, now)
|
||||
|
||||
plan.status = PlannedPlanStatus.ISSUING
|
||||
plan.updatedAt = now
|
||||
@@ -196,7 +188,7 @@ class IssueDuePlanWorker(
|
||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||
assertDraftBaseUnchanged(plan, state)
|
||||
assertDraftSlotStillValid(plan)
|
||||
assertReissuableInSlot(plan, state, now)
|
||||
assertReissuableInSlot(plan, now)
|
||||
|
||||
plan.status = PlannedPlanStatus.ISSUING
|
||||
plan.updatedAt = now
|
||||
@@ -255,25 +247,17 @@ class IssueDuePlanWorker(
|
||||
/**
|
||||
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
|
||||
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
||||
* - по КА нет активной выдачи ДРУГОГО плана (иначе 409 CONFLICT — две выдачи разом недопустимы);
|
||||
* - план не подтверждён как заложенный (`status != LAID_IN` — иначе 422, доступен только fork-forward);
|
||||
* - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 — окно заморожено).
|
||||
*
|
||||
* Важно: повторную ручную выдачу ТОГО ЖЕ плана разрешаем, даже если по нему уже летит attempt
|
||||
* (например, план только что ушёл в авто-выдачу за `notificationBeforeStartMinutes`). Оператор сохраняет
|
||||
* право перевыдать именно этот план вплоть до порога заморозки — новая attempt замещает текущую in-flight
|
||||
* (запоздалое решение по старой attempt запишется как stale и цепочку не тронет).
|
||||
* Гарда «по КА уже идёт выдача другого плана» больше НЕТ: несколько выдач по КА одновременно —
|
||||
* норма (исходный план + коррекция того же окна). Конкуренция разрешается на закладке: ушедший
|
||||
* в LAID_IN план гасит остальные в SUPERSEDED ([HandlePlanDecisionUseCase.handleLayinConfirmed]).
|
||||
*
|
||||
* Узкого списка «перевыдаваемых» статусов больше нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN,
|
||||
* пока план в будущем за порогом и не заложен.
|
||||
* Узкого списка «перевыдаваемых» статусов нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN, пока план
|
||||
* в будущем за порогом заморозки и не заложен.
|
||||
*/
|
||||
private fun assertReissuableInSlot(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity, now: LocalDateTime) {
|
||||
if (state.activeAttemptId != null && state.activePlanId != plan.planId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"По КА ${plan.spacecraftId} уже идёт выдача другого плана (${state.activePlanId}) — дождитесь её завершения"
|
||||
)
|
||||
}
|
||||
private fun assertReissuableInSlot(plan: PlannedPlanEntity, now: LocalDateTime) {
|
||||
if (plan.status == PlannedPlanStatus.LAID_IN) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
@@ -292,7 +276,8 @@ class IssueDuePlanWorker(
|
||||
|
||||
private fun materializeIssuingPlan(
|
||||
head: CalculatedPlan,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
basePlanId: UUID?,
|
||||
chainVersion: Long,
|
||||
issueNotBefore: LocalDateTime,
|
||||
now: LocalDateTime
|
||||
): PlannedPlanEntity =
|
||||
@@ -303,8 +288,8 @@ class IssueDuePlanWorker(
|
||||
endTime = head.endTime,
|
||||
kppId = head.kppId,
|
||||
issueNotBefore = issueNotBefore,
|
||||
basePlanId = state.currentBasePlanId,
|
||||
chainVersion = state.chainVersion,
|
||||
basePlanId = basePlanId,
|
||||
chainVersion = chainVersion,
|
||||
now = now
|
||||
)
|
||||
|
||||
@@ -388,4 +373,20 @@ class IssueDuePlanWorker(
|
||||
val planId: UUID,
|
||||
val attemptId: UUID
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** Выдачи «в работе», по которым решение ещё ожидается (для гейта авто-выдачи). */
|
||||
val BLOCKING_INFLIGHT_STATUSES = setOf(
|
||||
PlannedPlanStatus.ISSUING,
|
||||
PlannedPlanStatus.WAITING_DECISION
|
||||
)
|
||||
|
||||
/** Зафиксированные окна КА (выданы/приняты/заложены) — источник провенанса форвард-цепочки. */
|
||||
val COMMITTED_STATUSES = setOf(
|
||||
PlannedPlanStatus.ISSUING,
|
||||
PlannedPlanStatus.WAITING_DECISION,
|
||||
PlannedPlanStatus.AWAITING_LAYIN,
|
||||
PlannedPlanStatus.LAID_IN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-18
@@ -41,7 +41,7 @@ class PlanProjectionService(
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val anchorPlan = resolveAnchorPlan(state)
|
||||
val anchorPlan = resolveAnchorPlan(spacecraftId, state)
|
||||
return if (anchorPlan == null) {
|
||||
planCalculationService.calculatePlans(points)
|
||||
} else {
|
||||
@@ -77,23 +77,21 @@ class PlanProjectionService(
|
||||
}
|
||||
|
||||
/**
|
||||
* Якорь, после которого строится будущая цепочка: самая дальняя уже зафиксированная точка.
|
||||
* Якорь, после которого строится будущая цепочка: самое дальнее (по endTime) зафиксированное
|
||||
* окно КА среди ISSUING/WAITING_DECISION/AWAITING_LAYIN/LAID_IN.
|
||||
*
|
||||
* Если есть выданный, но ещё не принятый план в полёте (ISSUING/WAITING_DECISION) — цепочку
|
||||
* продолжаем ПОСЛЕ него, а не после принятого базового плана. Иначе проекция заново вывела бы
|
||||
* уже выданный слот и при дрейфе снапшота ЗРВ (сместился КПП или границы окна) нарисовала бы
|
||||
* его призрак-дубль PLANNED рядом с реальным WAITING_DECISION: точечный дедуп по
|
||||
* businessKey в [PlanQueryService] такой рассинхрон не ловит.
|
||||
*
|
||||
* `activePlanId` заполнен только вместе с активной выдачей; в пути выдачи он к моменту
|
||||
* вызова проекции уже снят, поэтому якорение на план в полёте не влияет на выбор головы.
|
||||
* Берём именно крайнее зафиксированное окно (а не «принятую базу»), потому что выдач по КА может
|
||||
* быть несколько одновременно (исходный план + коррекция), и базой цепочка становится только на
|
||||
* закладке (LAID_IN). Якорение на крайнее окно не даёт проекции заново вывести уже выданный слот
|
||||
* и нарисовать его призрак-дубль PLANNED при дрейфе снапшота ЗРВ. Если зафиксированных окон нет —
|
||||
* откатываемся на `currentBasePlanId` (последняя заложенная база), иначе строим с нуля.
|
||||
*/
|
||||
private fun resolveAnchorPlan(state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? {
|
||||
val inFlight = state?.activePlanId
|
||||
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
|
||||
?.takeIf { plan -> plan.status in IN_FLIGHT_STATUSES }
|
||||
if (inFlight != null) {
|
||||
return inFlight
|
||||
private fun resolveAnchorPlan(spacecraftId: String, state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? {
|
||||
val committed = plannedPlanRepository
|
||||
.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES)
|
||||
.maxByOrNull { plan -> plan.endTime }
|
||||
if (committed != null) {
|
||||
return committed
|
||||
}
|
||||
return state?.currentBasePlanId
|
||||
?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) }
|
||||
@@ -108,7 +106,12 @@ class PlanProjectionService(
|
||||
)
|
||||
|
||||
private companion object {
|
||||
/** Статусы выданного, но ещё не принятого плана — он уже фиксирует слот цепочки. */
|
||||
val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION)
|
||||
/** Зафиксированные окна КА (выданы/приняты/заложены) — кандидаты в якорь будущей цепочки. */
|
||||
val COMMITTED_STATUSES = setOf(
|
||||
PlannedPlanStatus.ISSUING,
|
||||
PlannedPlanStatus.WAITING_DECISION,
|
||||
PlannedPlanStatus.AWAITING_LAYIN,
|
||||
PlannedPlanStatus.LAID_IN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-22
@@ -6,53 +6,37 @@ import org.springframework.stereotype.Service
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||
import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Test-only helper: генерирует decision event для текущей active attempt persisted plan.
|
||||
* Test-only helper: генерирует decision event для последней (живой) attempt persisted plan.
|
||||
*/
|
||||
@ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true")
|
||||
@Service
|
||||
class TestPlanDecisionService(
|
||||
private val plannedPlanRepository: PlannedPlanRepository,
|
||||
private val planIssueAttemptRepository: PlanIssueAttemptRepository,
|
||||
private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository,
|
||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase
|
||||
) {
|
||||
/**
|
||||
* Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer.
|
||||
* Решение адресуется последней attempt плана напрямую — выдач по КА может быть несколько разом,
|
||||
* единого active-слота больше нет.
|
||||
*/
|
||||
fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage {
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
|
||||
val state = spacecraftPlanningStateJpaRepository.findById(plan.spacecraftId).orElse(null)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Planning state for spacecraftId=${plan.spacecraftId} was not found"
|
||||
)
|
||||
if (state.activePlanId != planId || state.activeAttemptId == null) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Plan $planId is not the active plan waiting for decision"
|
||||
)
|
||||
}
|
||||
|
||||
val attempt = planIssueAttemptRepository.findById(state.activeAttemptId!!).orElse(null)
|
||||
val attempt = planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)
|
||||
?: throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Active attempt ${state.activeAttemptId} for plan $planId was not found"
|
||||
"Plan $planId has no issue attempt to decide on"
|
||||
)
|
||||
if (attempt.planId != planId) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
"Active attempt ${attempt.attemptId} does not belong to plan $planId"
|
||||
)
|
||||
}
|
||||
if (attempt.status !in ACTIVE_ATTEMPT_STATUSES) {
|
||||
throw ResponseStatusException(
|
||||
HttpStatus.CONFLICT,
|
||||
@@ -73,6 +57,26 @@ class TestPlanDecisionService(
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт synthetic событие «план заложен» и проводит его через тот же use case, что Kafka
|
||||
* consumer ([HandlePlanDecisionUseCase.handleLayinConfirmed]) — для ручной проверки перехода
|
||||
* AWAITING_LAYIN → LAID_IN из UI.
|
||||
*/
|
||||
fun generateLayinConfirmed(planId: UUID, reason: String?): PlanExecutionFeedbackMessage {
|
||||
val plan = plannedPlanRepository.findById(planId).orElse(null)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
|
||||
|
||||
val message = PlanExecutionFeedbackMessage(
|
||||
eventId = UUID.randomUUID(),
|
||||
spacecraftId = plan.spacecraftId,
|
||||
planId = plan.planId,
|
||||
occurredAt = LocalDateTime.now(),
|
||||
reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON
|
||||
)
|
||||
handlePlanDecisionUseCase.handleLayinConfirmed(message)
|
||||
return message
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED)
|
||||
const val DEFAULT_REASON = "TEST_CONTROLLER"
|
||||
|
||||
@@ -31,6 +31,7 @@ topics:
|
||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
|
||||
layin-failed: pcp.tgu.layin-failed.v1
|
||||
layin-confirmed: pcp.tgu.layin-confirmed.v1
|
||||
plan-not-accepted: pcp.tgu.plan-not-accepted.v1
|
||||
station-params-changed: pcp.tgu.station-params-changed.v1
|
||||
|
||||
|
||||
+30
-8
@@ -34,8 +34,11 @@ class CreateDraftUseCaseTest {
|
||||
// --- replace ---
|
||||
|
||||
@Test
|
||||
fun `replace creates DRAFT with cloned missionId as planId, base window and basePlanId`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
fun `replace creates DRAFT inheriting the source's base (predecessor), not the source itself`() {
|
||||
// База исходного плана — это его предшественник в цепочке. Replace-черновик — коррекция,
|
||||
// занимающая тот же слот, поэтому он наследует ИМЕННО предшественника, а не сам исходный план.
|
||||
val predecessor = UUID.randomUUID()
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = predecessor)
|
||||
val newMissionId = UUID.randomUUID()
|
||||
val fixture = fixture(base, cloneResult = newMissionId)
|
||||
|
||||
@@ -45,12 +48,22 @@ class CreateDraftUseCaseTest {
|
||||
val saved = fixture.lastSaved!!
|
||||
assertEquals(newMissionId, saved.planId)
|
||||
assertEquals(PlannedPlanStatus.DRAFT, saved.status)
|
||||
assertEquals(base.planId, saved.basePlanId)
|
||||
assertEquals(predecessor, saved.basePlanId)
|
||||
assertEquals(base.startTime, saved.startTime)
|
||||
assertEquals(base.endTime, saved.endTime)
|
||||
assertEquals(base.kppId, saved.kppId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replace of a chain-start plan inherits a null base`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = null)
|
||||
val fixture = fixture(base)
|
||||
|
||||
fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE))
|
||||
|
||||
assertEquals(null, fixture.lastSaved!!.basePlanId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `draft snapshots the current chain version as its optimistic token`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
@@ -185,7 +198,7 @@ class CreateDraftUseCaseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an existing open DRAFT on the same base yields 409`() {
|
||||
fun `an existing open DRAFT on the same slot (spacecraft + window) yields 409`() {
|
||||
val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT)
|
||||
val fixture = fixture(base, existingDrafts = listOf(existing))
|
||||
@@ -227,9 +240,12 @@ class CreateDraftUseCaseTest {
|
||||
planningState: SpacecraftPlanningStateEntity? = null
|
||||
): Fixture {
|
||||
val repo = mock(PlannedPlanRepository::class.java)
|
||||
val basePlanId = base?.planId ?: UUID.randomUUID()
|
||||
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
||||
`when`(repo.findAllByBasePlanIdAndStatus(basePlanId, PlannedPlanStatus.DRAFT)).thenReturn(existingDrafts)
|
||||
`when`(
|
||||
repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||
anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull()
|
||||
)
|
||||
).thenReturn(existingDrafts)
|
||||
|
||||
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
||||
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
|
||||
@@ -253,14 +269,20 @@ class CreateDraftUseCaseTest {
|
||||
return Fixture(useCase, client, saved)
|
||||
}
|
||||
|
||||
private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
|
||||
private fun plan(
|
||||
start: LocalDateTime,
|
||||
end: LocalDateTime,
|
||||
status: PlannedPlanStatus,
|
||||
basePlanId: UUID? = null
|
||||
): PlannedPlanEntity =
|
||||
PlannedPlanEntity(
|
||||
planId = UUID.randomUUID(),
|
||||
spacecraftId = spacecraftId,
|
||||
startTime = start,
|
||||
endTime = end,
|
||||
kppId = kppId,
|
||||
status = status
|
||||
status = status,
|
||||
basePlanId = basePlanId
|
||||
)
|
||||
|
||||
private fun props(freeze: Long): PlanningProperties =
|
||||
|
||||
+41
-35
@@ -93,10 +93,12 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips spacecraft that already has an active attempt`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
fun `skips spacecraft that has an in-flight plan being issued`() {
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activeAttemptId = UUID.randomUUID())
|
||||
// По КА есть план «в работе» (ISSUING) → авто-выдача следующего окна блокируется (до решения).
|
||||
val inFlight = persistedPlan(PlannedPlanStatus.ISSUING, now().plusMinutes(10), now().plusHours(1))
|
||||
val planStore = linkedMapOf(inFlight.planId to inFlight)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val head = plan(now().plusMinutes(10), now().plusHours(3))
|
||||
val projection = mock(PlanProjectionService::class.java)
|
||||
`when`(projection.project(spacecraftId)).thenReturn(listOf(head))
|
||||
@@ -104,7 +106,8 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
|
||||
worker.issueDuePlans()
|
||||
|
||||
assertTrue(planStore.isEmpty())
|
||||
// Новый план не материализован, проекция даже не запрашивалась (блокировка раньше).
|
||||
assertEquals(1, planStore.size)
|
||||
verify(projection, never()).project(spacecraftId)
|
||||
}
|
||||
|
||||
@@ -119,9 +122,10 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
end = now().plusHours(1)
|
||||
)
|
||||
planStore[unconfirmed.planId] = unconfirmed
|
||||
val baseId = UUID.randomUUID()
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
currentBasePlanId = UUID.randomUUID(),
|
||||
currentBasePlanId = baseId,
|
||||
activePlanId = unconfirmed.planId,
|
||||
activeAttemptId = UUID.randomUUID()
|
||||
)
|
||||
@@ -132,8 +136,9 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
|
||||
// Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION.
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status)
|
||||
// Якорь цепочки сдвинут на него, следующий план выдан с новым attempt.
|
||||
assertEquals(unconfirmed.planId, state.currentBasePlanId)
|
||||
// Базу/версию grace НЕ двигает (это делает только LAID_IN) — currentBasePlanId не меняется.
|
||||
assertEquals(baseId, state.currentBasePlanId)
|
||||
// Следующий план всё равно выдан с новым attempt (grace перестал блокировать).
|
||||
assertEquals(2, planStore.size)
|
||||
assertNotNull(state.activeAttemptId)
|
||||
assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime)
|
||||
@@ -237,23 +242,23 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `issuePlanNow rejects when spacecraft has an active attempt for another plan`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
fun `issuePlanNow allows a parallel issue while another plan is in flight`() {
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
// По КА уже летит другой план (WAITING_DECISION) — это больше НЕ блокирует: несколько выдач
|
||||
// по КА разом допустимы (исходный + коррекция), конкуренция разрешается на закладке.
|
||||
val other = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(2))
|
||||
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[target.planId] = target
|
||||
// Активная выдача идёт по ДРУГОМУ плану того же КА — две выдачи разом недопустимы.
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
activePlanId = UUID.randomUUID(),
|
||||
activeAttemptId = UUID.randomUUID()
|
||||
)
|
||||
val planStore = linkedMapOf(other.planId to other, target.planId to target)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val worker = worker(planStore, attemptStore, state)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||
worker.issuePlanNow(target.planId)
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
// Выдача target стартовала параллельно: новый attempt, target в WAITING_DECISION.
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
|
||||
assertEquals(1, attemptStore.size)
|
||||
assertEquals(target.planId, state.activePlanId)
|
||||
assertNotNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -391,27 +396,22 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `promoteDraftNow rejects with 409 when another plan is being issued`() {
|
||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||
fun `promoteDraftNow promotes a correction while the base is still in flight`() {
|
||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||
val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||
planStore[base.planId] = base
|
||||
// База того же окна ещё «в полёте» (WAITING_DECISION) — это больше НЕ блокирует promote
|
||||
// коррекции: оба плана летят параллельно, конкуренция разрешается на закладке (LAID_IN).
|
||||
val base = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
|
||||
val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId)
|
||||
planStore[draft.planId] = draft
|
||||
// По КА уже идёт активная выдача другого плана.
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
chainVersion = 0,
|
||||
activePlanId = UUID.randomUUID(),
|
||||
activeAttemptId = UUID.randomUUID()
|
||||
)
|
||||
// Слот валиден — отказ именно 409 (чужая выдача), а не 422 (слот).
|
||||
val planStore = linkedMapOf(base.planId to base, draft.planId to draft)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0)
|
||||
val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft)))
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) }
|
||||
worker.promoteDraftNow(draft.planId)
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
assertTrue(attemptStore.isEmpty())
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status)
|
||||
assertEquals(1, attemptStore.size)
|
||||
assertEquals(draft.planId, state.activePlanId)
|
||||
assertNotNull(state.activeAttemptId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -522,6 +522,12 @@ class IssueDuePlanWorkerOnDemandTest {
|
||||
store[entity.planId] = entity
|
||||
entity
|
||||
}
|
||||
`when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||
val sc = invocation.arguments[0] as String
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val statuses = invocation.arguments[1] as Collection<PlannedPlanStatus>
|
||||
store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses }
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
|
||||
+87
-12
@@ -4,12 +4,15 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.never
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.TransactionDefinition
|
||||
import org.springframework.transaction.TransactionStatus
|
||||
@@ -58,7 +61,7 @@ class PlanIssueAndDecisionFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepted decision moves plan to AWAITING_LAYIN sets base and enqueues slots update once`() {
|
||||
fun `accepted decision moves plan to AWAITING_LAYIN and enqueues slots without anchoring base`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
@@ -74,13 +77,74 @@ class PlanIssueAndDecisionFlowTest {
|
||||
|
||||
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status)
|
||||
assertEquals(plan.planId, state.currentBasePlanId)
|
||||
assertNull(state.activePlanId)
|
||||
assertNull(state.activeAttemptId)
|
||||
assertNull(state.urgentReissueFrom)
|
||||
// ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN.
|
||||
assertNull(state.currentBasePlanId)
|
||||
assertEquals(0, state.chainVersion)
|
||||
verify(slotsUpdateJobService).enqueue(plan.planId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layin confirmed makes plan the base bumps version and supersedes overlapping competitors`() {
|
||||
val spacecraftId = "25544"
|
||||
val laidIn = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
// Конкурент-коррекция того же окна (пересекается) — должна уйти в SUPERSEDED.
|
||||
val competitor = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
|
||||
// Несвязанное будущее окно — не трогаем.
|
||||
val unrelated = plannedPlan(spacecraftId, time(20), time(21), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val store = linkedMapOf(laidIn.planId to laidIn, competitor.planId to competitor, unrelated.planId to unrelated)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 4)
|
||||
val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
|
||||
`when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
|
||||
val useCase = HandlePlanDecisionUseCase(
|
||||
processedPlanDecisionEventRepository = processedRepository,
|
||||
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
|
||||
plannedPlanRepository = plannedRepository(store),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(state),
|
||||
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
|
||||
planningProperties = planningProperties()
|
||||
)
|
||||
val message = feedbackMessage(spacecraftId, laidIn.planId)
|
||||
|
||||
useCase.handleLayinConfirmed(message)
|
||||
useCase.handleLayinConfirmed(message) // повтор того же eventId — no-op (идемпотентность)
|
||||
|
||||
assertEquals(PlannedPlanStatus.LAID_IN, laidIn.status)
|
||||
assertEquals(laidIn.planId, state.currentBasePlanId)
|
||||
assertEquals(5, state.chainVersion)
|
||||
assertEquals(PlannedPlanStatus.SUPERSEDED, competitor.status)
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, unrelated.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layin confirmed rejected with 409 when another plan is already laid in within 10 minutes`() {
|
||||
val spacecraftId = "25544"
|
||||
val target = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN)
|
||||
// Уже заложенный план со стартом на 5 минут позже — в пределах ±10 мин → подтвердить нельзя.
|
||||
val alreadyLaidIn = plannedPlan(spacecraftId, time(12).plusMinutes(5), time(13), PlannedPlanStatus.LAID_IN)
|
||||
val store = linkedMapOf(target.planId to target, alreadyLaidIn.planId to alreadyLaidIn)
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
currentBasePlanId = alreadyLaidIn.planId,
|
||||
chainVersion = 1
|
||||
)
|
||||
val useCase = HandlePlanDecisionUseCase(
|
||||
processedPlanDecisionEventRepository = processedRepository(),
|
||||
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
|
||||
plannedPlanRepository = plannedRepository(store),
|
||||
spacecraftPlanningStateJpaRepository = stateRepository(state),
|
||||
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
|
||||
planningProperties = planningProperties()
|
||||
)
|
||||
|
||||
val ex = assertThrows(ResponseStatusException::class.java) {
|
||||
useCase.handleLayinConfirmed(feedbackMessage(spacecraftId, target.planId))
|
||||
}
|
||||
|
||||
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, target.status) // не заложен
|
||||
assertEquals(1, state.chainVersion) // версия цепочки не двигалась
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
|
||||
val spacecraftId = "25544"
|
||||
@@ -138,21 +202,20 @@ class PlanIssueAndDecisionFlowTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() {
|
||||
fun `decision for a superseded plan records the attempt but does not move the chain`() {
|
||||
val spacecraftId = "25544"
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
|
||||
// План уже вытеснен заложенной коррекцией (SUPERSEDED, не in-flight) — запоздавшее решение
|
||||
// по его attempt записывается, но цепочку не двигает и базу не трогает.
|
||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.SUPERSEDED)
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val activeAttemptId = UUID.randomUUID()
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
|
||||
val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService)
|
||||
|
||||
useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED))
|
||||
|
||||
assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status)
|
||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
|
||||
assertEquals(plan.planId, state.activePlanId)
|
||||
assertEquals(activeAttemptId, state.activeAttemptId)
|
||||
assertEquals(PlannedPlanStatus.SUPERSEDED, plan.status)
|
||||
assertNull(state.currentBasePlanId)
|
||||
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
||||
}
|
||||
@@ -255,6 +318,18 @@ class PlanIssueAndDecisionFlowTest {
|
||||
store[plan.planId] = plan
|
||||
plan
|
||||
}
|
||||
`when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||
val sc = invocation.arguments[0] as String
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val statuses = invocation.arguments[1] as Collection<PlannedPlanStatus>
|
||||
store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses }
|
||||
}
|
||||
`when`(repository.saveAll(anyNonNull<MutableList<PlannedPlanEntity>>())).thenAnswer { invocation ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val plans = invocation.arguments[0] as Iterable<PlannedPlanEntity>
|
||||
plans.forEach { plan -> store[plan.planId] = plan }
|
||||
plans.toMutableList()
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -106,7 +106,9 @@ class PlanProjectionServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the base plan when the active plan is no longer in flight`() {
|
||||
fun `anchors on the furthest accepted window among several not-yet-laid-in plans`() {
|
||||
// Принят и базовый, и более поздняя коррекция (оба AWAITING_LAYIN) — якорь берём по крайнему
|
||||
// окну (endTime), чтобы форвард-цепочка строилась после самого дальнего зафиксированного слота.
|
||||
val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN)
|
||||
val calculation = mock(PlanCalculationService::class.java)
|
||||
@@ -118,8 +120,7 @@ class PlanProjectionServiceTest {
|
||||
val service = service(
|
||||
state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = spacecraftId,
|
||||
currentBasePlanId = base.planId,
|
||||
activePlanId = accepted.planId
|
||||
currentBasePlanId = base.planId
|
||||
),
|
||||
plansById = mapOf(base.planId to base, accepted.planId to accepted),
|
||||
calculation = calculation
|
||||
@@ -127,8 +128,8 @@ class PlanProjectionServiceTest {
|
||||
|
||||
service.project(spacecraftId)
|
||||
|
||||
assertEquals(base.startTime, anchor?.startTime)
|
||||
assertEquals(base.kppId, anchor?.kppId)
|
||||
assertEquals(accepted.startTime, anchor?.startTime)
|
||||
assertEquals(accepted.kppId, anchor?.kppId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -162,6 +163,11 @@ class PlanProjectionServiceTest {
|
||||
`when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation ->
|
||||
Optional.ofNullable(plansById[invocation.getArgument(0)])
|
||||
}
|
||||
`when`(plannedRepository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val statuses = invocation.getArgument<Collection<PlannedPlanStatus>>(1)
|
||||
plansById.values.filter { plan -> plan.status in statuses }
|
||||
}
|
||||
} else {
|
||||
`when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan))
|
||||
}
|
||||
|
||||
+6
-19
@@ -16,10 +16,8 @@ import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
|
||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus
|
||||
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.repository.PlanIssueAttemptRepository
|
||||
import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository
|
||||
import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
@@ -27,16 +25,11 @@ import java.util.UUID
|
||||
class TestPlanDecisionServiceTest {
|
||||
|
||||
@Test
|
||||
fun `generate decision builds message for active attempt and delegates to use case`() {
|
||||
fun `generate decision builds message for latest attempt and delegates to use case`() {
|
||||
val plan = plannedPlan()
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(
|
||||
spacecraftId = plan.spacecraftId,
|
||||
activePlanId = plan.planId,
|
||||
activeAttemptId = attempt.attemptId
|
||||
)
|
||||
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
|
||||
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
|
||||
val service = service(plan, attempt, handlePlanDecisionUseCase)
|
||||
|
||||
val message = service.generateDecision(plan.planId, PlanDecision.ACCEPTED, "manual accept")
|
||||
|
||||
@@ -50,12 +43,10 @@ class TestPlanDecisionServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate decision rejects plan without active attempt`() {
|
||||
fun `generate decision rejects plan without any attempt`() {
|
||||
val plan = plannedPlan()
|
||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||
val state = SpacecraftPlanningStateEntity(spacecraftId = plan.spacecraftId)
|
||||
val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java)
|
||||
val service = service(plan, attempt, state, handlePlanDecisionUseCase)
|
||||
val service = service(plan, attempt = null, handlePlanDecisionUseCase)
|
||||
|
||||
val exception = assertThrows(ResponseStatusException::class.java) {
|
||||
service.generateDecision(plan.planId, PlanDecision.REJECTED, null)
|
||||
@@ -67,22 +58,18 @@ class TestPlanDecisionServiceTest {
|
||||
|
||||
private fun service(
|
||||
plan: PlannedPlanEntity,
|
||||
attempt: PlanIssueAttemptEntity,
|
||||
state: SpacecraftPlanningStateEntity,
|
||||
attempt: PlanIssueAttemptEntity?,
|
||||
handlePlanDecisionUseCase: HandlePlanDecisionUseCase
|
||||
): TestPlanDecisionService {
|
||||
val plannedPlanRepository = mock(PlannedPlanRepository::class.java)
|
||||
val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java)
|
||||
val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java)
|
||||
|
||||
`when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan))
|
||||
`when`(planIssueAttemptRepository.findById(attempt.attemptId)).thenReturn(Optional.of(attempt))
|
||||
`when`(stateRepository.findById(plan.spacecraftId)).thenReturn(Optional.of(state))
|
||||
`when`(planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)).thenReturn(attempt)
|
||||
|
||||
return TestPlanDecisionService(
|
||||
plannedPlanRepository = plannedPlanRepository,
|
||||
planIssueAttemptRepository = planIssueAttemptRepository,
|
||||
spacecraftPlanningStateJpaRepository = stateRepository,
|
||||
handlePlanDecisionUseCase = handlePlanDecisionUseCase
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user