pcp-tgu: статусы плана — актуальная легенда с тултипами + док жизненного цикла

- STATUS_STYLES в порядке жизненного цикла: убран легаси ACCEPTED, добавлены
  LAID_IN/NOT_ACCEPTED/LAYIN_FAILED, у каждого статуса человеческое описание.
- Легенда вкладки «Планы»: тултипы по наведению (title=description), убрана
  устаревшая приписка про стрелки.
- TimelineStatusStyle: добавлено обязательное поле description.
- HandlePlanDecisionUseCase: TODO(layin-confirmed) — обработчик приёма события
  «план заложен» (AWAITING_LAYIN → LAID_IN) ещё не реализован.
- docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md: справочник по каждому статусу
  (когда наступает / что значит / какой переход), диаграмма, зарезервированный ACCEPTED.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 13:06:58 +03:00
parent a5a1cdfa64
commit fd3603a086
6 changed files with 212 additions and 14 deletions
@@ -0,0 +1,143 @@
# Жизненный цикл статусов плана ТГУ
Справочник по статусам business-plan в расписании ТГУ: когда наступает каждый статус, что он
означает и какой переход его порождает. Источник правды — enum
[`PlannedPlanStatus`](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt);
переходы — в `service/IssueDuePlanWorker`, `service/HandlePlanDecisionUseCase`,
`service/CreateDraftUseCase`, `service/CreateChainPlanUseCase`, `dto/PlanResponse`.
Эти же статусы (кроме «зарезервированных», см. ниже) показаны в легенде вкладки «Планы»
([`tguStatus.ts`](../../services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts)) и задают
цвет плашек на таймлайне.
## Диаграмма переходов (упрощённо)
```
создать черновик
(нет плана) ───────────────────────────────► DRAFT
│ promote («Применить») / авто-выдача
проекция будущего ····(виртуально)····► PLANNED ──(подошёл срок, авто-выдача)──► ISSUING
│ BPMN-процесс стартовал
WAITING_DECISION
ACCEPTED-решение │ │ REJECTED-решение
▼ ▼
AWAITING_LAYIN REJECTED (цепочка на паузе)
┌────────────────────────────────┼────────────────────────────────┐
событие «заложен» событие «не заложен» событие «не принят»
(пока не реализовано) ▼ ▼
▼ LAYIN_FAILED NOT_ACCEPTED
LAID_IN (срочная перевыдача) (срочная перевыдача)
при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS
```
## Статусы
### DRAFT — черновик
- **Когда.** Оператор создаёт план через «Создать план» (`CreateChainPlanUseCase`) или «Добавить»/
редактирование (`CreateDraftUseCase`).
- **Что значит.** План в стадии правки. Персистится, но **не** является головой цепочки, **не**
попадает в проекцию/read-путь как запланированный слот и **не** выдаётся авто-воркером.
- **Дальше.** Становится активным только после promote («Применить») — переходит в `ISSUING`.
Удаление черновика (abandon) убирает его и клон-миссию.
- В UI помечен как «ЧЕРНОВИК», виден только при включённом режиме «Черновики».
### PLANNED — проекция будущего (виртуальный)
- **Когда.** Вычисляется на лету `PlanProjectionService`/`PlanResponse.projected()` при чтении
расписания. **Никогда не пишется в БД.**
- **Что значит.** Предполагаемый будущий слот цепочки (следующие ЗРВ) — «как пойдёт цепочка, если
ничего не менять». Не несёт `basePlanId`, стартом цепочки не считается.
- **Дальше.** Когда подходит срок выдачи, по этому слоту авто-воркер материализует реальный план
сразу в `ISSUING` (`materializeIssuingPlan``insertIssuingPlan`).
- В UI виден только при включённом тумблере «Возможные будущие планы».
### ISSUING — идёт выдача
- **Когда.** `IssueDuePlanWorker` начинает выдачу: promote черновика (`prepareDraftPromotion`),
ручная (пере)выдача конкретного плана (`prepareSpecificPlan`) или авто-материализация головы
проекции (`materializeIssuingPlan`).
- **Что значит.** Запущен BPMN-процесс выдачи плана на борт; создан attempt со статусом `STARTING`.
- **Дальше.** Успешный старт процесса → `WAITING_DECISION` (`markStarted`). Сбой старта BPMN →
`START_AMBIGUOUS` (`markAmbiguous`).
### WAITING_DECISION — ожидает решения
- **Когда.** BPMN-процесс выдачи успешно стартовал, attempt → `STARTED` (`markStarted`).
- **Что значит.** План выдан, ждём решения оператора/борта: ACCEPTED либо REJECTED.
- **Дальше.**
- ACCEPTED → `AWAITING_LAYIN` (`acceptPlan`).
- REJECTED → `REJECTED` (`rejectPlan`).
- **Grace-окно.** Если решения нет дольше `waitingDecisionGraceMinutes` после `startTime`, цепочка
«молчаливо» едет дальше: якорь сдвигается на этот план, блокировка снимается, но **статус
остаётся `WAITING_DECISION`** (`tryAdvancePastUnconfirmedPlan`). Запоздалое решение запишется на
attempt и ушедшую вперёд цепочку не тронет. Поэтому в UI кнопки решения гасятся после grace.
### AWAITING_LAYIN — принят, ждёт подтверждения закладки
- **Когда.** Пришло решение ACCEPTED (`acceptPlan`).
- **Что значит.** План принят и **оптимистично считается заложенным**; это текущий **базовый план
цепочки** (`state.currentBasePlanId = plan.planId`, `chainVersion += 1`). Запускается джоб
обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- **Дальше.** При подтверждении закладки → `LAID_IN` (терминальный успех; **событие пока не
реализовано**, см. ниже). При негативе — `LAYIN_FAILED`/`NOT_ACCEPTED`.
- В легенде это де-факто «принятый/заложенный» статус (зелёный). Перевыдача в свой слот ещё
возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только
fork-forward).
### REJECTED — отклонён
- **Когда.** Пришло решение REJECTED (`rejectPlan`).
- **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план
бессмысленно): активная выдача снимается, срочная перевыдача гасится, цепочка ставится **на паузу**
(`pausedAfterReject = true`) — проекция становится пустой, воркер ничего не выдаёт. Базовый план не
трогается.
- **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу.
- В UI у КА с паузой рисуется пометка «⏸ на паузе».
### NOT_ACCEPTED — не принят (негативный фидбек)
- **Когда.** Пришло внешнее событие «не принят» по текущему базовому плану в `AWAITING_LAYIN`
(`handleNotAccepted``applyNegativeFeedback`).
- **Что значит.** Терминальный негатив по базовому плану. В отличие от REJECTED — это **технический**
негатив, поэтому запускается **срочная перевыдача** на ближайшую ЗРВ (`reissueOnNearestWindow`).
- Событие, пришедшее не по текущему базовому плану (устаревшее/дубль), игнорируется.
### LAYIN_FAILED — не заложен (негативный фидбек)
- **Когда.** Пришло внешнее событие «не заложен» по текущему базовому плану в `AWAITING_LAYIN`
(`handleLayinFailed``applyNegativeFeedback`).
- **Что значит.** Терминальный негатив: закладка не удалась. Поведение сейчас идентично
`NOT_ACCEPTED`**срочная перевыдача** на ближайшую ЗРВ.
### START_AMBIGUOUS — неопределённый старт
- **Когда.** Из `ISSUING` не удалось достоверно стартовать BPMN-процесс выдачи (исключение в
`startPreparedAttempt``markAmbiguous`); attempt → `AMBIGUOUS`.
- **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором.
Опасный статус, в UI выделен пунктиром.
### LAID_IN — заложен (терминальный успех)
- **Когда (целевое).** При подтверждении закладки базового плана из `AWAITING_LAYIN`.
- **Что значит.** Подтверждённая закладка, терминальный успех; «точка невозврата» — дальше план
меняется только через fork-forward (используется в гардах `CreateDraftUseCase`,
`assertReissuableInSlot`).
- **⚠️ TODO — не реализовано.** Потребителя события «план заложен» сейчас **нет**, поэтому план в
`LAID_IN` не переходит и остаётся в `AWAITING_LAYIN`. Нужно: Kafka-consumer (по аналогии с
`LayinFailedConsumer`/`PlanNotAcceptedConsumer`) + `handleLayinConfirmed` в
`HandlePlanDecisionUseCase` (`AWAITING_LAYIN → LAID_IN` по текущему базовому плану). Маркер-TODO
стоит в коде `HandlePlanDecisionUseCase` рядом с обработчиками негативного фидбека.
- В легенде статус показан (зелёный, темнее `AWAITING_LAYIN`) — заранее, чтобы при появлении события
цвет уже был.
## Зарезервированные / не возникающие сейчас статусы
- **ACCEPTED** — в enum присутствует, но приёмка (`acceptPlan`) переводит план **сразу** в
`AWAITING_LAYIN`, минуя `ACCEPTED`. Фактически это легаси-значение, нигде не присваивается; в
легенду вкладки «Планы» намеренно не вынесено.
## Связанные конфиги (`PlanningProperties`)
- `waitingDecisionGraceMinutes` — сколько ждём решение после старта, прежде чем «молчаливо» ехать
дальше (см. `WAITING_DECISION`).
- `layinFreezeMinutes` — за сколько минут до старта окно выдачи замораживается («точка невозврата»):
перевыдача в свой слот запрещена, только fork-forward.
- `notificationBeforeStartMinutes` — за сколько до старта авто-воркер начинает выдачу головы цепочки.
## См. также
- [PCP_ARCHITECTURE.md](PCP_ARCHITECTURE.md)
- [PCP_TGU_OPS_UI_CAPABILITY_MAP.md](PCP_TGU_OPS_UI_CAPABILITY_MAP.md)
@@ -4,12 +4,11 @@ export function TguLegend() {
return (
<div className="tgu-legend" aria-label="Легенда статусов">
{STATUS_STYLES.map((item) => (
<span className="tgu-legend__item" key={item.label}>
<span className="tgu-legend__item" key={item.label} title={item.description}>
<span className={`tgu-legend__swatch ${item.className}`} />
{item.label}
</span>
))}
<span className="tgu-legend__note">Стрелки показывают следующий план того же КА по времени.</span>
</div>
);
}
@@ -1,14 +1,51 @@
import type { TguPlanStatus } from "../../model/tguTypes";
import type { TimelineStatusStyle } from "../../model/timelineTypes";
// Порядок — по жизненному циклу плана (см. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md).
// ACCEPTED из enum бэка здесь намеренно отсутствует: приёмка переводит план сразу в AWAITING_LAYIN.
// LAID_IN показан, хотя пока не возникает — нужен обработчик события подтверждения закладки
// (AWAITING_LAYIN → LAID_IN), см. TODO в HandlePlanDecisionUseCase и док жизненного цикла.
export const STATUS_STYLES: TimelineStatusStyle[] = [
{ status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" },
{ status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" },
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" },
{ status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft" }
{
status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft",
description: "Черновик: план в стадии правки, в выдачу пока не идёт."
},
{
status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned",
description: "Возможный будущий план (проекция цепочки)."
},
{
status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing",
description: "Идёт выдача плана."
},
{
status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting",
description: "План выдан, ожидается решение (принять / отклонить)."
},
{
status: "AWAITING_LAYIN", label: "AWAITING_LAYIN", color: "#2f9e44", className: "status-awaiting-layin",
description: "План принят, ожидается подтверждение закладки."
},
{
status: "LAID_IN", label: "LAID_IN", color: "#1b6b34", className: "status-laid-in",
description: "Получено подтверждение закладки плана."
},
{
status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous",
description: "Ошибка после старта процесса выдачи плана. Требуется оператор."
},
{
status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected",
description: "План отклонён, цепочка поставлена на паузу."
},
{
status: "NOT_ACCEPTED", label: "NOT_ACCEPTED", color: "#e8590c", className: "status-not-accepted",
description: "План не принят, идёт срочная перевыдача на ближайшую ЗРВ."
},
{
status: "LAYIN_FAILED", label: "LAYIN_FAILED", color: "#a61e4d", className: "status-layin-failed",
description: "План не заложен, идёт срочная перевыдача на ближайшую ЗРВ."
}
];
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
@@ -19,7 +56,8 @@ export function statusStyle(status: TguPlanStatus): TimelineStatusStyle {
status,
label: status || "UNKNOWN",
color: "#79828d",
className: "status-unknown"
className: "status-unknown",
description: "Неизвестный статус."
}
);
}
@@ -59,4 +59,6 @@ export type TimelineStatusStyle = {
label: string;
color: string;
className: string;
/** Человеческое пояснение статуса — тултип в легенде. */
description: string;
};
@@ -150,6 +150,7 @@
align-items: center;
gap: 0.35rem;
white-space: nowrap;
cursor: help;
}
.tgu-legend__swatch {
@@ -159,18 +160,26 @@
background: #79828d;
}
.tgu-legend__note {
color: var(--accent-strong);
.status-awaiting-layin {
background-color: #2f9e44;
}
.status-accepted {
background-color: #2f9e44;
.status-laid-in {
background-color: #1b6b34;
}
.status-rejected {
background-color: #d64545;
}
.status-not-accepted {
background-color: #e8590c;
}
.status-layin-failed {
background-color: #a61e4d;
}
.status-waiting {
background-color: #f0ad2e;
}
@@ -82,6 +82,13 @@ class HandlePlanDecisionUseCase(
}
}
// 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.
/** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */
@Transactional
fun handleLayinFailed(message: PlanExecutionFeedbackMessage) {