From e060e13b85a7622aec6afa227d08381bbebf6096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 3 Jun 2026 12:40:03 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-service:=20=D0=B1=D0=BE=D0=B5=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20POST=20/api/plans/{planId}/issue=20+=20=D0=B3?= =?UTF-8?q?=D0=B0=D1=80=D0=B4=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ручная выдача вынесена из 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 отмечена сделанной) --- config-repo/pcp-tgu-service.yaml | 3 + ...TASK_tgu-plan-versioning-manual-reissue.md | 278 ++++++++++++++++++ .../config/PlanningProperties.kt | 10 +- .../controller/PlanController.kt | 19 +- .../controller/TestTguController.kt | 8 - .../service/IssueDuePlanWorker.kt | 64 ++-- .../src/main/resources/application.yml | 1 + .../api/ExternalPointsClientTest.kt | 3 +- .../AcceptedPlanSlotsUpdateFlowTest.kt | 2 +- .../service/IssueDuePlanWorkerOnDemandTest.kt | 127 +++++++- .../service/PlanCalculationServiceTest.kt | 3 +- .../service/PlanIssueAndDecisionFlowTest.kt | 12 +- 12 files changed, 489 insertions(+), 41 deletions(-) create mode 100644 docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index 50c3800..46208ed 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -42,6 +42,9 @@ planning: reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 waiting-decision-grace-minutes: 30 + # Заморозка перед стартом: ручная replace-перевыдача плана в свой слот запрещена, + # если до старта осталось меньше этого числа минут (часть «точки невозврата»). + layin-freeze-minutes: 10 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 diff --git a/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md new file mode 100644 index 0000000..7e3f0f7 --- /dev/null +++ b/docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md @@ -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 | diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index 0ac4329..e1e83de 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -46,5 +46,13 @@ data class PlanningProperties( * без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION, * но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки. */ - val waitingDecisionGraceMinutes: Long + val waitingDecisionGraceMinutes: Long, + + /** + * Порог заморозки перед стартом плана для ручной перевыдачи в свой слот (replace). + * Если до начала плана осталось меньше этого числа минут (now >= startTime - layinFreezeMinutes), + * план считается невозвратным: заменить его в своём слоте уже нельзя (доступен только fork-forward). + * Вместе со статусом LAID_IN образует «точку невозврата». + */ + val layinFreezeMinutes: Long ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index b67a0bb..6439b41 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -1,18 +1,23 @@ package space.nstart.pcp_tgu_service.controller -/** Содержит методы API для чтения текущих планов. */ +/** Содержит методы API для чтения текущих планов и ручной выдачи плана. */ import org.springframework.web.bind.annotation.GetMapping 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.RequestParam import org.springframework.web.bind.annotation.RestController 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 java.util.UUID @RestController @RequestMapping("/api/plans") class PlanController( - private val planQueryService: PlanQueryService + private val planQueryService: PlanQueryService, + private val issueDuePlanWorker: IssueDuePlanWorker ) { /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @@ -27,4 +32,14 @@ class PlanController( @RequestParam(defaultValue = "7") historyDays: Long ): List = 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) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt index be17d65..4ede946 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt @@ -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.dto.PlanDecisionMessage 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.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.PlanQueryService import space.nstart.pcp_tgu_service.service.PlatformService @@ -35,7 +33,6 @@ import java.util.UUID class TestTguController( private val visibilityRefreshJobService: VisibilityRefreshJobService, private val planProjectionService: PlanProjectionService, - private val issueDuePlanWorker: IssueDuePlanWorker, private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase, private val testPlanDecisionService: TestPlanDecisionService, private val externalPointsClient: ExternalPointsClient, @@ -56,11 +53,6 @@ class TestTguController( fun getProjection(@PathVariable spacecraftId: String): List = 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. */ @PostMapping("/plan-decisions") fun handleDecision(@RequestBody message: PlanDecisionMessage) { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index 777d456..fa9ea8e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -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 { 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) } @@ -136,12 +140,7 @@ class IssueDuePlanWorker( val now = LocalDateTime.now(ZoneOffset.UTC) val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null val state = getOrCreateLockedState(plan.spacecraftId) - if (state.activeAttemptId != null) { - return null - } - if (plan.status !in REISSUABLE_STATUSES) { - return null - } + assertReissuableInSlot(plan, state, now) plan.status = PlannedPlanStatus.ISSUING plan.updatedAt = now @@ -157,6 +156,44 @@ class IssueDuePlanWorker( 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( head: CalculatedPlan, state: SpacecraftPlanningStateEntity, @@ -255,15 +292,4 @@ class IssueDuePlanWorker( val planId: 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 - ) - } } diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 99adb35..da1e015 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -25,6 +25,7 @@ planning: reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 waiting-decision-grace-minutes: 30 + layin-freeze-minutes: 10 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index 5cd1237..d00721d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -116,6 +116,7 @@ class ExternalPointsClientTest { minReissueLeadMinutes = 5, reissuePrepMinutes = 30, workerLockTtlSeconds = 300, - waitingDecisionGraceMinutes = 30 + waitingDecisionGraceMinutes = 30, + layinFreezeMinutes = 10 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt index 5f00dad..da72127 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest { starter: AcceptedPlanSlotsUpdateProcessStarter ): 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, acceptedPlanSlotsUpdateProcessStarter = starter, transactionTemplate = transactionTemplate() diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt index a14c597..ad05243 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -1,10 +1,14 @@ package space.nstart.pcp_tgu_service.service 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.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException import org.mockito.Mockito.mock import org.mockito.Mockito.never 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.dto.ProcessStartResponse 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 @@ -160,6 +165,126 @@ class IssueDuePlanWorkerOnDemandTest { assertEquals(attemptId, state.activeAttemptId) } + // --- Ручная выдача issuePlanNow + гард «точки невозврата» (layinFreezeMinutes = 10) --- + + @Test + fun `issuePlanNow issues a future not-laid-in plan past the freeze threshold`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Принятый план в будущем (старт через 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() + val attemptStore = linkedMapOf() + // Старт через 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() + val attemptStore = linkedMapOf() + // В будущем за порогом, но уже подтверждён как заложенный → только 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() + val attemptStore = linkedMapOf() + 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() + val attemptStore = linkedMapOf() + // План уже ушёл в авто-выдачу (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() + val attemptStore = linkedMapOf() + 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 = PlannedPlanEntity( planId = UUID.randomUUID(), @@ -264,7 +389,7 @@ class IssueDuePlanWorkerOnDemandTest { private fun now(): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) 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 = TransactionTemplate( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index f2c1877..57ebfad 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -340,7 +340,8 @@ class PlanCalculationServiceTest { minReissueLeadMinutes = 5, reissuePrepMinutes = 30, workerLockTtlSeconds = 300, - waitingDecisionGraceMinutes = 30 + waitingDecisionGraceMinutes = 30, + layinFreezeMinutes = 10 ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 93cd737..618909a 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -38,8 +38,9 @@ import java.util.UUID class PlanIssueAndDecisionFlowTest { @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" + // Старт через 12 часов — далеко за порогом заморозки, перевыдача того же плана разрешена. val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED) val planStore = linkedMapOf(plan.planId to plan) val attemptStore = linkedMapOf() @@ -47,17 +48,14 @@ class PlanIssueAndDecisionFlowTest { val worker = issueWorker(planStore, attemptStore, state) val firstResponse = worker.issuePlanNow(plan.planId) - val blocked = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() - state.activePlanId = null - state.activeAttemptId = null - plan.status = PlannedPlanStatus.REJECTED + // Тот же план, attempt #1 ещё «в полёте» — ручная перевыдача не блокируется, а замещает её. val secondResponse = worker.issuePlanNow(plan.planId) assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo) assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo) assertNotEquals(firstResponse.attemptId, secondResponse.attemptId) + assertEquals(secondResponse.attemptId, state.activeAttemptId) assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) - assertTrue(blocked?.message?.startsWith("404 NOT_FOUND") == true) } @Test @@ -333,7 +331,7 @@ class PlanIssueAndDecisionFlowTest { private fun time(hour: Int): LocalDateTime = LocalDateTime.now(ZoneOffset.UTC).plusHours(hour.toLong()) 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 = TransactionTemplate(