pcp-tgu-service: боевой POST /api/plans/{planId}/issue + гард точки невозврата
- ручная выдача вынесена из TestTguController в PlanController - assertReissuableInSlot: 422 при LAID_IN и заморозке (now >= start - layinFreezeMinutes), 409 при активной выдаче другого плана КА; перевыдача того же плана поверх своей in-flight attempt разрешена (новая attemptNo++ замещает летящую) - REISSUABLE_STATUSES удалён — гард по времени/статусу его заместил - layinFreezeMinutes (деф. 10) в PlanningProperties + config-repo/application yaml - граничные тесты в IssueDuePlanWorkerOnDemandTest, переписан PlanIssueAndDecisionFlowTest - дизайн-док версионирования (фаза 1 отмечена сделанной)
This commit is contained in:
@@ -42,6 +42,9 @@ planning:
|
|||||||
reissue-prep-minutes: 30
|
reissue-prep-minutes: 30
|
||||||
worker-lock-ttl-seconds: 300
|
worker-lock-ttl-seconds: 300
|
||||||
waiting-decision-grace-minutes: 30
|
waiting-decision-grace-minutes: 30
|
||||||
|
# Заморозка перед стартом: ручная replace-перевыдача плана в свой слот запрещена,
|
||||||
|
# если до старта осталось меньше этого числа минут (часть «точки невозврата»).
|
||||||
|
layin-freeze-minutes: 10
|
||||||
|
|
||||||
topics:
|
topics:
|
||||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ
|
||||||
|
|
||||||
|
> **Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 9).**
|
||||||
|
> Источник — разбор кода `pcp-tgu-service` и `pcp-mission-planing-service` (см. раздел 2).
|
||||||
|
> Цель документа — зафиксировать модель, API, статусы, миграции и риски, чтобы дальше резать на фазы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Контекст и цель
|
||||||
|
|
||||||
|
Оператор в `pcp-tgu-ops-ui` (редактор включений, `src/features/tgu-editor/`) правит съёмку и сброс
|
||||||
|
актуального плана: строит новую съёмку, корректирует или удаляет существующую, формирует сброс.
|
||||||
|
План — **живой**: он выдаётся на борт КА и исполняется. Поэтому:
|
||||||
|
|
||||||
|
- **Нельзя** писать правки сразу в действующий план (сейчас `saveSurveyRoute`/`saveDropRoute` именно так и делают — мгновенный персист в живую миссию).
|
||||||
|
- Нужна **версия-черновик**: правки идут в неё, на каждое изменение считаются конфликты/наложения, оператор видит результат, и только по явному действию **«Применить» отправляет план вручную**.
|
||||||
|
- Нужна **возможность отката**: прежняя версия сохраняется.
|
||||||
|
- **Гард с первой кладки:** перевыдать план можно, только если он **строго в будущем и ещё не заложен**.
|
||||||
|
|
||||||
|
Сценарий оператора: *план поправили → всё проверили → отправили вручную*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Что уже есть в бэке (находки разбора)
|
||||||
|
|
||||||
|
Версионирование/цепочка и ручная выдача **существуют наполовину** — строим поверх, не дублируем.
|
||||||
|
|
||||||
|
### Ось 1 — расписание/выдача: `pcp-tgu-service`, таблица `planned_plan`
|
||||||
|
- `PlannedPlanEntity` уже несёт: `base_plan_id`, `chain_version`, `issue_not_before`, `accepted_at`, `status`.
|
||||||
|
- `PlannedPlanStatus`: `PLANNED`(виртуальный, не персистится) → `ISSUING` → `WAITING_DECISION` → `ACCEPTED` → `AWAITING_LAYIN` → `LAID_IN`; терминалы `REJECTED/LAYIN_FAILED/NOT_ACCEPTED/START_AMBIGUOUS`.
|
||||||
|
- Цепочка на КА — `SpacecraftPlanningStateEntity` (`currentBasePlanId`, `chainVersion`, `activePlanId`, `urgentReissueFrom`).
|
||||||
|
- **Ручная выдача уже есть:** `IssueDuePlanWorker.issuePlanNow(planId)` → `prepareSpecificPlan` → BPMN. Наружу — `POST /test/tgu/plans/{planId}/issue` (за флагом `tgu.test-controller.enabled`, по умолчанию выключен).
|
||||||
|
- Гард перевыдачи: `REISSUABLE_STATUSES = {PLANNED, REJECTED, LAYIN_FAILED, NOT_ACCEPTED, START_AMBIGUOUS}`.
|
||||||
|
- Супершединг на приём: `HandlePlanDecisionUseCase.acceptPlan` → `status=AWAITING_LAYIN`, `currentBasePlanId=plan`, `chainVersion++`; старые планы цепочки **остаются** (это и есть история/откат).
|
||||||
|
- Выдача склеивает слот и контент по одному id: `BpmnPlanIssueProcessStarter` стартует `createSatelliteMission` с `satellitePlanId = planId`; BPMN тянет контент миссии по этому id.
|
||||||
|
- Проекция (`PlanProjectionService`) считает будущее из снапшота ЗРВ; `CalculatedPlan` = только слот `{spacecraftId, startTime, endTime, kppId}`, **без контента**.
|
||||||
|
|
||||||
|
### Ось 2 — содержимое: `pcp-mission-planing-service`
|
||||||
|
- Модель: `Mission(status="NEW") → Plan(plan_number, stage4_snapshot_id) → Mode(SURVEY|DROP)`.
|
||||||
|
- `SurveyModeEntity`: `status: SurveyModeStatus`, `source: SurveyModeSource` (`MANUAL` для ручных), `contourWkt`, `roll`, `lat/long`, `duration`.
|
||||||
|
- API уже есть: `survey-route/build` (превью), `{planId}/survey-route` и `{planId}/drop-route` (**персист в живую миссию сразу**, авто-создание миссии/плана при отсутствии), `{id}/modes`, `confirm`, `update-status`, `delete mission`.
|
||||||
|
- **Нет**: операции клонирования миссии, update/delete отдельного режима, расчёта конфликтов/наложений (`mode_booking` — это бронь слота, не конфликт).
|
||||||
|
|
||||||
|
**Связь осей: `planId` (tgu) == `missionId` (mission-planing).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Модель версионирования: «клон миссии = версия»
|
||||||
|
|
||||||
|
Выбрано (согласовано): **одна правка-сессия = новая версия с новым id**, клонирующая контент.
|
||||||
|
Поскольку `planId == missionId`, новая версия = новый `planId` = новый `missionId`. Это совмещает обе оси:
|
||||||
|
один новый id и в расписании (`planned_plan`), и в контенте (`missions/plans/modes`).
|
||||||
|
|
||||||
|
```
|
||||||
|
Жизненный цикл версии:
|
||||||
|
|
||||||
|
P0 (актуальная, в будущем, не заложена)
|
||||||
|
│ оператор начинает правку → CLONE
|
||||||
|
▼
|
||||||
|
P1 = DRAFT (planned_plan: status=DRAFT, base_plan_id=P0, chain_version=след.)
|
||||||
|
mission M1 = клон M0 (все modes скопированы)
|
||||||
|
│ правки идут только в M1; на каждую — VALIDATE (конфликты)
|
||||||
|
│ «Применить» (ручная отправка) → ISSUE(P1) [гард: P0 строго в будущем и не заложен]
|
||||||
|
▼
|
||||||
|
P1: ISSUING → WAITING_DECISION → (внешний ACCEPT) → AWAITING_LAYIN
|
||||||
|
supersede: currentBasePlanId=P1, chain_version++
|
||||||
|
P0 остаётся в БД как история → откат = клонировать P0 в новый DRAFT и применить
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевые свойства:
|
||||||
|
- **Живой план не трогается** до «Применить»: правки только в `DRAFT`-клоне.
|
||||||
|
- **Откат**: прежние версии — обычные записи цепочки (`base_plan_id`), восстановление = новый клон из старой версии → ревалидация против «сейчас» → отправка. Не реактивация устаревшего снапшота.
|
||||||
|
- **Оптимистичная конкуренция**: на «Применить» проверяем, что база `P0` всё ещё актуальна (не уехала вперёд, не заложена); иначе — отказ «план изменился, посмотрите дифф».
|
||||||
|
|
||||||
|
### 3a. Два режима порождения версии
|
||||||
|
|
||||||
|
Клон-черновик создаётся одним из двух способов в зависимости от состояния базы:
|
||||||
|
|
||||||
|
**A. Перевыдача (replace)** — база **строго в будущем и не заложена**.
|
||||||
|
Новая версия занимает **тот же слот** и супершедит базу (раздел 3, основной флоу). Окно/КПП копируются из базы.
|
||||||
|
|
||||||
|
**B. Форк вперёд (fork-forward)** — база **уже заложена (`LAID_IN`)**.
|
||||||
|
Тот же план перевыдать нельзя (он на борту). Но можно **взять его за основу нового плана на следующую закладку** и от него повести новую цепочку:
|
||||||
|
- Создаётся новый план (`base_plan_id = заложенный`, новая цепочка), это **не** замена слота, а продолжение вперёд.
|
||||||
|
- **Окно нового плана задаёт оператор, но только выбором из доступных ЗРВ, строго в будущем** (не произвольный ввод). Так окно гарантированно совпадает с реальным выдаваемым слотом (1:1 с ЗРВ), и проблема «окно ↔ слот» снимается на входе.
|
||||||
|
- Чтобы не заполнять с нуля, черновик **преднаполняется маршрутами заложенного плана, попавшими в окно нового плана**: переносятся те `Mode`(SURVEY/DROP), чей интервал `[timeStart, timeStart+duration)` пересекается с заданным окном `[start, stop)`. Дальше оператор правит/дополняет и отправляет вручную.
|
||||||
|
|
||||||
|
```
|
||||||
|
LAID_IN P0 ──(нельзя replace)
|
||||||
|
│ оператор: «создать план на след. закладку на основе P0», задаёт окно W
|
||||||
|
▼
|
||||||
|
P2 = DRAFT (base_plan_id=P0, окно=W)
|
||||||
|
mission M2: скопированы только modes из M0, пересёкшиеся с W
|
||||||
|
│ правки + VALIDATE → отправка вручную → ISSUE(P2) → ... → ACCEPT → новая база цепочки
|
||||||
|
```
|
||||||
|
|
||||||
|
Граница между A и B — точка невозврата из раздела 4: пока база правима в своём слоте — `replace`; как только заложена — только `fork-forward`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Статусы и точка невозврата
|
||||||
|
|
||||||
|
### Новый статус
|
||||||
|
Добавить `PlannedPlanStatus.DRAFT` — персистится, но **не часть проекции/головы цепочки**: не выдаётся воркером,
|
||||||
|
не учитывается как «голова» в `PlanProjectionService`, помечается отдельно в `PlanQueryService` (видна как черновик, а не как запланированный слот).
|
||||||
|
|
||||||
|
### Гард перевыдачи / точка невозврата
|
||||||
|
|
||||||
|
**План становится невозвратным (заменить в своём слоте нельзя — доступен только `fork-forward`), когда выполнено любое из:**
|
||||||
|
- `now ≥ startTime − 10 мин` — порог заморозки перед стартом (в конфиг, напр. `layinFreezeMinutes = 10`); **или**
|
||||||
|
- `status = LAID_IN` — пришло подтверждение закладки.
|
||||||
|
|
||||||
|
Соответственно **`replace`-перевыдача** базы `P0` разрешена, только если `now < startTime − 10 мин` **и** `status ≠ LAID_IN`.
|
||||||
|
Лок конкуренции по КА действует **только для чужого плана**: нельзя стартовать выдачу, пока по КА летит attempt **другого** плана (`state.activeAttemptId != null && state.activePlanId != planId`). Перевыдача **того же** плана разрешена даже поверх его собственной in-flight attempt (в т.ч. авто-выдачи за `notificationBeforeStartMinutes`) — новая attempt замещает летящую. Это и сохраняет право «перевыдать именно этот план» до порога заморозки, несмотря на то что авто-выдача стартует сильно раньше (`notificationBeforeStartMinutes ≫ layinFreezeMinutes`).
|
||||||
|
|
||||||
|
Важно: статус `AWAITING_LAYIN` **сам по себе НЕ** делает план невозвратным — принятый план правим, пока до старта больше 10 минут и нет подтверждения закладки. (Прежняя неоднозначность снята.)
|
||||||
|
|
||||||
|
**Невозвратный план — не тупик:** `replace` запрещён, но доступен **`fork-forward`** (раздел 3a) — создать новый план на следующую ЗРВ на основе текущего и повести новую цепочку. То есть невозвратность закрывает замену слота, но не закрывает развитие плана вперёд.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API — что добавить
|
||||||
|
|
||||||
|
### `pcp-mission-planing-service` (контент)
|
||||||
|
1. `POST /api/missions/{id}/clone` → `{ newMissionId }`. Клон миссии в новый `missionId` (= новый `planId`). Два варианта наполнения:
|
||||||
|
- **replace**: глубокий клон — все `Mode`(SURVEY/DROP) с контурами.
|
||||||
|
- **fork-forward**: клон **только тех `Mode`**, чей интервал `[timeStart, timeStart+duration)` пересекается с переданным окном `{ windowStart, windowStop }` (окно задаёт оператор). Маршруты вне окна не переносятся.
|
||||||
|
2. `PATCH /api/missions/survey-modes/{modeId}` — правка съёмки (перестроить маршрут: окно/крен → новый контур). Сейчас правки нет, есть только смена статуса.
|
||||||
|
3. `DELETE /api/missions/modes/{modeId}` — удалить включение из черновика.
|
||||||
|
4. `POST /api/missions/{id}/validate` → `{ conflicts: [...] }` — **новая логика** (раздел 6).
|
||||||
|
5. (опц.) `GET /api/missions/{id}/versions` или связка через tgu — список версий для отката.
|
||||||
|
|
||||||
|
> Существующие `survey-route`/`drop-route` остаются, но **адресуются на DRAFT-миссию**, а не на живую. Авто-создание миссии на лету для DRAFT-сценария отключить/пересмотреть.
|
||||||
|
|
||||||
|
### `pcp-tgu-service` (расписание/выдача)
|
||||||
|
1. `POST /api/plans/{planId}/draft` → создаёт `DRAFT`-план (`base_plan_id`), триггерит клон миссии, возвращает `{ draftPlanId }`. Параметр `mode`:
|
||||||
|
- `replace` (база в будущем, не заложена): окно/КПП копируются из базы; вызывается lazy на **первую** правку.
|
||||||
|
- `fork-forward` (база `LAID_IN`): окно `{ windowStart, windowStop }` = выбранная оператором **будущая ЗРВ** (из списка доступных, не произвольная); клон миссии — только маршруты из перекрытия с окном (см. mission-planing п.1). Бэк валидирует, что переданное окно соответствует существующей будущей ЗРВ.
|
||||||
|
2. `POST /api/plans/{planId}/issue` — **боевой** эндпоинт ручной выдачи (поднять из `TestTguController` в `PlanController`), с гардом раздела 4 и оптимистичной проверкой базы. Это и есть «Применить/Отправить вручную».
|
||||||
|
3. `DELETE /api/plans/{draftPlanId}` — отказ от черновика (с удалением клон-миссии).
|
||||||
|
4. Цепочка/откат: переиспользуем `base_plan_id`/`chain_version`; восстановление версии = `draft(from=Pk)` → `issue`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Валидация (конфликты) — «Проверить → бэкенд»
|
||||||
|
|
||||||
|
> **РЕШЕНО (MVP): валидация — номинальная заглушка.** На первом этапе `POST /api/missions/{id}/validate` существует и возвращает корректный контракт (пустой/тривиальный список конфликтов), но **реальных правил не считает**. Главное — endpoint есть и UI на него завязан. Полноценные правила — отдельной задачей.
|
||||||
|
|
||||||
|
**TODO (отдельная задача — реальные правила валидации):**
|
||||||
|
- [ ] **Пересечение по времени** между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс).
|
||||||
|
- [ ] **Видимость цели**: окно съёмки внутри окна видимости точки (баллистика).
|
||||||
|
- [ ] **Сеанс связи**: окно сброса внутри ЗРВ станции.
|
||||||
|
- [ ] **Порядок сброса**: сбрасываемые съёмки завершаются строго до начала зоны (уже есть в UI как фильтр кандидатов — закрепить на бэке).
|
||||||
|
- [ ] **Бортовые ограничения**: длительность/скважность, энергетика, память (обсудить).
|
||||||
|
- [ ] Возврат **диффа конфликтов** на затронутые режимы (для отзывчивости), а не всего плана.
|
||||||
|
|
||||||
|
Сейчас проверка локальная (`editorConflicts.ts`); при переносе на бэк UI делает оптимистичное локальное превью контура сразу + дебаунс-синк с эндпоинтом за авторитетными конфликтами.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Фронт — раскладка флоу (после бэка)
|
||||||
|
|
||||||
|
- **Открытие плана** = read-only текущей версии. Версия/черновик не создаётся.
|
||||||
|
- **Первая правка** (`+ Съёмка` / `+ Сброс` / правка / удаление) → `POST /plans/{planId}/draft` → редактор переключается на DRAFT-миссию; дальнейшие правки адресуются в неё.
|
||||||
|
- **Каждая правка** → мутация черновика + `validate` → показ конфликтов. (Заменяет локальный `checkVisibility` и заглушку `saveEditedPlan`.)
|
||||||
|
- **Кнопки** наконец непротиворечивы:
|
||||||
|
- `+ Съёмка / + Сброс` — правка в черновик (не молчаливый персист в живой план).
|
||||||
|
- **Проверить** — полный пересчёт `validate` (или убрать, т.к. валидация на каждой правке).
|
||||||
|
- **Применить** — `POST /plans/{draftPlanId}/issue` (ручная отправка), кнопка погашена, если база не проходит гард (не в будущем / заложена).
|
||||||
|
- **Удалить съёмку** = `DELETE mode` в черновике. **Корректировать** = `PATCH survey-mode` (перестроение маршрута).
|
||||||
|
- **Откат** = выбрать версию из цепочки → «Восстановить» → новый черновик из неё → ревалидация → отправка.
|
||||||
|
|
||||||
|
> Правый инфо-панель включения (виток, аппарат, крен, захват, центр + место под задания) — **уже сделан** в предыдущей итерации (`WorkInspector`), переиспользуется как есть.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Миграции и конфиг
|
||||||
|
|
||||||
|
- **tgu**: значение enum `DRAFT` (хранится строкой — миграция данных не нужна, но проверить CHECK-констрейнты/маппинг в `PlanQueryService` и проекции, чтобы DRAFT не попадал в голову цепочки). Возможна колонка-владелец черновика/`created_by` для GC. Снять флаг `tgu.test-controller` с боевого `issue` (вынести в `PlanController`).
|
||||||
|
- **mission-planing**: схема не меняется (клон = новые строки; validate/patch/delete — без новых таблиц). Проверить каскады при `DELETE mode` и при удалении DRAFT-миссии.
|
||||||
|
- **GC/TTL**: брошенные DRAFT (`planned_plan` + клон-миссия) чистить по TTL — иначе копятся.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Открытые решения (нужно закрыть до кода)
|
||||||
|
|
||||||
|
1. **GC-политика черновиков** — TTL и кто чистит (scheduled worker по аналогии с `*UpdateWorker`).
|
||||||
|
2. **Видимость DRAFT в общем read-пути** — показывать только автору сессии или всем как «черновик»; как метить в `PlanResponse`.
|
||||||
|
|
||||||
|
**Решено (зафиксировано):**
|
||||||
|
- Версия = **клон миссии** (новый `planId==missionId`).
|
||||||
|
- Два режима: `replace` (база в будущем, не заложена → замена слота) и `fork-forward` (база невозвратна → новый план на следующую ЗРВ, новая цепочка).
|
||||||
|
- **Точка невозврата:** план невозвратен, когда `now ≥ startTime − 10 мин` ИЛИ `status = LAID_IN`. До этого (в т.ч. в `AWAITING_LAYIN`) план правим через `replace`. Порог 10 мин — в конфиг (`layinFreezeMinutes`).
|
||||||
|
- **Отмена прежней базы на борту — не нужна:** при супершединге достаточно того, что новый план перекрывает старый по слоту. Явного сигнала «отменить ранее заложенное» не вводим.
|
||||||
|
- **Валидация в MVP — номинальная заглушка** (endpoint есть, правил нет); реальные правила — отдельной задачей (TODO в разделе 6).
|
||||||
|
- При `fork-forward` окно нового плана оператор **выбирает из доступных будущих ЗРВ** (не произвольный ввод) — окно совпадает с реальным слотом, снимая проблему «окно ↔ слот ЗРВ». Преднаполнение — маршруты базы, **пересёкшиеся с окном нового плана** `[start, stop)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Риски
|
||||||
|
|
||||||
|
- **Супершединг уже выданного/заложенного** (решено: отмену на борту не вводим, опираемся на перекрытие слота). Остаточный риск: новый план должен надёжно перекрыть старый по слоту/времени — если перекрытие неполное, борт может частично исполнить старую базу. Контроль перекрытия слота при `replace` обязателен.
|
||||||
|
- **Порог заморозки (10 мин) vs lead-time выдачи** — *снято в Ф1*: `notificationBeforeStartMinutes=263` ≫ `layinFreezeMinutes=10`, поэтому в окне `[startTime − 263м, startTime − 10м)` план обычно уже в авто-выдаче (`ISSUING/WAITING_DECISION`). Лок активной attempt больше этому не мешает: перевыдача **того же** плана разрешена поверх его in-flight attempt (новая замещает старую). Остаточный момент: летят два BPMN-процесса по одному плану, решение по старой attempt запишется как stale (lifecycle не трогает) — для Ф1 приемлемо; явную отмену/cancel старой attempt можно добавить позже, если понадобится.
|
||||||
|
- **Две оси по одному id**: клон должен атомарно создавать и `planned_plan(DRAFT)`, и клон-миссию; частичный сбой → осиротевший id. Нужна согласованность (saga/компенсация или общий триггер через BPMN).
|
||||||
|
- **Дрейф снапшота ЗРВ** под DRAFT: окно базы могло сместиться, пока правили. На «Применить» — ревалидация окна против актуальной проекции.
|
||||||
|
- **Конкуренция операторов**: оптимистичный лок на базе обязателен, иначе тихий клобер.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Фазы реализации
|
||||||
|
|
||||||
|
> Каждая фаза — самостоятельный PR с тестами и критериями приёмки. Помета **«Модель»** — рекомендация,
|
||||||
|
> кому отдавать: `Sonnet` (механика по чёткому контракту), `Opus` (reasoning-heavy: гонки/согласованность),
|
||||||
|
> `Sonnet+ревью Opus` (можно Sonnet, но дифф смотрит Opus). Порядок — снизу вверх по зависимостям;
|
||||||
|
> фазы 2–4 можно вести параллельно после согласования контрактов.
|
||||||
|
|
||||||
|
### Фаза 1 — Боевой endpoint ручной выдачи + гард точки невозврата — ✅ СДЕЛАНО
|
||||||
|
**Модель: Opus** (тонкое взаимодействие порога, lead-time и лока attempt).
|
||||||
|
|
||||||
|
> **Реализовано** (commit на ветке `tgu-ops-ui`):
|
||||||
|
> - `POST /api/plans/{planId}/issue` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → `IssueDuePlanWorker.issuePlanNow`; дублирующий endpoint удалён из `TestTguController`.
|
||||||
|
> - Гард [`assertReissuableInSlot`](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): активная выдача **другого** плана КА → **409**, `LAID_IN` → **422**, `now ≥ startTime − layinFreezeMinutes` → **422**; нет плана → **404**. `REISSUABLE_STATUSES` удалён (гард по времени/статусу заместил список).
|
||||||
|
> - **Перевыдача того же плана поверх авто-выдачи разрешена:** если план уже ушёл в авто-выдачу за `notificationBeforeStartMinutes` (attempt в полёте по этому же плану), оператор всё равно может перевыдать его вручную вплоть до порога заморозки — новая attempt (`attemptNo++`) замещает летящую, активная attempt КА переключается на неё. Блокирует только активная выдача **другого** плана. Это реализует требование: «в автомате выдаём за 263 мин, но сохраняем возможность перевыдать именно этот план, пока до старта > 10 мин».
|
||||||
|
> - `layinFreezeMinutes` добавлен в `PlanningProperties` + `config-repo/pcp-tgu-service.yaml` + `application.yml` (дефолт 10).
|
||||||
|
> - Тесты: 6 граничных кейсов в `IssueDuePlanWorkerOnDemandTest` (вкл. перевыдачу того же плана поверх in-flight attempt и блокировку 409 на чужой план); `PlanIssueAndDecisionFlowTest` переписан под новую семантику (повтор того же плана замещает attempt, а не падает). Весь модуль `:services:pcp-tgu-service:test` зелёный.
|
||||||
|
- `pcp-tgu-service`:
|
||||||
|
- Вынести ручную выдачу из [TestTguController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt#L60) в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt): `POST /api/plans/{planId}/issue`.
|
||||||
|
- Гард в [IssueDuePlanWorker.prepareSpecificPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt#L135): разрешить только если `now < startTime − layinFreezeMinutes` **и** `status ≠ LAID_IN` **и** нет активной attempt по КА (`state.activeAttemptId == null`). Иначе `409/422` с понятной причиной.
|
||||||
|
- Пересмотреть `REISSUABLE_STATUSES` — гард по времени/статусу замещает узкий список статусов (правимы и `AWAITING_LAYIN`, и `ACCEPTED`, пока до старта > порога).
|
||||||
|
- `layinFreezeMinutes` в `PlanningProperties` + `config-repo/pcp-tgu-service.yaml` (дефолт 10).
|
||||||
|
- **Приёмка:** будущий не-`LAID_IN` план > порога → attempt создаётся, BPMN стартует; `now ≥ startTime−10мин` → отказ; `LAID_IN` → отказ; активная attempt по КА → отказ.
|
||||||
|
- **Тесты:** unit-таблица граничных случаев (время × статус) по образцу `IssueDuePlanWorkerOnDemandTest`.
|
||||||
|
|
||||||
|
### Фаза 2 — Черновик-клон (replace + fork-forward)
|
||||||
|
**Модель: Sonnet+ревью Opus** (механика clone простая, но атомарность двух осей смотрит Opus).
|
||||||
|
- `pcp-mission-planing-service`: `POST /api/missions/{id}/clone` body `{ mode, windowStart?, windowStop? }` → `{ newMissionId }`. `replace` — полный клон `Plan`+`Mode`; `fork-forward` — только `Mode`, пересёкшиеся с окном `[start,stop)`. Валидировать, что окно `fork-forward` = существующая будущая ЗРВ.
|
||||||
|
- `pcp-tgu-service`: `POST /api/plans/{planId}/draft` body `{ mode, windowStart?, windowStop? }` → создаёт `planned_plan(DRAFT, base_plan_id)`, триггерит clone миссии, возвращает `{ draftPlanId }`. **Атомарность:** оба создания координируются; при сбое clone — компенсация (не оставлять осиротевший `planned_plan`).
|
||||||
|
- `PlannedPlanStatus.DRAFT`; [PlanProjectionService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt) и `PlanQueryService` — `DRAFT` не голова цепочки и не в проекции.
|
||||||
|
- Миграция: проверить enum/CHECK-констрейнт статуса.
|
||||||
|
- **Приёмка:** `replace`-клон копирует все режимы; `fork-forward` — только пересёкшиеся; `DRAFT` не виден как запланированный слот; сбой clone не оставляет осиротыша.
|
||||||
|
- **Тесты:** предикат перекрытия (границы окна), фильтр `DRAFT` в проекции/запросе.
|
||||||
|
|
||||||
|
### Фаза 3 — Правки контента в черновик
|
||||||
|
**Модель: Sonnet.**
|
||||||
|
- `pcp-mission-planing-service`: `PATCH /api/missions/survey-modes/{modeId}` (перестроение контура по новому окну/крену тем же алгоритмом, что `survey-route/build`), `DELETE /api/missions/modes/{modeId}`.
|
||||||
|
- Переключить [saveSurveyRoute/saveDropRoute](services/pcp-tgu-service/../../../pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt#L61) на адресацию **DRAFT-миссии**; убрать авто-создание живой миссии для editor-сценария.
|
||||||
|
- **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы.
|
||||||
|
- **Тесты:** PATCH меняет контур; DELETE с каскадами.
|
||||||
|
|
||||||
|
### Фаза 4 — Валидация-заглушка
|
||||||
|
**Модель: Sonnet.**
|
||||||
|
- `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6.
|
||||||
|
- **Приёмка:** `200` с пустым diff; контракт стабилен для фронта.
|
||||||
|
|
||||||
|
### Фаза 5 — «Применить» = promote
|
||||||
|
**Модель: Opus** (сердце корректности живого плана).
|
||||||
|
- `pcp-tgu-service`: promote `draftPlanId` → выдача с **оптимистичным локом базы** (проверить, что `P0`/`currentBasePlanId` не изменились с момента создания черновика; иначе `409` «план изменился, посмотрите дифф»). Вход в цепочку: при внешнем `ACCEPT` существующий [acceptPlan](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt#L142) делает супершединг (`chainVersion++`); для `fork-forward` `base_plan_id` уже выставлен.
|
||||||
|
- Контроль **полноты перекрытия слота** при `replace` (остаточный риск раздела 10).
|
||||||
|
- **Приёмка:** успешный promote будущего черновика → `ISSUING→…`; конкурентный promote (база уехала) → `409`; старая версия остаётся в БД (откат возможен).
|
||||||
|
- **Тесты:** оптимистичный лок (база сменилась между `draft` и `promote`), супершединг цепочки.
|
||||||
|
|
||||||
|
### Фаза 6 — Фронт (редактор)
|
||||||
|
**Модель: Sonnet.**
|
||||||
|
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку [saveEditedPlan](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts#L13) на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`.
|
||||||
|
- [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): lazy-создание черновика на первую правку; адресация правок в `DRAFT`; на каждой правке — `validate`.
|
||||||
|
- [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = `validate` (или скрыть), «Применить» = `promote` (гасить по гарду точки невозврата).
|
||||||
|
- Выбор будущей ЗРВ для `fork-forward` (мини-панель/диалог); откат: список версий цепочки → «восстановить».
|
||||||
|
- **Приёмка:** ручной прогон сценария (правка → проверка → применить); кнопки соответствуют состоянию; `tsc`/`vitest` зелёные.
|
||||||
|
|
||||||
|
### Сводка по моделям
|
||||||
|
| Фаза | Тема | Модель |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Endpoint выдачи + гард | **Opus** |
|
||||||
|
| 2 | Черновик-клон | Sonnet + ревью Opus |
|
||||||
|
| 3 | Правки контента | Sonnet |
|
||||||
|
| 4 | Валидация-заглушка | Sonnet |
|
||||||
|
| 5 | Promote | **Opus** |
|
||||||
|
| 6 | Фронт | Sonnet |
|
||||||
+9
-1
@@ -46,5 +46,13 @@ data class PlanningProperties(
|
|||||||
* без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION,
|
* без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION,
|
||||||
* но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки.
|
* но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки.
|
||||||
*/
|
*/
|
||||||
val waitingDecisionGraceMinutes: Long
|
val waitingDecisionGraceMinutes: Long,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порог заморозки перед стартом плана для ручной перевыдачи в свой слот (replace).
|
||||||
|
* Если до начала плана осталось меньше этого числа минут (now >= startTime - layinFreezeMinutes),
|
||||||
|
* план считается невозвратным: заменить его в своём слоте уже нельзя (доступен только fork-forward).
|
||||||
|
* Вместе со статусом LAID_IN образует «точку невозврата».
|
||||||
|
*/
|
||||||
|
val layinFreezeMinutes: Long
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-2
@@ -1,18 +1,23 @@
|
|||||||
package space.nstart.pcp_tgu_service.controller
|
package space.nstart.pcp_tgu_service.controller
|
||||||
|
|
||||||
/** Содержит методы API для чтения текущих планов. */
|
/** Содержит методы API для чтения текущих планов и ручной выдачи плана. */
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RequestParam
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||||
|
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||||
|
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
||||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/plans")
|
@RequestMapping("/api/plans")
|
||||||
class PlanController(
|
class PlanController(
|
||||||
private val planQueryService: PlanQueryService
|
private val planQueryService: PlanQueryService,
|
||||||
|
private val issueDuePlanWorker: IssueDuePlanWorker
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
/** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */
|
||||||
@@ -27,4 +32,14 @@ class PlanController(
|
|||||||
@RequestParam(defaultValue = "7") historyDays: Long
|
@RequestParam(defaultValue = "7") historyDays: Long
|
||||||
): List<PlanResponse> =
|
): List<PlanResponse> =
|
||||||
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ручная выдача плана оператором («Применить / Отправить вручную»). Стартует новую attempt
|
||||||
|
* и Camunda-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки,
|
||||||
|
* не заложен (≠ LAID_IN) и по КА не должно быть активной выдачи.
|
||||||
|
* 404 — плана нет; 409 — по КА уже идёт выдача; 422 — точка невозврата пройдена (нужен fork-forward).
|
||||||
|
*/
|
||||||
|
@PostMapping("/{planId}/issue")
|
||||||
|
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
|
||||||
|
issueDuePlanWorker.issuePlanNow(planId)
|
||||||
}
|
}
|
||||||
|
|||||||
-8
@@ -12,10 +12,8 @@ import org.springframework.web.bind.annotation.RestController
|
|||||||
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
import space.nstart.pcp_tgu_service.domain.PlanDecision
|
||||||
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage
|
||||||
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
import space.nstart.pcp_tgu_service.dto.PlanResponse
|
||||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
|
||||||
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
|
import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient
|
||||||
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase
|
||||||
import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker
|
|
||||||
import space.nstart.pcp_tgu_service.service.PlanProjectionService
|
import space.nstart.pcp_tgu_service.service.PlanProjectionService
|
||||||
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
import space.nstart.pcp_tgu_service.service.PlanQueryService
|
||||||
import space.nstart.pcp_tgu_service.service.PlatformService
|
import space.nstart.pcp_tgu_service.service.PlatformService
|
||||||
@@ -35,7 +33,6 @@ import java.util.UUID
|
|||||||
class TestTguController(
|
class TestTguController(
|
||||||
private val visibilityRefreshJobService: VisibilityRefreshJobService,
|
private val visibilityRefreshJobService: VisibilityRefreshJobService,
|
||||||
private val planProjectionService: PlanProjectionService,
|
private val planProjectionService: PlanProjectionService,
|
||||||
private val issueDuePlanWorker: IssueDuePlanWorker,
|
|
||||||
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
|
private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase,
|
||||||
private val testPlanDecisionService: TestPlanDecisionService,
|
private val testPlanDecisionService: TestPlanDecisionService,
|
||||||
private val externalPointsClient: ExternalPointsClient,
|
private val externalPointsClient: ExternalPointsClient,
|
||||||
@@ -56,11 +53,6 @@ class TestTguController(
|
|||||||
fun getProjection(@PathVariable spacecraftId: String): List<PlanResponse> =
|
fun getProjection(@PathVariable spacecraftId: String): List<PlanResponse> =
|
||||||
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
|
planProjectionService.project(spacecraftId).map(PlanResponse::projected)
|
||||||
|
|
||||||
/** Принудительно выдаёт конкретный persisted plan через новую attempt. */
|
|
||||||
@PostMapping("/plans/{planId}/issue")
|
|
||||||
fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse =
|
|
||||||
issueDuePlanWorker.issuePlanNow(planId)
|
|
||||||
|
|
||||||
/** Инжектит decision event через тот же use case, что Kafka consumer. */
|
/** Инжектит decision event через тот же use case, что Kafka consumer. */
|
||||||
@PostMapping("/plan-decisions")
|
@PostMapping("/plan-decisions")
|
||||||
fun handleDecision(@RequestBody message: PlanDecisionMessage) {
|
fun handleDecision(@RequestBody message: PlanDecisionMessage) {
|
||||||
|
|||||||
+45
-19
@@ -48,10 +48,14 @@ class IssueDuePlanWorker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */
|
/**
|
||||||
|
* Ручная выдача конкретного persisted plan: создаёт новую attempt и стартует её.
|
||||||
|
* Гард «точки невозврата» (см. [assertReissuableInSlot]) выполняется под lock'ом состояния КА.
|
||||||
|
* Бросает 404, если плана нет; 409/422 — если выдать его нельзя.
|
||||||
|
*/
|
||||||
fun issuePlanNow(planId: UUID): ProcessStartResponse {
|
fun issuePlanNow(planId: UUID): ProcessStartResponse {
|
||||||
val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) }
|
val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) }
|
||||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found or cannot be issued")
|
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found")
|
||||||
return startPreparedAttempt(prepared)
|
return startPreparedAttempt(prepared)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +140,7 @@ class IssueDuePlanWorker(
|
|||||||
val now = LocalDateTime.now(ZoneOffset.UTC)
|
val now = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null
|
||||||
val state = getOrCreateLockedState(plan.spacecraftId)
|
val state = getOrCreateLockedState(plan.spacecraftId)
|
||||||
if (state.activeAttemptId != null) {
|
assertReissuableInSlot(plan, state, now)
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (plan.status !in REISSUABLE_STATUSES) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
plan.status = PlannedPlanStatus.ISSUING
|
plan.status = PlannedPlanStatus.ISSUING
|
||||||
plan.updatedAt = now
|
plan.updatedAt = now
|
||||||
@@ -157,6 +156,44 @@ class IssueDuePlanWorker(
|
|||||||
return PreparedAttempt(plan.planId, attempt.attemptId)
|
return PreparedAttempt(plan.planId, attempt.attemptId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException],
|
||||||
|
* если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия:
|
||||||
|
* - по КА нет активной выдачи ДРУГОГО плана (иначе 409 CONFLICT — две выдачи разом недопустимы);
|
||||||
|
* - план не подтверждён как заложенный (`status != LAID_IN` — иначе 422, доступен только fork-forward);
|
||||||
|
* - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 — окно заморожено).
|
||||||
|
*
|
||||||
|
* Важно: повторную ручную выдачу ТОГО ЖЕ плана разрешаем, даже если по нему уже летит attempt
|
||||||
|
* (например, план только что ушёл в авто-выдачу за `notificationBeforeStartMinutes`). Оператор сохраняет
|
||||||
|
* право перевыдать именно этот план вплоть до порога заморозки — новая attempt замещает текущую in-flight
|
||||||
|
* (запоздалое решение по старой attempt запишется как stale и цепочку не тронет).
|
||||||
|
*
|
||||||
|
* Узкого списка «перевыдаваемых» статусов больше нет: правим в т.ч. 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}) — дождитесь её завершения"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (plan.status == PlannedPlanStatus.LAID_IN) {
|
||||||
|
throw ResponseStatusException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
"План ${plan.planId} уже заложен (LAID_IN) — перевыдача в свой слот невозможна, используйте fork-forward"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val freezeAt = plan.startTime.minusMinutes(planningProperties.layinFreezeMinutes)
|
||||||
|
if (!now.isBefore(freezeAt)) {
|
||||||
|
throw ResponseStatusException(
|
||||||
|
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||||
|
"До старта плана ${plan.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин — " +
|
||||||
|
"окно выдачи заморожено (точка невозврата), используйте fork-forward"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun materializeIssuingPlan(
|
private fun materializeIssuingPlan(
|
||||||
head: CalculatedPlan,
|
head: CalculatedPlan,
|
||||||
state: SpacecraftPlanningStateEntity,
|
state: SpacecraftPlanningStateEntity,
|
||||||
@@ -255,15 +292,4 @@ class IssueDuePlanWorker(
|
|||||||
val planId: UUID,
|
val planId: UUID,
|
||||||
val attemptId: UUID
|
val attemptId: UUID
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Статусы persisted-плана, который допустимо выдать вручную через issuePlanNow (test-only). */
|
|
||||||
val REISSUABLE_STATUSES = setOf(
|
|
||||||
PlannedPlanStatus.PLANNED,
|
|
||||||
PlannedPlanStatus.REJECTED,
|
|
||||||
PlannedPlanStatus.LAYIN_FAILED,
|
|
||||||
PlannedPlanStatus.NOT_ACCEPTED,
|
|
||||||
PlannedPlanStatus.START_AMBIGUOUS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ planning:
|
|||||||
reissue-prep-minutes: 30
|
reissue-prep-minutes: 30
|
||||||
worker-lock-ttl-seconds: 300
|
worker-lock-ttl-seconds: 300
|
||||||
waiting-decision-grace-minutes: 30
|
waiting-decision-grace-minutes: 30
|
||||||
|
layin-freeze-minutes: 10
|
||||||
|
|
||||||
topics:
|
topics:
|
||||||
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
|
||||||
|
|||||||
+2
-1
@@ -116,6 +116,7 @@ class ExternalPointsClientTest {
|
|||||||
minReissueLeadMinutes = 5,
|
minReissueLeadMinutes = 5,
|
||||||
reissuePrepMinutes = 30,
|
reissuePrepMinutes = 30,
|
||||||
workerLockTtlSeconds = 300,
|
workerLockTtlSeconds = 300,
|
||||||
waitingDecisionGraceMinutes = 30
|
waitingDecisionGraceMinutes = 30,
|
||||||
|
layinFreezeMinutes = 10
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest {
|
|||||||
starter: AcceptedPlanSlotsUpdateProcessStarter
|
starter: AcceptedPlanSlotsUpdateProcessStarter
|
||||||
): AcceptedPlanSlotsUpdateWorker =
|
): AcceptedPlanSlotsUpdateWorker =
|
||||||
AcceptedPlanSlotsUpdateWorker(
|
AcceptedPlanSlotsUpdateWorker(
|
||||||
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30),
|
planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10),
|
||||||
acceptedPlanSlotsUpdateJobRepository = repository,
|
acceptedPlanSlotsUpdateJobRepository = repository,
|
||||||
acceptedPlanSlotsUpdateProcessStarter = starter,
|
acceptedPlanSlotsUpdateProcessStarter = starter,
|
||||||
transactionTemplate = transactionTemplate()
|
transactionTemplate = transactionTemplate()
|
||||||
|
|||||||
+126
-1
@@ -1,10 +1,14 @@
|
|||||||
package space.nstart.pcp_tgu_service.service
|
package space.nstart.pcp_tgu_service.service
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.assertNotNull
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.web.server.ResponseStatusException
|
||||||
import org.mockito.Mockito.mock
|
import org.mockito.Mockito.mock
|
||||||
import org.mockito.Mockito.never
|
import org.mockito.Mockito.never
|
||||||
import org.mockito.Mockito.verify
|
import org.mockito.Mockito.verify
|
||||||
@@ -18,6 +22,7 @@ import space.nstart.pcp_tgu_service.config.PlanningProperties
|
|||||||
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
|
||||||
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
import space.nstart.pcp_tgu_service.dto.ProcessStartResponse
|
||||||
import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity
|
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.PlannedPlanEntity
|
||||||
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus
|
||||||
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity
|
||||||
@@ -160,6 +165,126 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
assertEquals(attemptId, state.activeAttemptId)
|
assertEquals(attemptId, state.activeAttemptId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Ручная выдача issuePlanNow + гард «точки невозврата» (layinFreezeMinutes = 10) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow issues a future not-laid-in plan past the freeze threshold`() {
|
||||||
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
|
// Принятый план в будущем (старт через 30 мин > порога 10 мин), активной выдачи нет.
|
||||||
|
val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3))
|
||||||
|
planStore[target.planId] = target
|
||||||
|
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||||
|
val worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
worker.issuePlanNow(target.planId)
|
||||||
|
|
||||||
|
// Выдача стартовала: новая attempt, план ушёл в WAITING_DECISION (markStarted), КА залочен.
|
||||||
|
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
|
||||||
|
assertEquals(1, attemptStore.size)
|
||||||
|
assertEquals(target.planId, state.activePlanId)
|
||||||
|
assertNotNull(state.activeAttemptId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow rejects a plan inside the freeze window`() {
|
||||||
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
|
// Старт через 5 мин (< порога 10 мин) → точка невозврата пройдена.
|
||||||
|
val target = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3))
|
||||||
|
planStore[target.planId] = target
|
||||||
|
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||||
|
val worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||||
|
assertEquals(PlannedPlanStatus.ACCEPTED, planStore[target.planId]!!.status)
|
||||||
|
assertTrue(attemptStore.isEmpty())
|
||||||
|
assertNull(state.activeAttemptId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow rejects a laid-in plan`() {
|
||||||
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
|
// В будущем за порогом, но уже подтверждён как заложенный → только fork-forward.
|
||||||
|
val target = persistedPlan(PlannedPlanStatus.LAID_IN, now().plusMinutes(30), now().plusHours(3))
|
||||||
|
planStore[target.planId] = target
|
||||||
|
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||||
|
val worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||||
|
assertTrue(attemptStore.isEmpty())
|
||||||
|
assertNull(state.activeAttemptId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow rejects when spacecraft has an active attempt for another plan`() {
|
||||||
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
|
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 worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) }
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||||
|
assertTrue(attemptStore.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow re-issues the same plan over its own in-flight attempt`() {
|
||||||
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
|
// План уже ушёл в авто-выдачу (attempt #1 в полёте), но до старта далеко (> порога).
|
||||||
|
val target = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3))
|
||||||
|
planStore[target.planId] = target
|
||||||
|
val inFlight = PlanIssueAttemptEntity(
|
||||||
|
attemptId = UUID.randomUUID(),
|
||||||
|
planId = target.planId,
|
||||||
|
attemptNo = 1,
|
||||||
|
status = PlanIssueAttemptStatus.STARTED,
|
||||||
|
createdAt = now(),
|
||||||
|
updatedAt = now()
|
||||||
|
)
|
||||||
|
attemptStore[inFlight.attemptId] = inFlight
|
||||||
|
val state = SpacecraftPlanningStateEntity(
|
||||||
|
spacecraftId = spacecraftId,
|
||||||
|
activePlanId = target.planId,
|
||||||
|
activeAttemptId = inFlight.attemptId
|
||||||
|
)
|
||||||
|
val worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
worker.issuePlanNow(target.planId)
|
||||||
|
|
||||||
|
// Новая attempt #2 замещает летящую #1; план снова WAITING_DECISION, КА указывает на новую attempt.
|
||||||
|
assertEquals(2, attemptStore.size)
|
||||||
|
assertEquals(target.planId, state.activePlanId)
|
||||||
|
assertNotEquals(inFlight.attemptId, state.activeAttemptId)
|
||||||
|
assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow returns 404 for unknown plan`() {
|
||||||
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
|
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||||
|
val worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(UUID.randomUUID()) }
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
|
private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity =
|
||||||
PlannedPlanEntity(
|
PlannedPlanEntity(
|
||||||
planId = UUID.randomUUID(),
|
planId = UUID.randomUUID(),
|
||||||
@@ -264,7 +389,7 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
|
|
||||||
private fun planningProperties(): PlanningProperties =
|
private fun planningProperties(): PlanningProperties =
|
||||||
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30)
|
PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, 10)
|
||||||
|
|
||||||
private fun transactionTemplate(): TransactionTemplate =
|
private fun transactionTemplate(): TransactionTemplate =
|
||||||
TransactionTemplate(
|
TransactionTemplate(
|
||||||
|
|||||||
+2
-1
@@ -340,7 +340,8 @@ class PlanCalculationServiceTest {
|
|||||||
minReissueLeadMinutes = 5,
|
minReissueLeadMinutes = 5,
|
||||||
reissuePrepMinutes = 30,
|
reissuePrepMinutes = 30,
|
||||||
workerLockTtlSeconds = 300,
|
workerLockTtlSeconds = 300,
|
||||||
waitingDecisionGraceMinutes = 30
|
waitingDecisionGraceMinutes = 30,
|
||||||
|
layinFreezeMinutes = 10
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+5
-7
@@ -38,8 +38,9 @@ import java.util.UUID
|
|||||||
class PlanIssueAndDecisionFlowTest {
|
class PlanIssueAndDecisionFlowTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `issuePlanNow creates one active attempt and repeated manual issue creates a new attempt after state cleared`() {
|
fun `repeated manual issue of the same plan supersedes its in-flight attempt`() {
|
||||||
val spacecraftId = "25544"
|
val spacecraftId = "25544"
|
||||||
|
// Старт через 12 часов — далеко за порогом заморозки, перевыдача того же плана разрешена.
|
||||||
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED)
|
val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED)
|
||||||
val planStore = linkedMapOf(plan.planId to plan)
|
val planStore = linkedMapOf(plan.planId to plan)
|
||||||
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
val attemptStore = linkedMapOf<UUID, PlanIssueAttemptEntity>()
|
||||||
@@ -47,17 +48,14 @@ class PlanIssueAndDecisionFlowTest {
|
|||||||
val worker = issueWorker(planStore, attemptStore, state)
|
val worker = issueWorker(planStore, attemptStore, state)
|
||||||
|
|
||||||
val firstResponse = worker.issuePlanNow(plan.planId)
|
val firstResponse = worker.issuePlanNow(plan.planId)
|
||||||
val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull()
|
// Тот же план, attempt #1 ещё «в полёте» — ручная перевыдача не блокируется, а замещает её.
|
||||||
state.activePlanId = null
|
|
||||||
state.activeAttemptId = null
|
|
||||||
plan.status = PlannedPlanStatus.REJECTED
|
|
||||||
val secondResponse = worker.issuePlanNow(plan.planId)
|
val secondResponse = worker.issuePlanNow(plan.planId)
|
||||||
|
|
||||||
assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
|
assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo)
|
||||||
assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
|
assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo)
|
||||||
assertNotEquals(firstResponse.attemptId, secondResponse.attemptId)
|
assertNotEquals(firstResponse.attemptId, secondResponse.attemptId)
|
||||||
|
assertEquals(secondResponse.attemptId, state.activeAttemptId)
|
||||||
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
|
assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status)
|
||||||
assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -333,7 +331,7 @@ class PlanIssueAndDecisionFlowTest {
|
|||||||
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong())
|
||||||
|
|
||||||
private fun planningProperties(): PlanningProperties =
|
private fun planningProperties(): PlanningProperties =
|
||||||
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30)
|
PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10)
|
||||||
|
|
||||||
private fun transactionTemplate(): TransactionTemplate =
|
private fun transactionTemplate(): TransactionTemplate =
|
||||||
TransactionTemplate(
|
TransactionTemplate(
|
||||||
|
|||||||
Reference in New Issue
Block a user