pcp-tgu: пауза цепочки после REJECTED + режим «Черновики» + UI цепочек/ЗРВ
Бэкенд (pcp-tgu-service): - REJECTED трактуется как «план неправильный»: авто-перевыдача больше НЕ взводится, цепочка встаёт на паузу (новый флаг SpacecraftPlanningStateEntity.pausedAfterReject, миграция V13). Технические негативы («не заложен»/«не принят») по-прежнему дают срочную перевыдачу. - Пауза централизована в PlanProjectionService.project(): для отклонённой цепочки проекция пустая, воркер выдачи ничего не материализует. Флаг снимается при ручной выдаче/промоуте черновика/приёмке (оператор вмешался). - Режим «Черновики»: GET /plans/drafts (опц. ?spacecraftId) -> только DRAFT. - Гард дублей черновиков: (КА + начало + конец) уникальны среди DRAFT, повторное создание окна -> 409. - ZrvWindowMatcher: сравнение по истинной abs-длительности Duration (<=1с), устойчиво к усечению ISO на фронте до целых секунд. Фронт (pcp-tgu-ops-ui): вкладка «Планы» — таймлайн цепочек, детали плана, панель ЗРВ (TguZrvPanel), диалог создания цепочки, режим черновиков. Удалён устаревший docs/tasks/TASK_tgu-plan-versioning-manual-reissue.md.
This commit is contained in:
@@ -1,322 +0,0 @@
|
|||||||
# Задача (дизайн): версионирование плана + ручная перевыдача из редактора ТГУ
|
|
||||||
|
|
||||||
> **Статус: ДИЗАЙН-ДОК НА СОГЛАСОВАНИЕ. Код не пишем, пока не закрыты «Открытые решения» (раздел 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}` — отказ от черновика (с удалением клон-миссии). ✅ СДЕЛАНО — [DeleteDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt) (404 нет / 422 не DRAFT / 204 успех; удаление клон-миссии best-effort, строка удаляется первой) + [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) `DELETE /api/plans/{draftPlanId}`; 4 unit-теста в `DeleteDraftUseCaseTest`. Фронт: `abandonDraft` + кнопка «Отмена» в редакторе + авто-отмена незавершённого черновика при смене плана/уходе (best-effort, не оставляет осиротевший DRAFT).
|
|
||||||
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).
|
|
||||||
|
|
||||||
> **Реализовано:**
|
|
||||||
> - `POST /api/missions/{id}/clone` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt): replace — полный клон; fork-forward — только включения, перекрывающиеся с окном `(modeStop > windowStart && modeStart < windowStop)`. DropMode клонируется только если хотя бы одна из его съёмок вошла в клон (иначе пустой drop пропускается). Перекрытие: открытый правый конец `[start, stop)`.
|
|
||||||
> - `PlannedPlanStatus.DRAFT` в [PlannedPlanStatus](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt) — персистируется, не входит в проекцию и read-путь.
|
|
||||||
> - `POST /api/plans/{planId}/draft` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt): создаёт DRAFT-план с `planId = newMissionId` (инвариант planId==missionId сохраняется); при сбоге save — компенсация (delete клон-миссии). Для fork-forward валидирует, что окно есть в проекции КА.
|
|
||||||
> - DRAFT отфильтрован в [PlanQueryService](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt) (оба метода).
|
|
||||||
> - [MissionPlaningClient](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt) + `MissionPlaningProperties` + `mission-planing.base-url` в yaml.
|
|
||||||
> - Тесты: 6 кейсов в `MissionCloneTest` (граничные случаи предиката перекрытия); 1 кейс в `PlanQueryServiceTest` (DRAFT не виден). Оба модуля зелёные.
|
|
||||||
- `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.**
|
|
||||||
|
|
||||||
> **Реализовано:**
|
|
||||||
> - `PATCH /api/missions/survey-modes/{modeId}` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [updateSurveyRoute](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): перестраивает контур существующей съёмки тем же `buildContour`, что и `/survey-route/build`, и сохраняет на месте (timeStart/revolution/lat/long/duration/contourWkt/roll). Источник переводится в `MANUAL` (правка переживает пересчёт), статус сохраняется. Нет режима → 404. Реюзит `SurveyRouteBuildRequestDTO`.
|
|
||||||
> - `DELETE /api/missions/modes/{modeId}` → [deleteMode](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): диспатч SURVEY/DROP. Съёмка — снимаются брони слотов (`mode_booking`) и связи `drop_mode_survey`; сброс, оставшийся без единой съёмки, удаляется (осиротевший); другие съёмки того же сброса не трогаются. Сброс — удаляются только его связи и он сам, съёмки независимы. Нет режима → 404.
|
|
||||||
> - `saveSurveyRoute`/`saveDropRoute` больше **не создают живую миссию на лету** — адресуется только существующая миссия-черновик; нет миссии → 404. В `saveSurveyRoute` проверка миссии вынесена **до** дорогого `buildContour` (fail-fast). Swagger-описания и kdoc обновлены.
|
|
||||||
> - Репозиторий: `DropModeSurveyRepository.findAllBySurveyMode_Id` (поиск связей по съёмке).
|
|
||||||
> - Тесты: `MissionEditModesTest` (6) — каскады DELETE (осиротевший сброс удаляется; общий сброс с другой съёмкой остаётся; удаление сброса не трогает съёмки; 404 на неизвестный режим) + 404 на `saveSurveyRoute`/`saveDropRoute` без миссии. Весь модуль зелёный.
|
|
||||||
- `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.**
|
|
||||||
|
|
||||||
> **Реализовано:**
|
|
||||||
> - `POST /api/missions/{id}/validate` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [validateMission](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): проверяет существование миссии (404, если нет) и возвращает корректный контракт с **пустым** списком конфликтов. Реальные правила — TODO раздела 6.
|
|
||||||
> - Контракт `MissionValidationResponse { missionId, conflicts: [ModeConflict] }`, где `ModeConflict { modeId, rule, severity, message, relatedModeId? }` ([MissionValidationDTOs.kt](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt)). Коды правил (`ConflictRule`: TIME_OVERLAP/TARGET_VISIBILITY/COMMS_SESSION/DROP_ORDER/ONBOARD_LIMIT) и уровни (`ConflictSeverity`: error/warning) заведены сразу как стабильная схема — появление реальных правил не сломает фронт. Дифф по режимам (а не весь план).
|
|
||||||
> - Тесты: 2 кейса в `MissionEditModesTest` (валидация существующей миссии → пустой список; неизвестной → 404). Весь модуль зелёный.
|
|
||||||
- `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6.
|
|
||||||
- **Приёмка:** `200` с пустым diff; контракт стабилен для фронта.
|
|
||||||
|
|
||||||
### Фаза 5 — «Применить» = promote — ✅ СДЕЛАНО
|
|
||||||
**Модель: Opus** (сердце корректности живого плана).
|
|
||||||
|
|
||||||
> **Реализовано:**
|
|
||||||
> - `POST /api/plans/{draftPlanId}/promote` в [PlanController](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt) → [IssueDuePlanWorker.promoteDraftNow](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt): под lock'ом состояния КА промоутит DRAFT→ISSUING и сразу выдаёт (новая attempt + BPMN, переиспользуя `startPreparedAttempt`).
|
|
||||||
> - **Оптимистичный лок базы:** [CreateDraftUseCase](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt) снимает `spacecraft_planning_state.chain_version` на момент создания черновика и хранит его в `planned_plan.chain_version` DRAFT-строки. На promote [assertDraftBaseUnchanged](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt) требует совпадения с текущей версией цепочки; любой ACCEPT/grace-сдвиг базы её инкрементит → **409** «база уехала, посмотрите дифф». Прежние версии остаются в БД (откат = новый черновик из старой версии).
|
|
||||||
> - **Контроль слота/дрейфа ЗРВ** ([assertDraftSlotStillValid](services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt)): окно черновика должно совпадать с реальной будущей ЗРВ — для fork-forward слот берётся из проекции, для replace учитывается окно заменяемого базового плана (проекция, заякоренная после плана в полёте, его не возвращает). Сместился снапшот → **422**.
|
|
||||||
> - **Гард точки невозврата** переиспользован (`assertReissuableInSlot`): заморожено / активная выдача другого плана → 422 / 409. Вход в цепочку штатный: на внешний ACCEPT существующий `acceptPlan` делает супершединг (`chainVersion++`); `basePlanId` черновика не трогаем (провенанс — что замещаем/продолжаем).
|
|
||||||
> - Не-DRAFT план → 422, неизвестный черновик → 404.
|
|
||||||
> - Тесты: 7 кейсов promote в `IssueDuePlanWorkerOnDemandTest` (успех; конкурентный сдвиг базы → 409; не-DRAFT → 422; неизвестный → 404; заморозка → 422; чужая активная выдача → 409; дрейф окна → 422) + 1 кейс снимка версии в `CreateDraftUseCaseTest`. Весь модуль зелёный.
|
|
||||||
- `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 — Фронт (редактор) — ✅ ЯДРО СДЕЛАНО (replace-draft + validate + promote)
|
|
||||||
**Модель: Sonnet.**
|
|
||||||
|
|
||||||
> **Реализовано (ядро):**
|
|
||||||
> - API-слой: [missionPlanningApi.ts](services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts) — `updateSurveyRoute` (PATCH), `deleteMode` (DELETE), `validateMission` (POST validate, тип `MissionValidation`/`ModeConflict`); новый [tguPlanApi.ts](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts) — `createDraft`/`promoteDraft`. Прямые вызовы через Vite-прокси: `/api/pcp-mission`→:7010, новый `/api/pcp-tgu`→:7011 (без BFF, как и было решено).
|
|
||||||
> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): ленивое создание DRAFT (`ensureDraft`, mode=replace) на первую сохранённую правку; `saveSurveyRoute`/`saveDropRoute`/`deleteMode` адресуются в `draftPlanId`, а не в живой план; после каждой серверной правки — перечитывание включений DRAFT + `validate`; смена выбранного плана сбрасывает черновик; дедуп включения (локальная правка ⊕ перечитанная plan-origin копия по `planModeId`).
|
|
||||||
> - Маппинг авторитетных конфликтов бэка на включения редактора: [editorValidation.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts) (по `planModeId`) + 3 теста.
|
|
||||||
> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением.
|
|
||||||
> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные.
|
|
||||||
>
|
|
||||||
> **fork-forward в UI — ✅ СДЕЛАНО:**
|
|
||||||
> - Селектор «Целевая ЗРВ» в [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Текущий слот (правка)» (replace, по умолчанию) ⊕ будущие ЗРВ КА. Список — из проекции pcp-tgu-service ([fetchSpacecraftPlans](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts), `GET /api/plans/{spacecraftId}`), отфильтрованной до PLANNED строго в будущем, кроме базы ([editorForkForward.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts) `futureZrvWindows` + 5 тестов). Это та же проекция, с которой бэк сверяет окно (`assertWindowMatchesProjection`).
|
|
||||||
> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): выбор окна переводит обзор редактора на него; `ensureDraft` шлёт `toCreateDraftRequest(targetWindow)` — fork-forward (naive-окно + kpp) при выбранной ЗРВ, иначе replace. Селектор блокируется после создания DRAFT (режим зафиксирован). Окна — naive-UTC (без зоны), как ждёт `LocalDateTime` бэка.
|
|
||||||
> - Приёмка: `tsc -b`, `vitest` (46) и `vite build` — зелёные.
|
|
||||||
>
|
|
||||||
> **Вынесено в отдельные подзадачи (не входит в ядро):**
|
|
||||||
> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote).
|
|
||||||
> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора.
|
|
||||||
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `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 |
|
|
||||||
@@ -109,6 +109,27 @@ export async function fetchZrvWindows(spacecraftId: string): Promise<ZrvWindowRe
|
|||||||
return text.trim() ? (JSON.parse(text) as ZrvWindowResponse[]) : [];
|
return text.trim() ? (JSON.parse(text) as ZrvWindowResponse[]) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Черновики (DRAFT) из pcp-tgu-service (GET /api/plans/drafts), опц. по одному КА. Основной
|
||||||
|
* read-path планов их отсекает, поэтому режим «Черновики» во вкладке «Планы» читает их отдельно.
|
||||||
|
*/
|
||||||
|
export async function fetchDraftPlans(spacecraftId?: string): Promise<TguPlanResponse[]> {
|
||||||
|
const url = spacecraftId
|
||||||
|
? `${BASE}/api/plans/drafts?spacecraftId=${encodeURIComponent(spacecraftId)}`
|
||||||
|
: `${BASE}/api/plans/drafts`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network");
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TguApiError(extractErrorText(text) || "Не удалось получить черновики.", "http", response.status);
|
||||||
|
}
|
||||||
|
return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат
|
* Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат
|
||||||
* (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки
|
* (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
|||||||
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
|
import { buildStationMarkers } from "../tgu-map-2d/Tgu2DMapLayers";
|
||||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
||||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||||
import type { MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
import type { GeoPoint, MapLine, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||||
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
import { fetchPlanModes, type PlanMode, type PlanSurveyMode } from "./editorApi";
|
||||||
import { validationToConflicts } from "./editorValidation";
|
import { validationToConflicts } from "./editorValidation";
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +124,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const [passFrom, setPassFrom] = useState(1);
|
const [passFrom, setPassFrom] = useState(1);
|
||||||
const [passTo, setPassTo] = useState(999);
|
const [passTo, setPassTo] = useState(999);
|
||||||
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
||||||
|
// Команда карте центрироваться/подсветить объект выбранного включения (клик по включению).
|
||||||
|
const [mapFocus, setMapFocus] = useState<{ point?: GeoPoint; objectId?: string; token: number }>({ token: 0 });
|
||||||
const draft = history.draft;
|
const draft = history.draft;
|
||||||
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
|
const stations = useMemo(() => buildStations(selectedPlan), [selectedPlan]);
|
||||||
const mapRange = useMemo<PassRange>(() => {
|
const mapRange = useMemo<PassRange>(() => {
|
||||||
@@ -192,6 +194,15 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
|
() => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute),
|
||||||
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
|
[selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute]
|
||||||
);
|
);
|
||||||
|
// Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем.
|
||||||
|
const focusWorkOnMap = useCallback(
|
||||||
|
(workId: string) => {
|
||||||
|
const work = displayWorks.find((w) => w.id === workId);
|
||||||
|
const focus = workMapFocus(work, mapScene);
|
||||||
|
setMapFocus((prev) => ({ point: focus.point, objectId: focus.objectId, token: prev.token + 1 }));
|
||||||
|
},
|
||||||
|
[displayWorks, mapScene]
|
||||||
|
);
|
||||||
const contextLanes = useMemo(
|
const contextLanes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
|
Array.from(contextSpacecraftIds).map((spacecraftId) => ({
|
||||||
@@ -264,8 +275,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void fetchRequestsForMap({
|
void fetchRequestsForMap({
|
||||||
endFrom: new Date(window.fromMs).toISOString(),
|
endFrom: new Date(window.fromMs).toISOString().slice(0, 19),
|
||||||
beginTo: new Date(window.toMs).toISOString()
|
beginTo: new Date(window.toMs).toISOString().slice(0, 19)
|
||||||
}).then((result) => {
|
}).then((result) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setRequestItems(result.items);
|
setRequestItems(result.items);
|
||||||
@@ -411,9 +422,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
};
|
};
|
||||||
}, [rightMode, noradId, window.fromMs, window.toMs]);
|
}, [rightMode, noradId, window.fromMs, window.toMs]);
|
||||||
|
|
||||||
// Смена выбранного плана сбрасывает открытый черновик: правки относятся к конкретному плану.
|
// Если выбран уже существующий черновик (status DRAFT — из режима «Черновики»/диалога цепочки),
|
||||||
|
// редактор работает с ним напрямую; иначе черновик создаётся лениво на первую правку (replace).
|
||||||
|
// createdImplicitlyRef различает эти случаи: авто-удалять при уходе можно только неявно созданный.
|
||||||
|
const createdImplicitlyRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraftPlanId(undefined);
|
setDraftPlanId(selectedPlan?.status === "DRAFT" ? selectedPlan.planId : undefined);
|
||||||
|
createdImplicitlyRef.current = false;
|
||||||
setServiceConflicts({});
|
setServiceConflicts({});
|
||||||
}, [selectedPlan]);
|
}, [selectedPlan]);
|
||||||
|
|
||||||
@@ -423,7 +438,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
|
useEffect(() => { draftPlanIdRef.current = draftPlanId; }, [draftPlanId]);
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
const stale = draftPlanIdRef.current;
|
const stale = draftPlanIdRef.current;
|
||||||
if (stale) {
|
// Авто-отмена только для неявно созданного черновика; явно открытый DRAFT не трогаем.
|
||||||
|
if (stale && createdImplicitlyRef.current) {
|
||||||
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
|
// best-effort: применённый/уже отменённый черновик сюда не попадёт (ref обнулён).
|
||||||
void abandonDraft(stale).catch(() => {});
|
void abandonDraft(stale).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -459,7 +475,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
const ensureDraft = useCallback(async (): Promise<string> => {
|
const ensureDraft = useCallback(async (): Promise<string> => {
|
||||||
if (draftPlanId) return draftPlanId;
|
if (draftPlanId) return draftPlanId;
|
||||||
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
||||||
|
// Выбран уже существующий черновик — правим его напрямую, новый клон не создаём.
|
||||||
|
if (selectedPlan.status === "DRAFT") {
|
||||||
|
setDraftPlanId(selectedPlan.planId);
|
||||||
|
return selectedPlan.planId;
|
||||||
|
}
|
||||||
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
||||||
|
createdImplicitlyRef.current = true;
|
||||||
setDraftPlanId(created.draftPlanId);
|
setDraftPlanId(created.draftPlanId);
|
||||||
return created.draftPlanId;
|
return created.draftPlanId;
|
||||||
}, [draftPlanId, selectedPlan]);
|
}, [draftPlanId, selectedPlan]);
|
||||||
@@ -863,6 +885,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
}}
|
}}
|
||||||
onObjectSelect={handleMapObjectSelect}
|
onObjectSelect={handleMapObjectSelect}
|
||||||
|
focusPoint={mapFocus.point}
|
||||||
|
focusToken={mapFocus.token}
|
||||||
|
selectedObjectId={mapFocus.objectId}
|
||||||
/>
|
/>
|
||||||
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
|
<div className="tgu-editor-map-resizer" onMouseDown={handleResizerDown} />
|
||||||
</div>
|
</div>
|
||||||
@@ -880,11 +905,13 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setSelectedWorkId(workId);
|
setSelectedWorkId(workId);
|
||||||
setRightMode("inspect");
|
setRightMode("inspect");
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
|
focusWorkOnMap(workId);
|
||||||
}}
|
}}
|
||||||
onClearSelection={() => {
|
onClearSelection={() => {
|
||||||
if (rightMode === "inspect") {
|
if (rightMode === "inspect") {
|
||||||
setSelectedWorkId(undefined);
|
setSelectedWorkId(undefined);
|
||||||
setRightMode("none");
|
setRightMode("none");
|
||||||
|
setMapFocus((prev) => ({ ...prev, objectId: undefined }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1168,11 +1195,34 @@ function buildEditorMapScene(
|
|||||||
layer: "planWorks",
|
layer: "planWorks",
|
||||||
points: ring,
|
points: ring,
|
||||||
status: "PLANNED",
|
status: "PLANNED",
|
||||||
|
modeId: mode.id,
|
||||||
zIndex: zIndex++
|
zIndex: zIndex++
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса
|
||||||
|
// [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс.
|
||||||
|
for (const mode of planModes) {
|
||||||
|
if (mode.type !== "DROP" || mode.timeStart == null) continue;
|
||||||
|
const startMs = localDateTimeMs(mode.timeStart);
|
||||||
|
const endMs = startMs + mode.duration * 1000;
|
||||||
|
const pts = flightLineData
|
||||||
|
.map((p) => ({ t: parseUtc(p.time), lat: p.lat, lon: p.long }))
|
||||||
|
.filter((p) => p.t >= startMs && p.t <= endMs)
|
||||||
|
.sort((a, b) => a.t - b.t)
|
||||||
|
.map((p) => ({ lat: p.lat, lon: p.lon }));
|
||||||
|
if (pts.length < 2) continue;
|
||||||
|
lines.push({
|
||||||
|
id: `drop:${mode.id}`,
|
||||||
|
layer: "tracks",
|
||||||
|
kind: "drop",
|
||||||
|
modeId: mode.id,
|
||||||
|
points: pts,
|
||||||
|
zIndex: zIndex++
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of requestItems) {
|
for (const item of requestItems) {
|
||||||
const rings = parseWktPolygon(item.geometry);
|
const rings = parseWktPolygon(item.geometry);
|
||||||
for (const ring of rings) {
|
for (const ring of rings) {
|
||||||
@@ -1226,3 +1276,43 @@ function nextWorkId(ref: MutableRefObject<number>): string {
|
|||||||
ref.current += 1;
|
ref.current += 1;
|
||||||
return `NW-${String(ref.current).padStart(4, "0")}`;
|
return `NW-${String(ref.current).padStart(4, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Объект карты, соответствующий включению, и точка для центрирования:
|
||||||
|
* - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура;
|
||||||
|
* - сброс → яркая линия сброса (drop) по modeId; точка — середина линии.
|
||||||
|
*/
|
||||||
|
function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } {
|
||||||
|
if (!work) return {};
|
||||||
|
if (work.kind === "shoot") {
|
||||||
|
const polygon =
|
||||||
|
work.planModeId != null
|
||||||
|
? scene.polygons.find((p) => p.modeId === work.planModeId)
|
||||||
|
: scene.polygons.find((p) => p.id.startsWith("surveyRoute:"));
|
||||||
|
const point: GeoPoint | undefined =
|
||||||
|
work.centerLat != null && work.centerLon != null
|
||||||
|
? { lat: work.centerLat, lon: work.centerLon }
|
||||||
|
: polygon
|
||||||
|
? polygonCentroid(polygon.points)
|
||||||
|
: undefined;
|
||||||
|
return { point, objectId: polygon?.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const line =
|
||||||
|
work.planModeId != null
|
||||||
|
? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId)
|
||||||
|
: undefined;
|
||||||
|
const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined;
|
||||||
|
return { point, objectId: line?.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined {
|
||||||
|
if (points.length === 0) return undefined;
|
||||||
|
let lat = 0;
|
||||||
|
let lon = 0;
|
||||||
|
for (const p of points) {
|
||||||
|
lat += p.lat;
|
||||||
|
lon += p.lon;
|
||||||
|
}
|
||||||
|
return { lat: lat / points.length, lon: lon / points.length };
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ type Tgu2DMapViewProps = {
|
|||||||
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
|
onRequestsAtPoint?: (polygons: MapPolygon[]) => void;
|
||||||
hoveredRequestPolygonId?: string;
|
hoveredRequestPolygonId?: string;
|
||||||
selectedRequestPolygonId?: string;
|
selectedRequestPolygonId?: string;
|
||||||
|
/** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */
|
||||||
|
selectedObjectId?: string;
|
||||||
|
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
|
||||||
|
focusPoint?: GeoPoint;
|
||||||
|
focusToken?: number;
|
||||||
pickMode?: boolean;
|
pickMode?: boolean;
|
||||||
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
onPickPoint?: (point: { lat: number; lon: number }) => void;
|
||||||
targets?: Tgu2DMapTarget[];
|
targets?: Tgu2DMapTarget[];
|
||||||
@@ -68,6 +73,9 @@ export function Tgu2DMapView({
|
|||||||
onRequestsAtPoint,
|
onRequestsAtPoint,
|
||||||
hoveredRequestPolygonId,
|
hoveredRequestPolygonId,
|
||||||
selectedRequestPolygonId,
|
selectedRequestPolygonId,
|
||||||
|
selectedObjectId,
|
||||||
|
focusPoint,
|
||||||
|
focusToken,
|
||||||
pickMode = false,
|
pickMode = false,
|
||||||
onPickPoint,
|
onPickPoint,
|
||||||
targets = [],
|
targets = [],
|
||||||
@@ -139,6 +147,19 @@ export function Tgu2DMapView({
|
|||||||
return () => resizeObserver.disconnect();
|
return () => resizeObserver.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту
|
||||||
|
// на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken,
|
||||||
|
// поэтому повторный клик по тому же включению снова возвращает карту к объекту.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusPoint || focusToken === undefined) return;
|
||||||
|
setView((current) => ({
|
||||||
|
centerLon: focusPoint.lon,
|
||||||
|
centerLat: Math.max(-82, Math.min(82, focusPoint.lat)),
|
||||||
|
zoom: Math.max(current.zoom, 4.5)
|
||||||
|
}));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [focusToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@@ -344,9 +365,14 @@ export function Tgu2DMapView({
|
|||||||
|
|
||||||
const selectedPolygon = selectedPolygonRef.current;
|
const selectedPolygon = selectedPolygonRef.current;
|
||||||
const selectedMarker = selectedMarkerRef.current;
|
const selectedMarker = selectedMarkerRef.current;
|
||||||
const externalSelectedPolygon = useMemo(
|
const externalSelectedPolygon = useMemo(() => {
|
||||||
() => selectedRequestPolygonId ? activePolygons.find((p) => p.id === selectedRequestPolygonId) : undefined,
|
const id = selectedRequestPolygonId ?? selectedObjectId;
|
||||||
[selectedRequestPolygonId, activePolygons]
|
return id ? activePolygons.find((p) => p.id === id) : undefined;
|
||||||
|
}, [selectedRequestPolygonId, selectedObjectId, activePolygons]);
|
||||||
|
// Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons.
|
||||||
|
const externalSelectedLine = useMemo(
|
||||||
|
() => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined),
|
||||||
|
[selectedObjectId, scene.lines]
|
||||||
);
|
);
|
||||||
const externalHoveredPolygon = useMemo(
|
const externalHoveredPolygon = useMemo(
|
||||||
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
|
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
|
||||||
@@ -396,6 +422,24 @@ export function Tgu2DMapView({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
})}
|
})}
|
||||||
|
{externalSelectedLine && externalSelectedLine.points.length >= 2 && (() => {
|
||||||
|
const pts = unwrapX(externalSelectedLine.points.map((point) => projection.project(point)), projection.worldSize);
|
||||||
|
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
|
||||||
|
const visible = pts.some((p) => {
|
||||||
|
const x = p.x + dx;
|
||||||
|
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
|
||||||
|
});
|
||||||
|
if (!visible) return [];
|
||||||
|
return (
|
||||||
|
<polyline
|
||||||
|
key={`line:${externalSelectedLine.id}:${dx}`}
|
||||||
|
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
|
||||||
|
className="is-selected"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
{overlayMarkers.map((marker) => {
|
{overlayMarkers.map((marker) => {
|
||||||
const pt = projection.project(marker.point);
|
const pt = projection.project(marker.point);
|
||||||
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
|
const cls = marker.id === selectedMarker?.id ? "is-selected" : "is-hovered";
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import type { MapProjection } from "../geometry/mapProjection";
|
import type { MapProjection } from "../geometry/mapProjection";
|
||||||
import type { MapLine } from "../model/mapTypes";
|
import type { MapLine } from "../model/mapTypes";
|
||||||
|
|
||||||
|
const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 };
|
||||||
|
// Сброс — яркая толстая линия поверх трассы, на отрезке от начала до конца сеанса.
|
||||||
|
const DROP_STYLE = { stroke: "#ffcf3f", width: 5 };
|
||||||
|
|
||||||
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
|
export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.lineJoin = "round";
|
ctx.lineJoin = "round";
|
||||||
ctx.strokeStyle = "rgba(104, 195, 189, 0.76)";
|
|
||||||
ctx.lineWidth = 1.2;
|
|
||||||
|
|
||||||
const wrapThreshold = projection.worldSize / 2;
|
const wrapThreshold = projection.worldSize / 2;
|
||||||
for (const line of lines) {
|
// Сбросы рисуем после трасс, чтобы их яркая линия легла поверх.
|
||||||
|
const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop"));
|
||||||
|
for (const line of ordered) {
|
||||||
if (line.points.length < 2) continue;
|
if (line.points.length < 2) continue;
|
||||||
|
const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE;
|
||||||
|
ctx.strokeStyle = style.stroke;
|
||||||
|
ctx.lineWidth = style.width;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
let prevX: number | undefined;
|
let prevX: number | undefined;
|
||||||
for (const point of line.points) {
|
for (const point of line.points) {
|
||||||
@@ -27,4 +34,3 @@ export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], proj
|
|||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export type MapPolygon = {
|
|||||||
spacecraftId?: string;
|
spacecraftId?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
surveyType?: string;
|
surveyType?: string;
|
||||||
|
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
|
||||||
|
modeId?: number;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ export type MapLine = {
|
|||||||
spacecraftId?: string;
|
spacecraftId?: string;
|
||||||
passIndex?: number;
|
passIndex?: number;
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
|
/** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */
|
||||||
|
kind?: "track" | "drop";
|
||||||
|
/** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */
|
||||||
|
modeId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MapMarker = {
|
export type MapMarker = {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type CreateChainPlanDialogProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */
|
/** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */
|
||||||
onSent: () => void;
|
onSent: () => void;
|
||||||
|
/** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */
|
||||||
|
onCreated?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,7 +27,7 @@ type CreateChainPlanDialogProps = {
|
|||||||
* показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки
|
* показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки
|
||||||
* («Отправить вручную» = promote) и последующей приёмки.
|
* («Отправить вручную» = promote) и последующей приёмки.
|
||||||
*/
|
*/
|
||||||
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) {
|
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent, onCreated }: CreateChainPlanDialogProps) {
|
||||||
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
|
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
|
||||||
const [zrvLoading, setZrvLoading] = useState(true);
|
const [zrvLoading, setZrvLoading] = useState(true);
|
||||||
const [zrvError, setZrvError] = useState<string>();
|
const [zrvError, setZrvError] = useState<string>();
|
||||||
@@ -78,6 +80,7 @@ export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }:
|
|||||||
try {
|
try {
|
||||||
const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end);
|
const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end);
|
||||||
setResult(await createChainPlan(request));
|
setResult(await createChainPlan(request));
|
||||||
|
onCreated?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план."
|
err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план."
|
||||||
|
|||||||
@@ -13,14 +13,33 @@ const DECISION_GRACE_MINUTES = 30;
|
|||||||
|
|
||||||
type TguPlanDetailsProps = {
|
type TguPlanDetailsProps = {
|
||||||
plan?: TguPlanUi;
|
plan?: TguPlanUi;
|
||||||
|
/** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */
|
||||||
|
chainPaused?: boolean;
|
||||||
decisionInFlight: boolean;
|
decisionInFlight: boolean;
|
||||||
notice?: string;
|
notice?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
|
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
|
||||||
|
/** Действия над черновиком (показываются только для status === "DRAFT"). */
|
||||||
|
draftActionInFlight?: boolean;
|
||||||
|
onEditDraft?: (planId: string) => void;
|
||||||
|
onPromoteDraft?: (planId: string) => void;
|
||||||
|
onDeleteDraft?: (planId: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
|
export function TguPlanDetails({
|
||||||
|
plan,
|
||||||
|
chainPaused,
|
||||||
|
decisionInFlight,
|
||||||
|
notice,
|
||||||
|
error,
|
||||||
|
onDecision,
|
||||||
|
draftActionInFlight,
|
||||||
|
onEditDraft,
|
||||||
|
onPromoteDraft,
|
||||||
|
onDeleteDraft,
|
||||||
|
onClose
|
||||||
|
}: TguPlanDetailsProps) {
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
return (
|
return (
|
||||||
<aside className="tgu-details tgu-details--empty">
|
<aside className="tgu-details tgu-details--empty">
|
||||||
@@ -60,6 +79,13 @@ export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecisi
|
|||||||
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
|
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
|
||||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
|
|
||||||
|
{chainPaused && (
|
||||||
|
<div className="tgu-alert tgu-alert--warning">
|
||||||
|
⏸ Цепочка на паузе: план отклонён — создайте исправленный план вручную («Создать план»).
|
||||||
|
Автоматической перевыдачи отклонённого окна не будет.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<dl className="tgu-details-grid">
|
<dl className="tgu-details-grid">
|
||||||
<Detail label="planId" value={plan.planId} mono />
|
<Detail label="planId" value={plan.planId} mono />
|
||||||
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
|
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
|
||||||
@@ -71,6 +97,35 @@ export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecisi
|
|||||||
<Detail label="status" value={plan.status} mono />
|
<Detail label="status" value={plan.status} mono />
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{plan.status === "DRAFT" && (
|
||||||
|
<div className="tgu-details__actions tgu-details__actions--draft">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--primary"
|
||||||
|
type="button"
|
||||||
|
disabled={draftActionInFlight}
|
||||||
|
onClick={() => onEditDraft?.(plan.planId)}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--accept"
|
||||||
|
type="button"
|
||||||
|
disabled={draftActionInFlight}
|
||||||
|
onClick={() => onPromoteDraft?.(plan.planId)}
|
||||||
|
>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--reject"
|
||||||
|
type="button"
|
||||||
|
disabled={draftActionInFlight}
|
||||||
|
onClick={() => onDeleteDraft?.(plan.planId)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{plan.status === "WAITING_DECISION" &&
|
{plan.status === "WAITING_DECISION" &&
|
||||||
(decisionWindowClosed ? (
|
(decisionWindowClosed ? (
|
||||||
<div className="tgu-alert tgu-alert--warning">
|
<div className="tgu-alert tgu-alert--warning">
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
||||||
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
||||||
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||||
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||||||
import { fetchZrvWindows, type ZrvWindowResponse } from "../../api/tguPlanApi";
|
import {
|
||||||
|
abandonDraft,
|
||||||
|
fetchDraftPlans,
|
||||||
|
fetchZrvWindows,
|
||||||
|
promoteDraft,
|
||||||
|
type ZrvWindowResponse
|
||||||
|
} from "../../api/tguPlanApi";
|
||||||
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
|
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
|
||||||
import { TguPlanDetails } from "./TguPlanDetails";
|
import { TguPlanDetails } from "./TguPlanDetails";
|
||||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||||
import { TguSidebar } from "./TguSidebar";
|
import { TguSidebar } from "./TguSidebar";
|
||||||
import { TguTimeline } from "./TguTimeline";
|
import { TguTimeline, type ZrvWindow } from "./TguTimeline";
|
||||||
import { TguToolbar } from "./TguToolbar";
|
import { TguToolbar } from "./TguToolbar";
|
||||||
|
import { TguZrvPanel } from "./TguZrvPanel";
|
||||||
import {
|
import {
|
||||||
buildPlatformsForInterval,
|
buildPlatformsForInterval,
|
||||||
buildTimelineRows,
|
buildTimelineRows,
|
||||||
isOperationalPlatform,
|
isOperationalPlatform,
|
||||||
mapPlans,
|
mapPlans,
|
||||||
operationalSpacecraftIds
|
operationalSpacecraftIds,
|
||||||
|
pausedSpacecraftIds
|
||||||
} from "./tguTimelineMapper";
|
} from "./tguTimelineMapper";
|
||||||
import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
|
import { parseUtc, toUtcInputValue } from "../../utils/utcTime";
|
||||||
|
|
||||||
@@ -39,8 +47,16 @@ export function TguPlanningPage() {
|
|||||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||||
const [createChainOpen, setCreateChainOpen] = useState(false);
|
const [createChainOpen, setCreateChainOpen] = useState(false);
|
||||||
|
// Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно.
|
||||||
|
const [drafts, setDrafts] = useState<TguPlanUi[]>([]);
|
||||||
|
const [draftActionInFlight, setDraftActionInFlight] = useState(false);
|
||||||
|
// Тумблеры видимости «непринятого будущего» на схеме.
|
||||||
|
const [showDrafts, setShowDrafts] = useState(false);
|
||||||
|
const [showProjection, setShowProjection] = useState(true);
|
||||||
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
|
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
|
||||||
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
|
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
|
||||||
|
// Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа).
|
||||||
|
const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>();
|
||||||
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
||||||
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
||||||
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
||||||
@@ -69,15 +85,17 @@ export function TguPlanningPage() {
|
|||||||
setDecisionError(undefined);
|
setDecisionError(undefined);
|
||||||
|
|
||||||
const historyDays = historyDaysForRange(range);
|
const historyDays = historyDaysForRange(range);
|
||||||
const [platformResult, planResult] = await Promise.allSettled([
|
const [platformResult, planResult, draftResult] = await Promise.allSettled([
|
||||||
fetchPlatforms(),
|
fetchPlatforms(),
|
||||||
fetchPlans(historyDays)
|
fetchPlans(historyDays),
|
||||||
|
fetchDraftPlans()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу
|
// На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу
|
||||||
// на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам.
|
// на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам.
|
||||||
const operationalPlatforms =
|
const operationalPlatforms =
|
||||||
platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : [];
|
platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : [];
|
||||||
|
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
|
||||||
|
|
||||||
if (platformResult.status === "rejected") {
|
if (platformResult.status === "rejected") {
|
||||||
setPlatformLoadFailed(true);
|
setPlatformLoadFailed(true);
|
||||||
@@ -90,10 +108,19 @@ export function TguPlanningPage() {
|
|||||||
setPlans([]);
|
setPlans([]);
|
||||||
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
|
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
|
||||||
} else {
|
} else {
|
||||||
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
|
|
||||||
setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId)));
|
setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Черновики читаем из pcp-tgu-service отдельно; сбой непрозрачен — просто нет черновиков.
|
||||||
|
if (draftResult.status === "fulfilled") {
|
||||||
|
setDrafts(
|
||||||
|
mapPlans(draftResult.value as unknown as TguPlan[], operationalPlatforms)
|
||||||
|
.filter((plan) => operationalIds.has(plan.spacecraftId))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setDrafts([]);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@@ -130,29 +157,56 @@ export function TguPlanningPage() {
|
|||||||
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
|
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
|
||||||
return {
|
return {
|
||||||
spacecraftId: selectedSpacecraftId,
|
spacecraftId: selectedSpacecraftId,
|
||||||
windows: zrvWindows.map((zrv) => ({ startMs: parseUtc(zrv.startTime), endMs: parseUtc(zrv.endTime) }))
|
windows: zrvWindows.map((zrv) => ({
|
||||||
|
startMs: parseUtc(zrv.startTime),
|
||||||
|
endMs: parseUtc(zrv.endTime),
|
||||||
|
kppId: zrv.kppId
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}, [selectedSpacecraftId, zrvWindows]);
|
}, [selectedSpacecraftId, zrvWindows]);
|
||||||
|
|
||||||
|
// Сбрасываем выбранную ЗРВ при смене КА (зоны перечитываются) — панель не должна показывать чужие.
|
||||||
|
useEffect(() => {
|
||||||
|
setZrvSelection(undefined);
|
||||||
|
}, [selectedSpacecraftId]);
|
||||||
|
|
||||||
|
const selectedZrvId = useMemo(() => {
|
||||||
|
if (!zrvSelection) return undefined;
|
||||||
|
const zrv = zrvSelection.windows[zrvSelection.index];
|
||||||
|
return zrv ? `${zrv.kppId}|${zrv.startMs}` : undefined;
|
||||||
|
}, [zrvSelection]);
|
||||||
|
|
||||||
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
||||||
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
||||||
const tabStyle = (tab: TguActiveTab) =>
|
const tabStyle = (tab: TguActiveTab) =>
|
||||||
({ display: activeTab === tab ? "contents" : "none" }) as const;
|
({ display: activeTab === tab ? "contents" : "none" }) as const;
|
||||||
|
|
||||||
|
// Все планы для выбора/поиска (включая черновики), независимо от тумблеров видимости.
|
||||||
|
const allPlans = useMemo(() => [...plans, ...drafts], [plans, drafts]);
|
||||||
|
// Планы для отрисовки на таймлайне с учётом тумблеров: проекция PLANNED и черновики.
|
||||||
|
const visiblePlans = useMemo(() => {
|
||||||
|
const base = showProjection ? plans : plans.filter((plan) => plan.status !== "PLANNED");
|
||||||
|
return showDrafts ? [...base, ...drafts] : base;
|
||||||
|
}, [plans, drafts, showProjection, showDrafts]);
|
||||||
|
|
||||||
|
// КА с цепочкой на паузе после REJECTED (см. pausedSpacecraftIds): считаем по persisted-планам,
|
||||||
|
// черновики/проекция паузу не снимают. Совпадает с серверной паузой воркера выдачи.
|
||||||
|
const paused = useMemo(() => pausedSpacecraftIds(plans), [plans]);
|
||||||
|
|
||||||
const platforms = useMemo(
|
const platforms = useMemo(
|
||||||
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
|
() => buildPlatformsForInterval(rawPlatforms, allPlans, appliedRange),
|
||||||
[rawPlatforms, plans, appliedRange]
|
[rawPlatforms, allPlans, appliedRange]
|
||||||
);
|
);
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
|
() => buildTimelineRows(visiblePlans, platforms, appliedRange, selectedSpacecraftId),
|
||||||
[plans, platforms, appliedRange, selectedSpacecraftId]
|
[visiblePlans, platforms, appliedRange, selectedSpacecraftId]
|
||||||
);
|
);
|
||||||
const selectedPlan = useMemo(() => {
|
const selectedPlan = useMemo(() => {
|
||||||
const plan = plans.find((item) => item.planId === selectedPlanId);
|
const plan = allPlans.find((item) => item.planId === selectedPlanId);
|
||||||
if (!plan) return undefined;
|
if (!plan) return undefined;
|
||||||
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
|
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
|
||||||
return { ...plan, platform };
|
return { ...plan, platform };
|
||||||
}, [plans, platforms, selectedPlanId]);
|
}, [allPlans, platforms, selectedPlanId]);
|
||||||
|
|
||||||
const applyRange = () => {
|
const applyRange = () => {
|
||||||
setDecisionNotice(undefined);
|
setDecisionNotice(undefined);
|
||||||
@@ -194,6 +248,48 @@ export function TguPlanningPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Открыть черновик в редакторе: выбираем его план/КА и переключаемся на вкладку «Редактор».
|
||||||
|
const handleEditDraft = (planId: string) => {
|
||||||
|
const draft = drafts.find((item) => item.planId === planId);
|
||||||
|
setSelectedPlanId(planId);
|
||||||
|
if (draft) setSelectedSpacecraftId(draft.spacecraftId);
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
setActiveTab("editor");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправить черновик вручную (promote): уходит в выдачу, рабочим станет после приёмки.
|
||||||
|
const handlePromoteDraft = async (planId: string) => {
|
||||||
|
setDraftActionInFlight(true);
|
||||||
|
setDecisionError(undefined);
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
try {
|
||||||
|
await promoteDraft(planId);
|
||||||
|
setDecisionNotice("Черновик отправлен на выдачу. Рабочим (якорем цепочки) он станет после приёмки.");
|
||||||
|
await loadData(appliedRange);
|
||||||
|
} catch (error) {
|
||||||
|
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить черновик.");
|
||||||
|
} finally {
|
||||||
|
setDraftActionInFlight(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удалить черновик (abandon): убирает DRAFT и его клон-миссию.
|
||||||
|
const handleDeleteDraft = async (planId: string) => {
|
||||||
|
setDraftActionInFlight(true);
|
||||||
|
setDecisionError(undefined);
|
||||||
|
setDecisionNotice(undefined);
|
||||||
|
try {
|
||||||
|
await abandonDraft(planId);
|
||||||
|
setSelectedPlanId(undefined);
|
||||||
|
setDecisionNotice("Черновик удалён.");
|
||||||
|
await loadData(appliedRange);
|
||||||
|
} catch (error) {
|
||||||
|
setDecisionError(error instanceof Error ? error.message : "Не удалось удалить черновик.");
|
||||||
|
} finally {
|
||||||
|
setDraftActionInFlight(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TguPlanningLayout
|
<TguPlanningLayout
|
||||||
toolbar={
|
toolbar={
|
||||||
@@ -227,6 +323,14 @@ export function TguPlanningPage() {
|
|||||||
/>
|
/>
|
||||||
<section className="tgu-center">
|
<section className="tgu-center">
|
||||||
<div className="tgu-center__actions">
|
<div className="tgu-center__actions">
|
||||||
|
<label className="tgu-center__toggle">
|
||||||
|
<input type="checkbox" checked={showDrafts} onChange={(e) => setShowDrafts(e.target.checked)} />
|
||||||
|
Черновики
|
||||||
|
</label>
|
||||||
|
<label className="tgu-center__toggle">
|
||||||
|
<input type="checkbox" checked={showProjection} onChange={(e) => setShowProjection(e.target.checked)} />
|
||||||
|
Возможные будущие планы
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
className="tgu-button tgu-button--primary"
|
className="tgu-button tgu-button--primary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -242,24 +346,45 @@ export function TguPlanningPage() {
|
|||||||
range={appliedRange}
|
range={appliedRange}
|
||||||
invalidRange={invalidRange}
|
invalidRange={invalidRange}
|
||||||
zrvBands={zrvBands}
|
zrvBands={zrvBands}
|
||||||
|
selectedZrvId={selectedZrvId}
|
||||||
|
pausedSpacecraftIds={paused}
|
||||||
selectedPlanId={selectedPlanId}
|
selectedPlanId={selectedPlanId}
|
||||||
onSelectPlan={(planId) => {
|
onSelectPlan={(planId) => {
|
||||||
|
setZrvSelection(undefined);
|
||||||
setSelectedPlanId(planId);
|
setSelectedPlanId(planId);
|
||||||
const plan = plans.find((item) => item.planId === planId);
|
const plan = allPlans.find((item) => item.planId === planId);
|
||||||
if (plan) {
|
if (plan) {
|
||||||
setSelectedSpacecraftId(plan.spacecraftId);
|
setSelectedSpacecraftId(plan.spacecraftId);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onSelectZrv={(windows, index) => {
|
||||||
|
setSelectedPlanId(undefined);
|
||||||
|
setZrvSelection({ windows, index });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<TguPlanDetails
|
{zrvSelection ? (
|
||||||
plan={selectedPlan}
|
<TguZrvPanel
|
||||||
decisionInFlight={decisionInFlight}
|
windows={zrvSelection.windows}
|
||||||
notice={decisionNotice}
|
selectedIndex={zrvSelection.index}
|
||||||
error={decisionError}
|
onSelect={(index) => setZrvSelection((current) => (current ? { ...current, index } : current))}
|
||||||
onDecision={handleDecision}
|
onClose={() => setZrvSelection(undefined)}
|
||||||
onClose={() => setSelectedPlanId(undefined)}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<TguPlanDetails
|
||||||
|
plan={selectedPlan}
|
||||||
|
chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false}
|
||||||
|
decisionInFlight={decisionInFlight}
|
||||||
|
notice={decisionNotice}
|
||||||
|
error={decisionError}
|
||||||
|
onDecision={handleDecision}
|
||||||
|
draftActionInFlight={draftActionInFlight}
|
||||||
|
onEditDraft={handleEditDraft}
|
||||||
|
onPromoteDraft={handlePromoteDraft}
|
||||||
|
onDeleteDraft={handleDeleteDraft}
|
||||||
|
onClose={() => setSelectedPlanId(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
{createChainOpen && selectedSpacecraftId && (
|
{createChainOpen && selectedSpacecraftId && (
|
||||||
<CreateChainPlanDialog
|
<CreateChainPlanDialog
|
||||||
@@ -267,6 +392,10 @@ export function TguPlanningPage() {
|
|||||||
plans={plans}
|
plans={plans}
|
||||||
onClose={() => setCreateChainOpen(false)}
|
onClose={() => setCreateChainOpen(false)}
|
||||||
onSent={() => void loadData(appliedRange)}
|
onSent={() => void loadData(appliedRange)}
|
||||||
|
onCreated={() => {
|
||||||
|
setShowDrafts(true);
|
||||||
|
void loadData(appliedRange);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,22 @@ import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../mode
|
|||||||
import {
|
import {
|
||||||
buildSequentialLinks,
|
buildSequentialLinks,
|
||||||
buildTimeTicks,
|
buildTimeTicks,
|
||||||
buildTimelineSegments,
|
buildTimelineLayout,
|
||||||
TIMELINE_AXIS_HEIGHT,
|
TIMELINE_AXIS_HEIGHT,
|
||||||
TIMELINE_BOTTOM_PADDING,
|
TIMELINE_BOTTOM_PADDING,
|
||||||
TIMELINE_LABEL_WIDTH,
|
TIMELINE_LABEL_WIDTH,
|
||||||
TIMELINE_ROW_HEIGHT,
|
TIMELINE_ZRV_BAND_HEIGHT,
|
||||||
timeToX,
|
timeToX
|
||||||
timelineHeight
|
|
||||||
} from "./tguTimelineLayout";
|
} from "./tguTimelineLayout";
|
||||||
import { platformLabel } from "./tguTimelineMapper";
|
import { platformLabel } from "./tguTimelineMapper";
|
||||||
import { statusStyle } from "./tguStatus";
|
import { statusStyle } from "./tguStatus";
|
||||||
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
||||||
|
|
||||||
|
export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
|
||||||
|
|
||||||
type ZrvBands = {
|
type ZrvBands = {
|
||||||
spacecraftId: string;
|
spacecraftId: string;
|
||||||
windows: { startMs: number; endMs: number }[];
|
windows: ZrvWindow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TguTimelineProps = {
|
type TguTimelineProps = {
|
||||||
@@ -27,15 +28,34 @@ type TguTimelineProps = {
|
|||||||
invalidRange?: boolean;
|
invalidRange?: boolean;
|
||||||
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
|
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
|
||||||
zrvBands?: ZrvBands;
|
zrvBands?: ZrvBands;
|
||||||
|
/** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */
|
||||||
|
selectedZrvId?: string;
|
||||||
|
/** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */
|
||||||
|
pausedSpacecraftIds?: ReadonlySet<string>;
|
||||||
onSelectPlan: (planId: string) => void;
|
onSelectPlan: (planId: string) => void;
|
||||||
|
/** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */
|
||||||
|
onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, onSelectPlan }: TguTimelineProps) {
|
function zrvId(zrv: ZrvWindow): string {
|
||||||
|
return `${zrv.kppId}|${zrv.startMs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TguTimeline({
|
||||||
|
rows,
|
||||||
|
range,
|
||||||
|
selectedPlanId,
|
||||||
|
invalidRange,
|
||||||
|
zrvBands,
|
||||||
|
selectedZrvId,
|
||||||
|
pausedSpacecraftIds,
|
||||||
|
onSelectPlan,
|
||||||
|
onSelectZrv
|
||||||
|
}: TguTimelineProps) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(1200);
|
const [containerWidth, setContainerWidth] = useState(1200);
|
||||||
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
||||||
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
|
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
|
||||||
const height = Math.max(260, timelineHeight(rows.length));
|
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -50,10 +70,12 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const segments = useMemo(
|
const layout = useMemo(
|
||||||
() => buildTimelineSegments(rows, range, timelineWidth),
|
() => buildTimelineLayout(rows, range, timelineWidth),
|
||||||
[rows, range, timelineWidth]
|
[rows, range, timelineWidth]
|
||||||
);
|
);
|
||||||
|
const segments = layout.segments;
|
||||||
|
const height = Math.max(260, layout.height);
|
||||||
const segmentByPlanId = useMemo(
|
const segmentByPlanId = useMemo(
|
||||||
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
|
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
|
||||||
[segments]
|
[segments]
|
||||||
@@ -114,43 +136,72 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
|||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{rows.map((row, rowIndex) => {
|
{layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => {
|
||||||
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
|
|
||||||
return (
|
return (
|
||||||
<g key={row.spacecraftId}>
|
<g key={row.spacecraftId}>
|
||||||
<rect
|
<rect
|
||||||
x="0"
|
x="0"
|
||||||
y={y}
|
y={y}
|
||||||
width={fullWidth}
|
width={fullWidth}
|
||||||
height={TIMELINE_ROW_HEIGHT}
|
height={rowHeight}
|
||||||
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
|
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
|
||||||
/>
|
/>
|
||||||
<text x="16" y={y + 24} className="tgu-timeline__row-title">
|
<text x="16" y={y + 22} className="tgu-timeline__row-title">
|
||||||
{platformLabel(row.platform, row.spacecraftId)}
|
{platformLabel(row.platform, row.spacecraftId)}
|
||||||
</text>
|
</text>
|
||||||
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
<text x="16" y={y + 38} className="tgu-timeline__row-meta">
|
||||||
NORAD {row.platform?.noradId || row.spacecraftId}
|
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||||
</text>
|
</text>
|
||||||
{zrvBands?.spacecraftId === row.spacecraftId &&
|
{pausedSpacecraftIds?.has(row.spacecraftId) && (
|
||||||
zrvBands.windows.map((zrv, zrvIndex) => {
|
<text
|
||||||
const from = Math.max(zrv.startMs, range.fromMs);
|
x={TIMELINE_LABEL_WIDTH - 12}
|
||||||
const to = Math.min(zrv.endMs, range.toMs);
|
y={y + 22}
|
||||||
if (to <= from) return null;
|
textAnchor="end"
|
||||||
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
className="tgu-timeline__paused-badge"
|
||||||
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
>
|
||||||
return (
|
⏸ на паузе
|
||||||
<rect
|
<title>Цепочка на паузе: план отклонён — создайте исправленный план («Создать план»).</title>
|
||||||
key={`zrv-${zrvIndex}`}
|
</text>
|
||||||
className="tgu-timeline__zrv"
|
)}
|
||||||
x={bx}
|
{zrvBands?.spacecraftId === row.spacecraftId && (
|
||||||
y={y + 6}
|
<>
|
||||||
width={bw}
|
<text x="16" y={zrvBandY + TIMELINE_ZRV_BAND_HEIGHT - 3} className="tgu-timeline__zrv-label">
|
||||||
height={TIMELINE_ROW_HEIGHT - 12}
|
ЗРВ
|
||||||
>
|
</text>
|
||||||
<title>ЗРВ {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
{zrvBands.windows.map((zrv, zrvIndex) => {
|
||||||
</rect>
|
const from = Math.max(zrv.startMs, range.fromMs);
|
||||||
);
|
const to = Math.min(zrv.endMs, range.toMs);
|
||||||
})}
|
if (to <= from) return null;
|
||||||
|
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
||||||
|
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
||||||
|
const id = zrvId(zrv);
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key={`zrv-${zrvIndex}`}
|
||||||
|
className={`tgu-timeline__zrv${selectedZrvId === id ? " is-selected" : ""}`}
|
||||||
|
x={bx}
|
||||||
|
y={zrvBandY}
|
||||||
|
width={bw}
|
||||||
|
height={TIMELINE_ZRV_BAND_HEIGHT}
|
||||||
|
style={onSelectZrv ? { cursor: "pointer" } : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!onSelectZrv) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
// Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени
|
||||||
|
// (разные КПП могут перекрываться), как «заявки в точке» на карте.
|
||||||
|
const overlapping = zrvBands.windows.filter(
|
||||||
|
(w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs
|
||||||
|
);
|
||||||
|
const idx = overlapping.findIndex((w) => zrvId(w) === id);
|
||||||
|
onSelectZrv(overlapping, Math.max(0, idx));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<title>ЗРВ {zrv.kppId} · {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||||
|
</rect>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -163,11 +214,7 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
const y =
|
const y = segment.centerY;
|
||||||
TIMELINE_AXIS_HEIGHT +
|
|
||||||
segment.rowIndex * TIMELINE_ROW_HEIGHT +
|
|
||||||
TIMELINE_ROW_HEIGHT / 2 +
|
|
||||||
segment.laneOffset;
|
|
||||||
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
|
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
|
||||||
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
|
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
|
||||||
const style = statusStyle(segment.plan.status);
|
const style = statusStyle(segment.plan.status);
|
||||||
@@ -208,8 +255,8 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBand
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
|
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
|
||||||
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset;
|
const y1 = from.centerY;
|
||||||
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset;
|
const y2 = to.centerY;
|
||||||
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
|
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
|
||||||
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
|
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
|
||||||
const mid = x1 + Math.max(18, (x2 - x1) / 2);
|
const mid = x1 + Math.max(18, (x2 - x1) / 2);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ZrvWindow } from "./TguTimeline";
|
||||||
|
import { formatUtcDateTime } from "../../utils/utcTime";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
windows: ZrvWindow[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель по выбранной ЗРВ в окне «Планы». Если в точке пересекается несколько зон
|
||||||
|
* (разные КПП), показываем список — по примеру панели заявок на вкладке «Карта».
|
||||||
|
*/
|
||||||
|
export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props) {
|
||||||
|
const selected = windows[selectedIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="tgu-map-requests-panel">
|
||||||
|
<div className="tgu-panel-heading">
|
||||||
|
<span>Зоны радиовидимости ({windows.length})</span>
|
||||||
|
<button className="tgu-link-button" type="button" onClick={onClose}>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tgu-map-requests-list">
|
||||||
|
{windows.map((zrv, index) => (
|
||||||
|
<button
|
||||||
|
key={`${zrv.kppId}|${zrv.startMs}`}
|
||||||
|
className={`tgu-map-request-row ${index === selectedIndex ? "is-selected" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(index)}
|
||||||
|
>
|
||||||
|
<span className="tgu-map-request-row__body">
|
||||||
|
<span className="tgu-map-request-row__name">КПП {zrv.kppId}</span>
|
||||||
|
<span className="tgu-map-request-row__meta">
|
||||||
|
{formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}
|
||||||
|
{" · "}
|
||||||
|
{formatDuration(zrv.endMs - zrv.startMs)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="tgu-map-request-details">
|
||||||
|
<div className="tgu-map-panel__label">Детали ЗРВ</div>
|
||||||
|
<dl className="tgu-map-request-details__grid">
|
||||||
|
<dt>КПП</dt>
|
||||||
|
<dd className="mono">{selected.kppId}</dd>
|
||||||
|
|
||||||
|
<dt>Начало</dt>
|
||||||
|
<dd className="mono">{formatUtcDateTime(selected.startMs)}</dd>
|
||||||
|
|
||||||
|
<dt>Конец</dt>
|
||||||
|
<dd className="mono">{formatUtcDateTime(selected.endMs)}</dd>
|
||||||
|
|
||||||
|
<dt>Длительность</dt>
|
||||||
|
<dd className="mono">{formatDuration(selected.endMs - selected.startMs)}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return minutes > 0 ? `${minutes} мин ${seconds} с` : `${seconds} с`;
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ export const STATUS_STYLES: TimelineStatusStyle[] = [
|
|||||||
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
|
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
|
||||||
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
|
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
|
||||||
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
|
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
|
||||||
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
|
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" },
|
||||||
|
{ status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
|
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
|
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
|
||||||
import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
import { buildSequentialLinks, buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout";
|
||||||
import { parseUtc } from "../../utils/utcTime";
|
import { parseUtc } from "../../utils/utcTime";
|
||||||
|
|
||||||
const range = {
|
const range = {
|
||||||
@@ -57,28 +57,59 @@ describe("tguTimelineLayout", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places sequential plans on alternating timeline lanes", () => {
|
it("keeps non-overlapping plans on a single lane", () => {
|
||||||
const rows: TimelineRow[] = [
|
const rows: TimelineRow[] = [
|
||||||
{
|
{
|
||||||
spacecraftId: "56756",
|
spacecraftId: "56756",
|
||||||
plans: [
|
plans: [
|
||||||
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"),
|
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"),
|
||||||
plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"),
|
plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"),
|
||||||
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00"),
|
plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00")
|
||||||
plan("p4", "2026-05-29T07:00:00", "2026-05-29T08:00:00"),
|
|
||||||
plan("p5", "2026-05-29T09:00:00", "2026-05-29T10:00:00"),
|
|
||||||
plan("p6", "2026-05-29T11:00:00", "2026-05-29T12:00:00")
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(buildTimelineSegments(rows, range, 1200).map((segment) => segment.laneOffset)).toEqual([
|
const lanes = buildTimelineLayout(rows, range, 1200).segments.map((segment) => segment.lane);
|
||||||
0,
|
expect(lanes).toEqual([0, 0, 0]);
|
||||||
-12,
|
});
|
||||||
0,
|
|
||||||
12,
|
it("stacks overlapping plans onto separate lanes", () => {
|
||||||
0,
|
const rows: TimelineRow[] = [
|
||||||
-12
|
{
|
||||||
]);
|
spacecraftId: "56756",
|
||||||
|
plans: [
|
||||||
|
plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"),
|
||||||
|
plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00"),
|
||||||
|
plan("d3", "2026-05-29T02:30:00", "2026-05-29T04:30:00")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const layout = buildTimelineLayout(rows, range, 1200);
|
||||||
|
// Все три пересекаются — каждый на своей дорожке.
|
||||||
|
expect(new Set(layout.segments.map((segment) => segment.lane))).toEqual(new Set([0, 1, 2]));
|
||||||
|
// Разные дорожки — разные вертикальные координаты.
|
||||||
|
expect(new Set(layout.segments.map((segment) => segment.centerY)).size).toBe(3);
|
||||||
|
// Строка с тремя дорожками выше строки с одной.
|
||||||
|
expect(layout.rowLayouts[0].laneCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grows row height with the deepest lane stack", () => {
|
||||||
|
const single: TimelineRow[] = [
|
||||||
|
{ spacecraftId: "56756", plans: [plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")] }
|
||||||
|
];
|
||||||
|
const stacked: TimelineRow[] = [
|
||||||
|
{
|
||||||
|
spacecraftId: "56756",
|
||||||
|
plans: [
|
||||||
|
plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"),
|
||||||
|
plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(buildTimelineLayout(stacked, range, 1200).rowLayouts[0].height).toBeGreaterThan(
|
||||||
|
buildTimelineLayout(single, range, 1200).rowLayouts[0].height
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
|
import type {
|
||||||
|
TguPlanUi,
|
||||||
|
TimelineLink,
|
||||||
|
TimelinePlanSegment,
|
||||||
|
TimelineRange,
|
||||||
|
TimelineRow,
|
||||||
|
TimelineRowLayout
|
||||||
|
} from "../../model/timelineTypes";
|
||||||
|
|
||||||
export const TIMELINE_LABEL_WIDTH = 220;
|
export const TIMELINE_LABEL_WIDTH = 220;
|
||||||
export const TIMELINE_ROW_HEIGHT = 58;
|
|
||||||
export const TIMELINE_AXIS_HEIGHT = 46;
|
export const TIMELINE_AXIS_HEIGHT = 46;
|
||||||
export const TIMELINE_BOTTOM_PADDING = 18;
|
export const TIMELINE_BOTTOM_PADDING = 18;
|
||||||
export const TIMELINE_LANE_STEP = 12;
|
|
||||||
|
/**
|
||||||
|
* Вертикальная раскладка строки КА. Планы, которые не пересекаются по горизонтали, лежат на одной
|
||||||
|
* дорожке (чистая цепочка — прямая линия). Если план пересёкся бы с уже размещённым, он уходит на
|
||||||
|
* следующую дорожку (выше/ниже), чтобы планы не сливались. Высота строки растёт под самый глубокий
|
||||||
|
* стек дорожек, а страйп ЗРВ опускается ниже планов.
|
||||||
|
*/
|
||||||
|
const PLAN_TOP_Y = 28; // центр верхней дорожки (lane 0) относительно верха строки
|
||||||
|
export const TIMELINE_LANE_STEP = 16; // шаг между дорожками пересекающихся планов
|
||||||
|
const ZRV_GAP = 16; // отступ от нижней дорожки планов до страйпа ЗРВ
|
||||||
|
const ZRV_BAND_MIN_Y = 52; // минимум: строка с одной дорожкой выглядит как раньше (высота 74)
|
||||||
|
export const TIMELINE_ZRV_BAND_HEIGHT = 14;
|
||||||
|
const ROW_BOTTOM_PAD = 8;
|
||||||
|
const SEGMENT_GAP_PX = 6; // минимальный горизонтальный зазор, иначе считаем планы пересекающимися
|
||||||
|
|
||||||
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
|
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
|
||||||
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
|
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
|
||||||
@@ -21,38 +40,82 @@ export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startM
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTimelineSegments(
|
type PlacedPlan = { plan: TguPlanUi; x: number; x2: number; width: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Жадная раскладка по дорожкам (interval graph): для каждого плана берём первую дорожку, чья правая
|
||||||
|
* граница уже освободилась (с зазором SEGMENT_GAP_PX). Так пересекающиеся по горизонтали планы
|
||||||
|
* гарантированно оказываются на разных дорожках. Планы должны быть отсортированы по x.
|
||||||
|
*/
|
||||||
|
function packLanes(items: PlacedPlan[]): number[] {
|
||||||
|
const laneRightEdge: number[] = [];
|
||||||
|
return items.map((item) => {
|
||||||
|
let lane = laneRightEdge.findIndex((edge) => item.x >= edge + SEGMENT_GAP_PX);
|
||||||
|
if (lane === -1) {
|
||||||
|
lane = laneRightEdge.length;
|
||||||
|
}
|
||||||
|
laneRightEdge[lane] = item.x + item.width;
|
||||||
|
return lane;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function zrvBandYFor(rowTop: number, laneCount: number): number {
|
||||||
|
return rowTop + Math.max(ZRV_BAND_MIN_Y, PLAN_TOP_Y + (laneCount - 1) * TIMELINE_LANE_STEP + ZRV_GAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimelineLayout = {
|
||||||
|
rowLayouts: TimelineRowLayout[];
|
||||||
|
segments: TimelinePlanSegment[];
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит полную раскладку таймлайна: переменная высота строк (по числу дорожек), абсолютные
|
||||||
|
* y-координаты планов и страйпов ЗРВ. Единственный источник правды по вертикали — компонент только
|
||||||
|
* рисует по готовым координатам.
|
||||||
|
*/
|
||||||
|
export function buildTimelineLayout(
|
||||||
rows: TimelineRow[],
|
rows: TimelineRow[],
|
||||||
range: TimelineRange,
|
range: TimelineRange,
|
||||||
timelineWidth: number
|
timelineWidth: number
|
||||||
): TimelinePlanSegment[] {
|
): TimelineLayout {
|
||||||
|
const rowLayouts: TimelineRowLayout[] = [];
|
||||||
const segments: TimelinePlanSegment[] = [];
|
const segments: TimelinePlanSegment[] = [];
|
||||||
|
let cursorY = TIMELINE_AXIS_HEIGHT;
|
||||||
|
|
||||||
rows.forEach((row, rowIndex) => {
|
rows.forEach((row, rowIndex) => {
|
||||||
const visiblePlans = row.plans
|
const placed: PlacedPlan[] = row.plans
|
||||||
.map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) }))
|
.map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) }))
|
||||||
.filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null)
|
.filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null)
|
||||||
.sort((a, b) => a.plan.startMs - b.plan.startMs || a.plan.endMs - b.plan.endMs);
|
.map(({ plan, clipped }) => {
|
||||||
|
const x = timeToX(clipped.startMs, range, timelineWidth);
|
||||||
|
const x2 = timeToX(clipped.endMs, range, timelineWidth);
|
||||||
|
return { plan, x, x2, width: Math.max(3, x2 - x) };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.x - b.x || a.x2 - b.x2);
|
||||||
|
|
||||||
visiblePlans.forEach((item, planIndex) => {
|
const lanes = packLanes(placed);
|
||||||
const x = timeToX(item.clipped.startMs, range, timelineWidth);
|
const laneCount = lanes.length === 0 ? 1 : Math.max(...lanes) + 1;
|
||||||
const x2 = timeToX(item.clipped.endMs, range, timelineWidth);
|
const zrvBandY = zrvBandYFor(cursorY, laneCount);
|
||||||
|
const height = zrvBandY - cursorY + TIMELINE_ZRV_BAND_HEIGHT + ROW_BOTTOM_PAD;
|
||||||
|
|
||||||
|
placed.forEach((item, planIndex) => {
|
||||||
segments.push({
|
segments.push({
|
||||||
plan: item.plan,
|
plan: item.plan,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
laneOffset: timelineLaneOffset(planIndex),
|
lane: lanes[planIndex],
|
||||||
x,
|
centerY: cursorY + PLAN_TOP_Y + lanes[planIndex] * TIMELINE_LANE_STEP,
|
||||||
x2,
|
x: item.x,
|
||||||
width: Math.max(3, x2 - x)
|
x2: item.x2,
|
||||||
|
width: item.width
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
rowLayouts.push({ row, rowIndex, y: cursorY, height, laneCount, zrvBandY });
|
||||||
|
cursorY += height;
|
||||||
});
|
});
|
||||||
|
|
||||||
return segments;
|
return { rowLayouts, segments, height: cursorY + TIMELINE_BOTTOM_PADDING };
|
||||||
}
|
|
||||||
|
|
||||||
export function timelineLaneOffset(index: number): number {
|
|
||||||
return [0, -1, 0, 1][index % 4] * TIMELINE_LANE_STEP;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
|
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
|
||||||
@@ -95,7 +158,3 @@ export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[]
|
|||||||
|
|
||||||
return ticks;
|
return ticks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function timelineHeight(rowCount: number): number {
|
|
||||||
return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { TguPlatform } from "../../model/tguTypes";
|
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
|
||||||
import { isOperationalPlatform, operationalSpacecraftIds } from "./tguTimelineMapper";
|
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||||
|
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper";
|
||||||
|
|
||||||
function platform(overrides: Partial<TguPlatform>): TguPlatform {
|
function platform(overrides: Partial<TguPlatform>): TguPlatform {
|
||||||
return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides };
|
return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): TguPlanUi {
|
||||||
|
return {
|
||||||
|
planId: `${spacecraftId}-${status}-${startMs}`,
|
||||||
|
spacecraftId,
|
||||||
|
startTime: new Date(startMs).toISOString().slice(0, 19),
|
||||||
|
endTime: new Date(startMs + 3_600_000).toISOString().slice(0, 19),
|
||||||
|
kppId: "KPP-1",
|
||||||
|
status,
|
||||||
|
startMs,
|
||||||
|
endMs: startMs + 3_600_000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("isOperationalPlatform", () => {
|
describe("isOperationalPlatform", () => {
|
||||||
it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => {
|
it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => {
|
||||||
expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true);
|
expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true);
|
||||||
@@ -28,3 +42,38 @@ describe("operationalSpacecraftIds", () => {
|
|||||||
expect(ids).toEqual(new Set(["2", "30"]));
|
expect(ids).toEqual(new Set(["2", "30"]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pausedSpacecraftIds", () => {
|
||||||
|
const t0 = Date.UTC(2026, 5, 4, 0, 0, 0);
|
||||||
|
const hour = 3_600_000;
|
||||||
|
|
||||||
|
it("помечает КА паузой, если есть REJECTED и нет продвинувшего цепочку плана после него", () => {
|
||||||
|
const paused = pausedSpacecraftIds([
|
||||||
|
uiPlan("100", "REJECTED", t0),
|
||||||
|
// проекция и черновик паузу не снимают
|
||||||
|
uiPlan("100", "PLANNED", t0 + 2 * hour),
|
||||||
|
uiPlan("100", "DRAFT", t0)
|
||||||
|
]);
|
||||||
|
expect(paused).toEqual(new Set(["100"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("снимает паузу, если оператор отправил исправленный план (в полёте) на том же/более позднем окне", () => {
|
||||||
|
const paused = pausedSpacecraftIds([
|
||||||
|
uiPlan("100", "REJECTED", t0),
|
||||||
|
uiPlan("100", "WAITING_DECISION", t0)
|
||||||
|
]);
|
||||||
|
expect(paused.has("100")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не помечает паузой, если цепочка ушла вперёд (ACCEPTED после отказа)", () => {
|
||||||
|
const paused = pausedSpacecraftIds([
|
||||||
|
uiPlan("100", "REJECTED", t0),
|
||||||
|
uiPlan("100", "ACCEPTED", t0 + 5 * hour)
|
||||||
|
]);
|
||||||
|
expect(paused.has("100")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без REJECTED никого не помечает", () => {
|
||||||
|
expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -76,6 +76,47 @@ export function buildPlatformsForInterval(
|
|||||||
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
|
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статусы, означающие, что цепочка после отказа уже сдвинулась с места: оператор создал и отправил
|
||||||
|
* исправленный план (он в полёте) либо план уже принят/заложен. Проекция PLANNED и черновики DRAFT
|
||||||
|
* сюда НЕ входят — это ещё не действие оператора, пауза не снимается.
|
||||||
|
*/
|
||||||
|
const CHAIN_ADVANCED_STATUSES = new Set([
|
||||||
|
"ISSUING",
|
||||||
|
"WAITING_DECISION",
|
||||||
|
"AWAITING_LAYIN",
|
||||||
|
"LAID_IN",
|
||||||
|
"ACCEPTED"
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* КА, у которых цепочка стоит на паузе после REJECTED: есть отклонённый план и НЕТ ни одного
|
||||||
|
* «продвинувшего цепочку» плана (см. [CHAIN_ADVANCED_STATUSES]) со стартом не раньше последнего
|
||||||
|
* отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) —
|
||||||
|
* оператор должен вручную создать исправленный план.
|
||||||
|
*/
|
||||||
|
export function pausedSpacecraftIds(plans: TguPlanUi[]): Set<string> {
|
||||||
|
const latestRejectedStart = new Map<string, number>();
|
||||||
|
for (const plan of plans) {
|
||||||
|
if (plan.status === "REJECTED") {
|
||||||
|
const prev = latestRejectedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY;
|
||||||
|
if (plan.startMs > prev) latestRejectedStart.set(plan.spacecraftId, plan.startMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paused = new Set<string>();
|
||||||
|
for (const [spacecraftId, rejectedStart] of latestRejectedStart) {
|
||||||
|
const advanced = plans.some(
|
||||||
|
(plan) =>
|
||||||
|
plan.spacecraftId === spacecraftId &&
|
||||||
|
CHAIN_ADVANCED_STATUSES.has(plan.status) &&
|
||||||
|
plan.startMs >= rejectedStart
|
||||||
|
);
|
||||||
|
if (!advanced) paused.add(spacecraftId);
|
||||||
|
}
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTimelineRows(
|
export function buildTimelineRows(
|
||||||
plans: TguPlanUi[],
|
plans: TguPlanUi[],
|
||||||
platforms: TguPlatformUi[],
|
platforms: TguPlatformUi[],
|
||||||
|
|||||||
@@ -33,12 +33,27 @@ export type TimelineRow = {
|
|||||||
export type TimelinePlanSegment = {
|
export type TimelinePlanSegment = {
|
||||||
plan: TguPlanUi;
|
plan: TguPlanUi;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
laneOffset: number;
|
/** Индекс дорожки внутри строки КА (0 — верхняя); пересекающиеся планы получают разные дорожки. */
|
||||||
|
lane: number;
|
||||||
|
/** Абсолютная вертикальная координата центра линии плана. */
|
||||||
|
centerY: number;
|
||||||
x: number;
|
x: number;
|
||||||
x2: number;
|
x2: number;
|
||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TimelineRowLayout = {
|
||||||
|
row: TimelineRow;
|
||||||
|
rowIndex: number;
|
||||||
|
/** Верх строки (абсолютный y). */
|
||||||
|
y: number;
|
||||||
|
height: number;
|
||||||
|
/** Сколько дорожек занято планами (>=1). */
|
||||||
|
laneCount: number;
|
||||||
|
/** Абсолютный y верхней границы страйпа ЗРВ. */
|
||||||
|
zrvBandY: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TimelineLink = {
|
export type TimelineLink = {
|
||||||
spacecraftId: string;
|
spacecraftId: string;
|
||||||
fromPlanId: string;
|
fromPlanId: string;
|
||||||
|
|||||||
@@ -192,6 +192,11 @@
|
|||||||
background-color: #79828d;
|
background-color: #79828d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-draft {
|
||||||
|
background-color: #a98bdb;
|
||||||
|
outline: 1px dashed #e7dbff;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-workspace {
|
.tgu-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18.5rem minmax(0, 1fr) 22rem;
|
grid-template-columns: 18.5rem minmax(0, 1fr) 22rem;
|
||||||
@@ -382,6 +387,13 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__paused-badge {
|
||||||
|
fill: #f0a73d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-timeline__now {
|
.tgu-timeline__now {
|
||||||
stroke: #f46e4f;
|
stroke: #f46e4f;
|
||||||
stroke-width: 1.7;
|
stroke-width: 1.7;
|
||||||
@@ -426,6 +438,11 @@
|
|||||||
stroke-width: 13;
|
stroke-width: 13;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Черновик на таймлайне — пунктирная линия, чтобы отличить кандидата от реальных планов. */
|
||||||
|
.tgu-timeline-plan__bar.status-draft {
|
||||||
|
stroke-dasharray: 7 5;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-timeline-plan__selection {
|
.tgu-timeline-plan__selection {
|
||||||
stroke: #efffff;
|
stroke: #efffff;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
@@ -536,6 +553,11 @@
|
|||||||
padding-top: 0.35rem;
|
padding-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Действия над черновиком — переносим на несколько строк, чтобы три кнопки помещались. */
|
||||||
|
.tgu-details__actions--draft {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-alert {
|
.tgu-alert {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -834,6 +856,14 @@
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-2d-map__overlay polyline.is-selected {
|
||||||
|
stroke: #f0ad2e;
|
||||||
|
stroke-width: 7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-2d-map__overlay circle {
|
.tgu-2d-map__overlay circle {
|
||||||
fill: rgba(104, 195, 189, 0.12);
|
fill: rgba(104, 195, 189, 0.12);
|
||||||
stroke: rgba(130, 222, 216, 0.9);
|
stroke: rgba(130, 222, 216, 0.9);
|
||||||
@@ -1362,10 +1392,26 @@
|
|||||||
/* «Создать план»: кнопка над таймлайном + модальный диалог новой цепочки. */
|
/* «Создать план»: кнопка над таймлайном + модальный диалог новой цепочки. */
|
||||||
.tgu-center__actions {
|
.tgu-center__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
padding: 0.5rem 0.75rem 0;
|
padding: 0.5rem 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-center__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #c4ced0;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-center__toggle input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-chain-dialog__backdrop {
|
.tgu-chain-dialog__backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1434,11 +1480,25 @@
|
|||||||
color: var(--text-muted, #9fb0b3);
|
color: var(--text-muted, #9fb0b3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ЗРВ выбранного КА на таймлайне — лёгкая подсветка доступных зон. */
|
/* ЗРВ выбранного КА на таймлайне — отдельный страйп снизу строки, не перекрывает планы. */
|
||||||
.tgu-timeline__zrv {
|
.tgu-timeline__zrv {
|
||||||
fill: rgba(90, 170, 200, 0.16);
|
fill: rgba(90, 170, 200, 0.22);
|
||||||
stroke: rgba(90, 170, 200, 0.5);
|
stroke: rgba(90, 170, 200, 0.6);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
rx: 2;
|
rx: 2;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-timeline__zrv.is-selected {
|
||||||
|
fill: rgba(240, 173, 46, 0.35);
|
||||||
|
stroke: #f0ad2e;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Подпись страйпа ЗРВ слева — как имя КА над цепочкой планов. */
|
||||||
|
.tgu-timeline__zrv-label {
|
||||||
|
fill: rgba(90, 170, 200, 0.85);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|||||||
+9
@@ -41,6 +41,15 @@ class PlanController(
|
|||||||
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List<PlanResponse> =
|
fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List<PlanResponse> =
|
||||||
planQueryService.getAllPlans(historyDays)
|
planQueryService.getAllPlans(historyDays)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Черновики (DRAFT) для режима «Черновики» во вкладке «Планы»: созданные, но ещё не
|
||||||
|
* отправленные планы-кандидаты. Отдельный эндпоинт, т.к. остальные чтения DRAFT отсекают.
|
||||||
|
* Литеральный путь `/drafts` приоритетнее шаблона `/{spacecraftId}`.
|
||||||
|
*/
|
||||||
|
@GetMapping("/drafts")
|
||||||
|
fun getDraftPlans(@RequestParam(required = false) spacecraftId: String?): List<PlanResponse> =
|
||||||
|
planQueryService.getDraftPlans(spacecraftId)
|
||||||
|
|
||||||
/** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
|
/** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */
|
||||||
@GetMapping("/{spacecraftId}")
|
@GetMapping("/{spacecraftId}")
|
||||||
fun getPlansBySpacecraftId(
|
fun getPlansBySpacecraftId(
|
||||||
|
|||||||
+7
@@ -35,6 +35,13 @@ class SpacecraftPlanningStateEntity(
|
|||||||
@Column(name = "urgent_reissue_from")
|
@Column(name = "urgent_reissue_from")
|
||||||
var urgentReissueFrom: LocalDateTime? = null,
|
var urgentReissueFrom: LocalDateTime? = null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цепочка стоит на паузе после REJECTED: проекция пуста, воркер не выдаёт. Снимается при ручной
|
||||||
|
* выдаче / промоуте черновика / приёмке — то есть когда оператор осознанно вмешался.
|
||||||
|
*/
|
||||||
|
@Column(name = "paused_after_reject", nullable = false)
|
||||||
|
var pausedAfterReject: Boolean = false,
|
||||||
|
|
||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
var updatedAt: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
|
||||||
) {
|
) {
|
||||||
|
|||||||
+11
@@ -16,6 +16,17 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
|||||||
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
|
/** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */
|
||||||
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
|
fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List<PlannedPlanEntity>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков:
|
||||||
|
* сочетание (КА + начало + конец) уникально среди DRAFT.
|
||||||
|
*/
|
||||||
|
fun findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||||
|
spacecraftId: String,
|
||||||
|
startTime: LocalDateTime,
|
||||||
|
endTime: LocalDateTime,
|
||||||
|
status: PlannedPlanStatus
|
||||||
|
): List<PlannedPlanEntity>
|
||||||
|
|
||||||
/** Возвращает все планы в порядке старта (для read-проекции и истории). */
|
/** Возвращает все планы в порядке старта (для read-проекции и истории). */
|
||||||
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
||||||
|
|
||||||
|
|||||||
+15
@@ -75,6 +75,21 @@ class CreateChainPlanUseCase(
|
|||||||
plan
|
plan
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Гард на дубли: сочетание (КА + начало + конец) уникально среди черновиков. Повторное создание
|
||||||
|
// того же окна не плодит второй черновик — оператор не теряется в одинаковых кандидатах.
|
||||||
|
val duplicate = plannedPlanRepository
|
||||||
|
.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||||
|
req.spacecraftId, req.windowStart, req.windowStop, PlannedPlanStatus.DRAFT
|
||||||
|
)
|
||||||
|
.firstOrNull()
|
||||||
|
if (duplicate != null) {
|
||||||
|
throw ResponseStatusException(
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
"Черновик на окно [${req.windowStart}–${req.windowStop}] для КА ${req.spacecraftId} уже есть " +
|
||||||
|
"(${duplicate.planId}) — откройте его или удалите"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой).
|
// Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой).
|
||||||
val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId)
|
val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId)
|
||||||
if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) {
|
if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) {
|
||||||
|
|||||||
+17
-4
@@ -25,8 +25,11 @@ import java.time.ZoneOffset
|
|||||||
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
|
* Обрабатывает decision и закладочные feedback-события идемпотентно по eventId.
|
||||||
*
|
*
|
||||||
* - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
|
* - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается.
|
||||||
* - REJECTED / «не заложен» / «не принят» — план помечается терминально, и на ближайшую ЗРВ
|
* - REJECTED — план отклонён как неправильный: помечается терминально, авто-перевыдача НЕ
|
||||||
* запускается срочная перевыдача ([reissueOnNearestWindow]).
|
* запускается. Цепочка встаёт на паузу — оператор должен вручную создать исправленный план
|
||||||
|
* («Создать план»). Бессмысленно автоматически перевыдавать тот же план на то же окно.
|
||||||
|
* - «не заложен» / «не принят» — технический сбой доставки (содержимое не отклонялось): план
|
||||||
|
* помечается терминально, и на ближайшую ЗРВ запускается срочная перевыдача ([reissueOnNearestWindow]).
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class HandlePlanDecisionUseCase(
|
class HandlePlanDecisionUseCase(
|
||||||
@@ -156,6 +159,7 @@ class HandlePlanDecisionUseCase(
|
|||||||
state.activePlanId = null
|
state.activePlanId = null
|
||||||
state.activeAttemptId = null
|
state.activeAttemptId = null
|
||||||
state.urgentReissueFrom = null
|
state.urgentReissueFrom = null
|
||||||
|
state.pausedAfterReject = false
|
||||||
state.chainVersion += 1
|
state.chainVersion += 1
|
||||||
state.updatedAt = now
|
state.updatedAt = now
|
||||||
|
|
||||||
@@ -177,10 +181,19 @@ class HandlePlanDecisionUseCase(
|
|||||||
plan.status = PlannedPlanStatus.REJECTED
|
plan.status = PlannedPlanStatus.REJECTED
|
||||||
plan.updatedAt = now
|
plan.updatedAt = now
|
||||||
|
|
||||||
|
// Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный», и повторно выдавать тот же
|
||||||
|
// план бессмысленно. Снимаем активную выдачу, гасим срочную перевыдачу и ставим цепочку на
|
||||||
|
// паузу (pausedAfterReject) — проекция станет пустой, воркер ничего не выдаст, пока оператор
|
||||||
|
// вручную не создаст исправленный план (его promote/ручная выдача снимут паузу). Базу не трогаем.
|
||||||
|
state.activePlanId = null
|
||||||
|
state.activeAttemptId = null
|
||||||
|
state.urgentReissueFrom = null
|
||||||
|
state.pausedAfterReject = true
|
||||||
|
state.updatedAt = now
|
||||||
|
|
||||||
planIssueAttemptRepository.save(attempt)
|
planIssueAttemptRepository.save(attempt)
|
||||||
plannedPlanRepository.save(plan)
|
plannedPlanRepository.save(plan)
|
||||||
|
spacecraftPlanningStateJpaRepository.save(state)
|
||||||
reissueOnNearestWindow(state, plan, now)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+7
@@ -106,6 +106,9 @@ class IssueDuePlanWorker(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пауза после REJECTED централизована в PlanProjectionService.project(): для отклонённой
|
||||||
|
// цепочки проекция приходит пустой (выше уже сработал early-return), поэтому отдельного
|
||||||
|
// гарда «не выдавать отклонённое окно» здесь не нужно.
|
||||||
val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes)
|
val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes)
|
||||||
val due = urgentFrom != null || !now.isBefore(issueNotBefore)
|
val due = urgentFrom != null || !now.isBefore(issueNotBefore)
|
||||||
if (!due) {
|
if (!due) {
|
||||||
@@ -173,6 +176,8 @@ class IssueDuePlanWorker(
|
|||||||
state.activePlanId = plan.planId
|
state.activePlanId = plan.planId
|
||||||
state.activeAttemptId = attempt.attemptId
|
state.activeAttemptId = attempt.attemptId
|
||||||
state.urgentReissueFrom = null
|
state.urgentReissueFrom = null
|
||||||
|
// Оператор вручную выдал план — снимаем паузу после отказа (если была).
|
||||||
|
state.pausedAfterReject = false
|
||||||
state.updatedAt = now
|
state.updatedAt = now
|
||||||
spacecraftPlanningStateJpaRepository.save(state)
|
spacecraftPlanningStateJpaRepository.save(state)
|
||||||
|
|
||||||
@@ -202,6 +207,8 @@ class IssueDuePlanWorker(
|
|||||||
state.activePlanId = plan.planId
|
state.activePlanId = plan.planId
|
||||||
state.activeAttemptId = attempt.attemptId
|
state.activeAttemptId = attempt.attemptId
|
||||||
state.urgentReissueFrom = null
|
state.urgentReissueFrom = null
|
||||||
|
// Оператор отправил исправленный черновик — снимаем паузу после отказа (если была).
|
||||||
|
state.pausedAfterReject = false
|
||||||
state.updatedAt = now
|
state.updatedAt = now
|
||||||
spacecraftPlanningStateJpaRepository.save(state)
|
spacecraftPlanningStateJpaRepository.save(state)
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -33,8 +33,15 @@ class PlanProjectionService(
|
|||||||
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson)
|
||||||
|
|
||||||
val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null)
|
||||||
val anchorPlan = resolveAnchorPlan(state)
|
|
||||||
|
|
||||||
|
// Пауза после REJECTED: цепочка стоит, пока оператор вручную не создаст исправленный план
|
||||||
|
// (ручная выдача/промоут снимут флаг). Будущего автоматически быть не может — отдаём пусто.
|
||||||
|
// Это единый источник паузы: воркер выдачи получает пустую проекцию и ничего не материализует.
|
||||||
|
if (state?.pausedAfterReject == true) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val anchorPlan = resolveAnchorPlan(state)
|
||||||
return if (anchorPlan == null) {
|
return if (anchorPlan == null) {
|
||||||
planCalculationService.calculatePlans(points)
|
planCalculationService.calculatePlans(points)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+17
@@ -53,6 +53,23 @@ class PlanQueryService(
|
|||||||
return combine(persisted, projected)
|
return combine(persisted, projected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Черновики (DRAFT) — отдельным чтением, т.к. основной read-path их намеренно отсекает.
|
||||||
|
* Для режима «Черновики» во вкладке «Планы»: список созданных, но ещё не отправленных
|
||||||
|
* планов-кандидатов (из диалога новой цепочки или из редактора). Опционально по одному КА.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun getDraftPlans(spacecraftId: String?): List<PlanResponse> {
|
||||||
|
val plans = if (spacecraftId != null) {
|
||||||
|
plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)
|
||||||
|
} else {
|
||||||
|
plannedPlanRepository.findAllByOrderByStartTime()
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
.filter { plan -> plan.status == PlannedPlanStatus.DRAFT }
|
||||||
|
.map(PlanResponse::from)
|
||||||
|
}
|
||||||
|
|
||||||
/** Возвращает persisted plan по ID для test-only/debug API. */
|
/** Возвращает persisted plan по ID для test-only/debug API. */
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getByPlanId(planId: UUID): PlannedPlanEntity =
|
fun getByPlanId(planId: UUID): PlannedPlanEntity =
|
||||||
|
|||||||
+12
-6
@@ -18,8 +18,14 @@ import java.time.LocalDateTime
|
|||||||
*/
|
*/
|
||||||
object ZrvWindowMatcher {
|
object ZrvWindowMatcher {
|
||||||
|
|
||||||
/** Допуск сравнения времён — компенсирует погрешность сериализации ISO-строк на фронте. */
|
/**
|
||||||
private const val TOLERANCE_SECONDS = 1L
|
* Допуск сравнения времён — компенсирует усечение ISO-строк на фронте до целых секунд
|
||||||
|
* (`.slice(0,19)`): реальная ЗРВ имеет доли секунды, набранное окно — нет, расхождение строго
|
||||||
|
* меньше секунды. Сравнивается истинная абсолютная длительность (не усечённые секунды): у
|
||||||
|
* отрицательного `Duration` доли нормализуются в +nanos, поэтому `.toSeconds()` округлял бы 0.18с
|
||||||
|
* вниз до −1 и ломал совпадение.
|
||||||
|
*/
|
||||||
|
private val TOLERANCE = Duration.ofSeconds(1)
|
||||||
|
|
||||||
fun matches(
|
fun matches(
|
||||||
windows: List<CalculatedPlan>,
|
windows: List<CalculatedPlan>,
|
||||||
@@ -31,14 +37,14 @@ object ZrvWindowMatcher {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val startMatches = windows.any { zrv ->
|
val startMatches = windows.any { zrv ->
|
||||||
zrv.kppId == windowKppId && diffSeconds(zrv.startTime, windowStart) < TOLERANCE_SECONDS
|
zrv.kppId == windowKppId && within(zrv.startTime, windowStart)
|
||||||
}
|
}
|
||||||
val stopMatches = windows.any { zrv ->
|
val stopMatches = windows.any { zrv ->
|
||||||
diffSeconds(zrv.endTime, windowStop) < TOLERANCE_SECONDS
|
within(zrv.endTime, windowStop)
|
||||||
}
|
}
|
||||||
return startMatches && stopMatches
|
return startMatches && stopMatches
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long =
|
private fun within(a: LocalDateTime, b: LocalDateTime): Boolean =
|
||||||
Math.abs(Duration.between(a, b).toSeconds())
|
Duration.between(a, b).abs() <= TOLERANCE
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
-- Цепочка КА стоит на паузе после REJECTED: пока флаг TRUE, проекция пуста и воркер ничего не
|
||||||
|
-- выдаёт — оператор должен вручную создать исправленный план («Создать план»). Флаг снимается при
|
||||||
|
-- ручной выдаче / промоуте черновика / приёмке. В отличие от urgent_reissue_from (тех. негативы
|
||||||
|
-- LAYIN_FAILED/NOT_ACCEPTED авто-перевыдаются), отказ решением требует осознанного вмешательства.
|
||||||
|
ALTER TABLE spacecraft_planning_state
|
||||||
|
ADD COLUMN IF NOT EXISTS paused_after_reject BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
+18
@@ -137,6 +137,18 @@ class CreateChainPlanUseCaseTest {
|
|||||||
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate window for the same spacecraft yields 409 and no mission is created`() {
|
||||||
|
val existing = plan(time(5), time(6), PlannedPlanStatus.DRAFT)
|
||||||
|
val fixture = fixture(base = null, existingDuplicate = existing)
|
||||||
|
val ex = assertThrows<ResponseStatusException> {
|
||||||
|
fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId))
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatus.CONFLICT, ex.statusCode)
|
||||||
|
verify(fixture.client, never()).createBlankMission(anyNonNull<MissionBlankRequest>())
|
||||||
|
verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull<MissionCloneRequest>())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() {
|
fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() {
|
||||||
val newMissionId = UUID.randomUUID()
|
val newMissionId = UUID.randomUUID()
|
||||||
@@ -165,10 +177,16 @@ class CreateChainPlanUseCaseTest {
|
|||||||
projectedTail: List<CalculatedPlan> = emptyList(),
|
projectedTail: List<CalculatedPlan> = emptyList(),
|
||||||
failSave: Boolean = false,
|
failSave: Boolean = false,
|
||||||
spacecraftIdOverride: String = spacecraftId,
|
spacecraftIdOverride: String = spacecraftId,
|
||||||
|
existingDuplicate: PlannedPlanEntity? = null,
|
||||||
window: Pair<LocalDateTime, LocalDateTime> = time(5) to time(6)
|
window: Pair<LocalDateTime, LocalDateTime> = time(5) to time(6)
|
||||||
): Fixture {
|
): Fixture {
|
||||||
val repo = mock(PlannedPlanRepository::class.java)
|
val repo = mock(PlannedPlanRepository::class.java)
|
||||||
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
`when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base))
|
||||||
|
`when`(
|
||||||
|
repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(
|
||||||
|
anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull()
|
||||||
|
)
|
||||||
|
).thenReturn(listOfNotNull(existingDuplicate))
|
||||||
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
val saved = arrayOfNulls<PlannedPlanEntity>(1)
|
||||||
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
|
`when`(repo.save(anyNonNull<PlannedPlanEntity>())).thenAnswer { inv ->
|
||||||
if (failSave) throw RuntimeException("DB down")
|
if (failSave) throw RuntimeException("DB down")
|
||||||
|
|||||||
+16
@@ -186,6 +186,22 @@ class IssueDuePlanWorkerOnDemandTest {
|
|||||||
assertNotNull(state.activeAttemptId)
|
assertNotNull(state.activeAttemptId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `issuePlanNow clears the paused-after-reject flag (operator intervened)`() {
|
||||||
|
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, pausedAfterReject = true)
|
||||||
|
val worker = worker(planStore, attemptStore, state)
|
||||||
|
|
||||||
|
worker.issuePlanNow(target.planId)
|
||||||
|
|
||||||
|
assertEquals(false, state.pausedAfterReject)
|
||||||
|
assertEquals(target.planId, state.activePlanId)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `issuePlanNow rejects a plan inside the freeze window`() {
|
fun `issuePlanNow rejects a plan inside the freeze window`() {
|
||||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
|||||||
+5
-2
@@ -83,7 +83,7 @@ class PlanIssueAndDecisionFlowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejected decision marks plan REJECTED and arms urgent reissue`() {
|
fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() {
|
||||||
val spacecraftId = "25544"
|
val spacecraftId = "25544"
|
||||||
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
|
val plan = plannedPlan(spacecraftId, LocalDateTime.now(ZoneOffset.UTC).plusHours(2), LocalDateTime.now(ZoneOffset.UTC).plusHours(3), PlannedPlanStatus.WAITING_DECISION)
|
||||||
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED)
|
||||||
@@ -95,8 +95,11 @@ class PlanIssueAndDecisionFlowTest {
|
|||||||
|
|
||||||
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
|
assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status)
|
||||||
assertEquals(PlannedPlanStatus.REJECTED, plan.status)
|
assertEquals(PlannedPlanStatus.REJECTED, plan.status)
|
||||||
|
// Активная выдача снята, перевыдача НЕ взведена, цепочка на паузе — ждёт ручного исправленного плана.
|
||||||
|
assertNull(state.activePlanId)
|
||||||
assertNull(state.activeAttemptId)
|
assertNull(state.activeAttemptId)
|
||||||
assertNotNull(state.urgentReissueFrom)
|
assertNull(state.urgentReissueFrom)
|
||||||
|
assertTrue(state.pausedAfterReject)
|
||||||
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
verify(slotsUpdateJobService, never()).enqueue(plan.planId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
@@ -132,6 +132,21 @@ class PlanProjectionServiceTest {
|
|||||||
assertEquals(base.kppId, anchor?.kppId)
|
assertEquals(base.kppId, anchor?.kppId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns empty projection when the chain is paused after reject`() {
|
||||||
|
val calculation = mock(PlanCalculationService::class.java)
|
||||||
|
val service = service(
|
||||||
|
state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true),
|
||||||
|
calculation = calculation
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = service.project(spacecraftId)
|
||||||
|
|
||||||
|
assertTrue(result.isEmpty())
|
||||||
|
verify(calculation, never()).calculatePlans(anyNonNull())
|
||||||
|
verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull())
|
||||||
|
}
|
||||||
|
|
||||||
private fun service(
|
private fun service(
|
||||||
snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(),
|
snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(),
|
||||||
state: SpacecraftPlanningStateEntity? = null,
|
state: SpacecraftPlanningStateEntity? = null,
|
||||||
|
|||||||
+32
@@ -79,6 +79,38 @@ class PlanQueryServiceTest {
|
|||||||
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status)
|
assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDraftPlans returns only DRAFT plans`() {
|
||||||
|
val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT)
|
||||||
|
val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN)
|
||||||
|
val plannedRepository = mock(PlannedPlanRepository::class.java)
|
||||||
|
`when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active))
|
||||||
|
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
||||||
|
val projection = mock(PlanProjectionService::class.java)
|
||||||
|
|
||||||
|
val service = PlanQueryService(plannedRepository, snapshotRepository, projection)
|
||||||
|
val result = service.getDraftPlans(spacecraftId = null)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals(PlannedPlanStatus.DRAFT, result.single().status)
|
||||||
|
assertEquals(draft.planId, result.single().planId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getDraftPlans filters by spacecraftId when provided`() {
|
||||||
|
val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT)
|
||||||
|
val plannedRepository = mock(PlannedPlanRepository::class.java)
|
||||||
|
`when`(plannedRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)).thenReturn(listOf(draft))
|
||||||
|
val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java)
|
||||||
|
val projection = mock(PlanProjectionService::class.java)
|
||||||
|
|
||||||
|
val service = PlanQueryService(plannedRepository, snapshotRepository, projection)
|
||||||
|
val result = service.getDraftPlans(spacecraftId = spacecraftId)
|
||||||
|
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals(draft.planId, result.single().planId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
|
private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity =
|
||||||
PlannedPlanEntity(
|
PlannedPlanEntity(
|
||||||
planId = UUID.randomUUID(),
|
planId = UUID.randomUUID(),
|
||||||
|
|||||||
+13
@@ -51,4 +51,17 @@ class ZrvWindowMatcherTest {
|
|||||||
fun `empty windows never match`() {
|
fun `empty windows never match`() {
|
||||||
assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1"))
|
assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sub-second ZRV matches a window truncated to whole seconds`() {
|
||||||
|
// Фронт усекает ISO до целых секунд (.slice(0,19)); реальная ЗРВ имеет доли секунды.
|
||||||
|
// Расхождение строго меньше секунды должно совпадать (регрессия на Duration.toSeconds()).
|
||||||
|
val fractional = CalculatedPlan(
|
||||||
|
sc,
|
||||||
|
at(0).withNano(180_078_000),
|
||||||
|
at(10).withNano(19_687_000),
|
||||||
|
"KPP-1"
|
||||||
|
)
|
||||||
|
assertTrue(ZrvWindowMatcher.matches(listOf(fractional), at(0), at(10), "KPP-1"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user