pcp-tgu: слоты по факту закладки, запрет ранней закладки, шире рейл редактора

- слоты (acceptedPlanSlotsUpdate) занимаются на LAID_IN, а не на ACCEPT —
  до закладки план ещё может быть вытеснен коррекцией
- «Подтвердить закладку» недоступно, пока начало плана в будущем
- левая панель редактора 15.5rem → 17rem (цифры статистики у границы)
This commit is contained in:
Дмитрий Соловьев
2026-06-04 21:15:13 +03:00
parent bd2bc9d583
commit 69dfd8025d
4 changed files with 28 additions and 10 deletions
@@ -1032,7 +1032,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
{applyError && <div className="tgu-alert tgu-alert--danger tgu-editor-notice">{applyError}</div>}
</div>
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "15.5rem minmax(0, 1fr) 0" : "15.5rem minmax(0, 1fr) 21rem" }}>
<div className="tgu-editor-layout" style={{ gridTemplateColumns: rightMode === "none" ? "17rem minmax(0, 1fr) 0" : "17rem minmax(0, 1fr) 21rem" }}>
<EditorRail
selectedSpacecraftId={selectedSpacecraftId}
selectedPlan={selectedPlan}
@@ -82,6 +82,9 @@ export function TguPlanDetails({
const duration = formatDuration(plan.endMs - plan.startMs);
// Окно решения закрылось, если с момента старта плана прошло больше grace-периода.
const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000;
// Закладку нельзя подтверждать, пока начало плана ещё в будущем: план закладывается на КА
// по факту наступления своего старта, а не заранее.
const layinStartInFuture = plan.startMs > Date.now();
// «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для
// персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция,
// не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward).
@@ -232,9 +235,11 @@ export function TguPlanDetails({
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight || layinConflict}
disabled={decisionInFlight || layinConflict || layinStartInFuture}
title={
layinConflict
layinStartInFuture
? "Начало плана ещё не наступило — подтвердить закладку можно только после его старта"
: layinConflict
? "По КА уже заложен план со стартом в пределах ±10 минут — закладка недоступна"
: "Сымитировать событие «план заложен» (AWAITING_LAYIN → LAID_IN)"
}
@@ -242,7 +247,12 @@ export function TguPlanDetails({
>
Подтвердить закладку
</button>
{layinConflict && (
{layinStartInFuture && (
<div className="tgu-details__test-hint">
Начало плана ещё в будущем подтвердить закладку можно только после наступления его старта.
</div>
)}
{!layinStartInFuture && layinConflict && (
<div className="tgu-details__test-hint">
По КА уже есть заложенный план со стартом в пределах ±10 минут подтвердить нельзя.
</div>
@@ -149,6 +149,10 @@ class HandlePlanDecisionUseCase(
plannedPlanRepository.save(plan)
spacecraftPlanningStateJpaRepository.save(state)
// Статусы слотов проставляем только по факту закладки на КА (LAID_IN), а не на ACCEPT:
// до закладки план ещё может быть вытеснен коррекцией, и занимать слоты рано.
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
}
/** Гасит в SUPERSEDED ещё не заложенные планы КА, чьё окно пересекается с заложенным. */
@@ -247,9 +251,9 @@ class HandlePlanDecisionUseCase(
// ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт
// закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN
// ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна.
// Статусы слотов тоже проставляются не здесь, а на закладке ([handleLayinConfirmed]).
planIssueAttemptRepository.save(attempt)
plannedPlanRepository.save(plan)
acceptedPlanSlotsUpdateJobService.enqueue(plan.planId)
}
private fun rejectPlan(
@@ -61,7 +61,7 @@ class PlanIssueAndDecisionFlowTest {
}
@Test
fun `accepted decision moves plan to AWAITING_LAYIN and enqueues slots without anchoring base`() {
fun `accepted decision moves plan to AWAITING_LAYIN without anchoring base or touching slots`() {
val spacecraftId = "25544"
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION)
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
@@ -80,7 +80,8 @@ class PlanIssueAndDecisionFlowTest {
// ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN.
assertNull(state.currentBasePlanId)
assertEquals(0, state.chainVersion)
verify(slotsUpdateJobService).enqueue(plan.planId)
// Слоты проставляются на закладке (LAID_IN), а не на ACCEPT.
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
}
@Test
@@ -95,12 +96,13 @@ class PlanIssueAndDecisionFlowTest {
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 4)
val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java)
`when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0)
val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java)
val useCase = HandlePlanDecisionUseCase(
processedPlanDecisionEventRepository = processedRepository,
planIssueAttemptRepository = attemptRepository(linkedMapOf()),
plannedPlanRepository = plannedRepository(store),
spacecraftPlanningStateJpaRepository = stateRepository(state),
acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java),
acceptedPlanSlotsUpdateJobService = slotsUpdateJobService,
planningProperties = planningProperties()
)
val message = feedbackMessage(spacecraftId, laidIn.planId)
@@ -113,6 +115,8 @@ class PlanIssueAndDecisionFlowTest {
assertEquals(5, state.chainVersion)
assertEquals(PlannedPlanStatus.SUPERSEDED, competitor.status)
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, unrelated.status)
// Слоты проставляются по факту закладки — ровно один раз (второй вызов идемпотентен).
verify(slotsUpdateJobService).enqueue(laidIn.planId)
}
@Test