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:
Дмитрий Соловьев
2026-06-04 16:13:02 +03:00
parent 5235142fa6
commit 87905311b1
21 changed files with 558 additions and 240 deletions
+34 -24
View File
@@ -27,9 +27,10 @@
┌────────────────────────────────┼────────────────────────────────┐
событие «заложен» событие «не заложен» событие «не принят»
(пока не реализовано) ▼ ▼
LAYIN_FAILED NOT_ACCEPTED
LAID_IN (срочная перевыдача) (срочная перевыдача)
▼ ▼
LAID_IN LAYIN_FAILED NOT_ACCEPTED
(база цепочки; конкуренты (срочная перевыдача) (срочная перевыдача)
того же окна → SUPERSEDED)
при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS
```
@@ -75,21 +76,25 @@
### AWAITING_LAYIN — принят, ждёт подтверждения закладки
- **Когда.** Пришло решение ACCEPTED (`acceptPlan`).
- **Что значит.** План принят и **оптимистично считается заложенным**; это текущий **базовый план
цепочки** (`state.currentBasePlanId = plan.planId`, `chainVersion += 1`). Запускается джоб
обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- **Дальше.** При подтверждении закладки → `LAID_IN` (терминальный успех; **событие пока не
реализовано**, см. ниже). При негативе — `LAYIN_FAILED`/`NOT_ACCEPTED`.
- В легенде это де-факто «принятый/заложенный» статус (зелёный). Перевыдача в свой слот ещё
- **Что значит.** План принят и ждёт закладки. **ACCEPT больше НЕ делает план базой и не двигает
`chainVersion`** — это разъединено намеренно: после приёмки можно отправить и принять коррекцию
того же окна, поэтому по одному окну может одновременно «лететь» несколько AWAITING_LAYIN/
WAITING_DECISION. Базой и версией цепочки управляет только закладка (`LAID_IN`). На ACCEPT
запускается джоб обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`).
- **Дальше.** При подтверждении закладки → `LAID_IN` (становится базой). При негативе —
`LAYIN_FAILED`/`NOT_ACCEPTED`. Если базой стала **другая** коррекция того же окна — этот план
гасится в `SUPERSEDED`.
- В легенде это «принятый, ждёт закладки» статус (зелёный). Перевыдача/коррекция в свой слот ещё
возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только
fork-forward).
### REJECTED — отклонён
- **Когда.** Пришло решение REJECTED (`rejectPlan`).
- **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план
бессмысленно): активная выдача снимается, срочная перевыдача гасится, цепочка ставится **на паузу**
(`pausedAfterReject = true`) — проекция становится пустой, воркер ничего не выдаёт. Базовый план не
трогается.
бессмысленно). Цепочка ставится **на паузу** (`pausedAfterReject = true`) — **только если по КА не
осталось других живых кандидатов** (в полёте/приняты, но не заложены): при нескольких выдачах
отказ одной не глушит цепочку, пока жив другой кандидат. На паузе проекция пуста, воркер ничего не
выдаёт. Базовый план не трогается.
- **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу.
- В UI у КА с паузой рисуется пометка «⏸ на паузе».
@@ -112,18 +117,23 @@
- **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором.
Опасный статус, в 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`) — заранее, чтобы при появлении события
цвет уже был.
### LAID_IN — заложен (терминальный успех, база цепочки)
- **Когда.** Пришло внешнее событие «план заложен» по плану в `AWAITING_LAYIN`
(`LayinConfirmedConsumer``handleLayinConfirmed`, топик `layin-confirmed`).
- **Что значит.** Подтверждённая закладка — **именно она делает план базой цепочки**
(`state.currentBasePlanId = plan.planId`, `chainVersion += 1`, снимается пауза/срочная
перевыдача). «Точка невозврата»: дальше план меняется только через fork-forward (гарды
`CreateDraftUseCase`, `assertReissuableInSlot`).
- **Supersede конкурентов.** Все ещё не заложенные планы КА (ISSUING/WAITING_DECISION/AWAITING_LAYIN)
с окном, пересекающим окно заложенного, переводятся в `SUPERSEDED` (`supersedeCompetingPlans`).
- Событие по плану не в `AWAITING_LAYIN` (устаревшее/дубль/уже вытеснён) — игнорируется.
- В легенде зелёный, темнее `AWAITING_LAYIN`.
### SUPERSEDED — вытеснён заложенной коррекцией (терминал)
- **Когда.** По тому же окну КА другой план ушёл в `LAID_IN`, а этот был конкурирующей выдачей/
коррекцией (`supersedeCompetingPlans`).
- **Что значит.** Терминал, цепочку не двигает. Запоздавшее решение по его attempt записывается, но
игнорируется (план уже не in-flight). Базовый план не трогается.
## Зарезервированные / не возникающие сейчас статусы