Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
emelianov
2026-06-17 15:28:09 +03:00
51 changed files with 4658 additions and 255 deletions
+5
View File
@@ -72,3 +72,8 @@ out/
/services/pcp-tgu-ops-ui/yarn-error.log* /services/pcp-tgu-ops-ui/yarn-error.log*
/services/pcp-tgu-ops-ui/pnpm-debug.log* /services/pcp-tgu-ops-ui/pnpm-debug.log*
.gstack/ .gstack/
# Локальная раскладка собранных bootJar всех сервисов (артефакты, ~1.7G) — не версионируем.
/serviceAllBuild/
# Машинно-зависимый npm prefix фронта (локальный путь разработчика).
/services/pcp-tgu-ui-service/.npmrc
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
#
# Сборка bootJar всех Spring-сервисов.
#
# ./buildAll.sh # собрать все jar (без тестов; bootJar тесты не гоняет)
# ./buildAll.sh --clean # clean + сборка с нуля
#
# Фронт pcp-tgu-ui-service здесь не собирается (это npm-проект, не gradle).
# Чтобы после сборки разложить jar в serviceAllBuild/ — запусти ./serviceAllBuild.sh
# (или сразу ./serviceAllBuild.sh --build, который делает сборку и раскладку вместе).
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARGS=()
[[ "${1:-}" == "--clean" ]] && ARGS+=(clean)
ARGS+=(bootJar)
echo "==> ./gradlew ${ARGS[*]}"
"$ROOT/gradlew" "${ARGS[@]}"
echo
echo "Готово. Разложить в одну папку: ./serviceAllBuild.sh"
+9
View File
@@ -63,6 +63,15 @@ spring:
- Path=/api/pcp-slots/** - Path=/api/pcp-slots/**
filters: filters:
- StripPrefix=2 - StripPrefix=2
# Каталог КА: фронт сюда не ходит, но slots-service обращается к нему строго
# через gateway (settings.satellite-catalog-service -> /api/pcp-satellite-catalog).
# StripPrefix=2: /api/pcp-satellite-catalog/api/satellites -> бэкенд /api/satellites.
- id: satellite-catalog
uri: ${pcp.services.satellite-catalog}
predicates:
- Path=/api/pcp-satellite-catalog/**
filters:
- StripPrefix=2
# --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix. # --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix.
- id: stations - id: stations
uri: ${pcp.services.stations} uri: ${pcp.services.stations}
+10 -6
View File
@@ -68,10 +68,12 @@ spring:
codec: codec:
max-in-memory-size: 20MB max-in-memory-size: 20MB
# Все обращения к другим сервисам — строго через gateway (StripPrefix снимает /api/pcp-<x>).
# Напрямую по адресам сервисов slots-service не ходит.
settings: settings:
request-service: ${pcp.services.request} request-service: ${pcp.services.gateway}/api/pcp-request
earth-grid-service: ${pcp.services.request} earth-grid-service: ${pcp.services.gateway}/api/pcp-request
satellite-catalog-service: ${pcp.services.satellite-catalog} satellite-catalog-service: ${pcp.services.gateway}/api/pcp-satellite-catalog
app: app:
kafka: kafka:
@@ -127,10 +129,12 @@ spring:
codec: codec:
max-in-memory-size: 20MB max-in-memory-size: 20MB
# Все обращения к другим сервисам — строго через gateway (StripPrefix снимает /api/pcp-<x>).
# Напрямую по адресам сервисов slots-service не ходит.
settings: settings:
request-service: ${pcp.services.request} request-service: ${pcp.services.gateway}/api/pcp-request
earth-grid-service: ${pcp.services.request} earth-grid-service: ${pcp.services.gateway}/api/pcp-request
satellite-catalog-service: ${pcp.services.satellite-catalog} satellite-catalog-service: ${pcp.services.gateway}/api/pcp-satellite-catalog
app: app:
kafka: kafka:
topics: topics:
+208
View File
@@ -0,0 +1,208 @@
<!-- /autoplan restore point: /home/dasolovyev/.gstack/projects/missionis-observatio-terrae/feat-tgu-slots-filters-autoplan-restore-20260616-165024.md -->
# Эргономика выбора слотов: выбор «полосами» + правило выполнимости брони
## Проблема (как сформулировал оператор)
Сделана работа по сортировке, подсветке брони, показу забронированных, расчёту %
покрытия. Но **сам выбор слотов под бронь по-прежнему неудобен**: сейчас оператор
отмечает слоты галочками поштучно (или Shift+клик диапазоном, или «все видимые /
все сутки»). На сотнях-тысячах слотов это утомительно. Плюс выбор «слепой» — он не
знает, можно ли вообще физически отснять выбранный набор (манёвренность КА).
## Решённые premise (premise-гейт, 2026-06-16, скорректировано после CEO-ревью)
1. **Ограничение брони через смену крена реально существует** (агилити КА).
2. **КОРРЕКЦИЯ:** модель манёвренности УЖЕ есть в slots-service, по-КА из каталога
(`mmi`, `maxSurveyDuration`, `maxChainLength`, `ROLL_EPSILON=0.01` — см. ниже).
Ручная бронь (`bookReq`) её не проверяет, но coverage-solver и `buildSurveyChains`
её соблюдают. Поэтому **не хардкодим физику на фронте** — проверка выполнимости
будет **бэкенд validate-эндпоинтом**, переиспользующим эти по-КА параметры.
3. **Iteration 1 = только UX выбора полосами** (frontend, без правила). Проверка
выполнимости — **iteration 2** (бэкенд validate-эндпоинт + проводка на фронте).
## Что уже есть в бэкенде (источник правды для it.2)
- Параметры по-КА из каталога ([SatelliteCatalogClient.kt:116](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SatelliteCatalogClient.kt#L116),
[AbstractSatellite.kt:25](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/model/satellite/AbstractSatellite.kt#L25)):
`mmi`=10с (мин. интервал маневра, тот же крен), `maxSurveyDuration`=300с,
`maxChainLength`=30 слотов, `ROLL_EPSILON`=0.01.
- `buildSurveyChains` / `canMergeInTime` / `sameRoll` ([SlotService.kt:2151](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2151))
— склейка цепочек по-КА (для отрисовки), `OccupiedIntervalPrefilter` — mmi/maxSurveyDuration.
- **Несоответствия исходного черновика** (закрыты решением «правило за бэком»): фронтовые
150с ≠ бэкендовые 300с; фронтовый ε=0.1° ≠ бэкендовый 0.01; «30с на смену крена» —
в бэке отдельной такой константы НЕТ (склейка только при том же крене). Эти числа
сверяются с каталогом/баллистиками в it.2, НЕ хардкодятся.
## Что уже есть (карта на существующий код)
- Выбор: `selectedKeys: Set<slotKey>` в [TguSlotsTab.tsx](../../services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx).
Поштучный чек, Shift+клик-диапазон ([slotSelection.ts](../../services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts) `selectionAfterCheck`),
«выбрать видимые», «все сутки», «снять выбор».
- Слот несёт `roll`, `revolution`, `revolutionSign`, `cycle`, `satelliteId`, `slotNumber`, `tn`, `tk`, `state`.
- Чистые модули с юнит-тестами: slotFilters.ts, slotSelection.ts, coverage.ts (+ .test.ts) — образец стиля.
- Бронь: `book()` → подтверждение → `performBook` (чанк 200), снятие аналогично.
- Бэкенд бронирует без валидации манёвренности ([SlotService.kt:2500](../../services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt#L2500)). В it.1 не трогаем.
## Решение — Iteration 1 (только UX выбора полосами, frontend) — ФИНАЛ после ревью
### Модуль 1: `slotStrips.ts` (чистая логика + юнит-тесты)
- `groupStrips(slots, keyOf): Map<slotKey, StripId>` — максимальные same-roll цепочки
**по-КА**. `keyOf` — аксессор ключа слота (как `keyAt` в `selectionAfterCheck`), чтобы
не дублировать формулу `${satelliteId}:${slotNumber}:${cycle}`.
- **Детерминизм:** внутри функции тотальный порядок — сортировка по
`(satelliteId, parseLocal(tn), parseLocal(tk), slotNumber, cycle, slotKey)`. Результат
не зависит от порядка входного массива.
- **ε крена = `ROLL_EPSILON = 0.01`** (именованная константа, как в бэкенде; НЕ 0.1).
Сравнение — **с якорным креном полосы** (`abs(roll anchorRoll) ≤ 0.01`), НЕ с
предыдущим слотом (иначе дрейф крена 0.00→0.009→0.018 ошибочно склеит). Совпадает с
бэкендовым `sameRoll(roll, chainRoll)`.
- **StripId стабилен и читаем:** `strip:${slotKey первого члена}` (не счётчик — счётчик
зависит от порядка обхода КА).
- Краевые: один слот → своя полоса; два КА вперемешку по времени НЕ склеиваются;
`tn` не парсится (NaN) → синглтон без падения; ровно на границе ε (`==0.01`) → та же полоса.
### Модуль 2: выбор «полосами» (UX в TguSlotsTab) — решения ревью
- **Жест: чип в строке `полоса ×N`** (N = число видимых членов полосы). НЕ Alt+клик
(необнаружим, конфликт с чек/Shift), НЕ клик по карте как основной. Чип появляется
только при N≥2 и если есть хоть один выбираемый (не-BUSY) член.
- **Identity ≠ selection target:**
- `stripIdByKey = useMemo(groupStrips(displaySlots, slotKey), [displaySlots])` — id по
СТАБИЛЬНОМУ набору (не filteredSlots/sortedSlots — иначе фильтр-дырка ложно склеит/разорвёт).
- Видимые выбираемые члены полосы берём из `filteredSlots``!slotIsBusy` (по образцу
`selectableVisible`).
- **Поведение чипа = `toggleAllVisible`, но в рамках одной полосы:** если ВСЕ видимые
выбираемые члены уже выбраны → снять их; иначе добавить. После — `anchorIndexRef.current = null`.
Без нового state (`selectedStrips` не заводим — рассинхрон с `selectedKeys`).
- **Группировка визуально по StripId, sort-aware:** в сортировке «время» — левый рельс
(border-left, цвет по `StripId`, приглушённый) на смежных строках полосы. В «крен»/«вклад»
слоты полосы НЕ подряд → рельс НЕ рисуем; подсветка членов полосы по StripId при наведении.
Фон строки оставляем за состоянием (active/selected); статус-ячейки не перекрашиваем.
- **Карта it.1:** оставляем существующую подсветку выбранных слотов (`markedObjectIds` уже
ведётся от `selectedKeys`). Отдельную раскраску полос на карте и «выбрать полосу из
`SlotPickMenu`» — НЕ делаем в it.1 (расширяет scope на модель/рисовалку карты).
- **CSS/таблица:** обновить `colCount`; выравнивание ячеек — классами, не сдвигом
`nth-child`; чип держать в пределах `ROW_HEIGHT = 34`.
### Матрица состояний строки (зафиксировать перед реализацией)
| Состояние | Источник | Визуал |
|---|---|---|
| active (фокус по карте) | `activeSlotId` | подсветка строки (как есть) |
| selected | `selectedKeys.has(key)` | чек отмечен |
| strip-selected | все видимые выбираемые члены полосы выбраны | чип в активном виде |
| partial | часть членов полосы выбрана | чип в нейтральном виде |
| disabled chip | N<2 или 0 выбираемых членов | чипа нет |
| booked/occupied | как есть (бейдж) | не меняется; в полосу входит визуально, в выбор — нет |
## Реализовано (it.1, 2026-06-17 — пересмотрено)
ВАЖНО: первая версия it.1 (авто-чип «полоса ×N» по равному крену) оказалась не той фичей и
переделана. На реальных данных полоса по «подряд по времени + равный крен» не формировалась
(в один момент идёт «веер» кренов с шагом ~3°, равный крен повторяется не подряд, а вдоль трассы
через каденцию). Оператор хотел не авто-магию, а ручной выбор полосы мышью «от первого к последнему»
с запретом выйти за полосу.
Переопределение полосы (согласовано с оператором):
- **полоса = один КА + один крен (допуск `ROLL_EPSILON`) + подряд по времени (без разрыва каденции);**
- **виток/цикл в границу НЕ входят** — у экватора номер витка меняется, а слоты остаются в одной полосе.
Сделано:
- `slotStrips.ts`: `groupStrips(slots, keyOf) → { idByKey, membersOf }`. Группировка: по КА →
кластеризация по крену (`ROLL_EPSILON=0.5`, разрыв > ε начинает кластер) → внутри кластера деление
по непрерывности времени (`CADENCE_GAP_FACTOR=1.5` от медианной каденции). `PolosaId =
polosa:${ключ первого по времени члена}`; `membersOf` — члены в порядке времени.
- `slotSelection.ts`: вместо `toggleStripSelection` — `selectStripRange(current, members, fromKey,
toKey)` (чистая: добавляет членов полосы между двумя кликами включительно, без мутации).
- `TguSlotsTab.tsx`: убран чип/hover/`stripInfo`. Жест: обычный клик ставит якорь; Shift+клик по
слоту той же полосы выделяет членов между якорем и кликом (BUSY исключаются); Shift+клик по слоту
другой полосы/КА — выбор не меняется + подсказка «только в пределах одной полосы». Якорь — состояние
(`anchorKey`), строки полосы якоря подсвечены рельсом (оттенок по `PolosaId`); сбрасывается при
смене заявки/сортировки/фильтров/расчёта. Подсказка жеста в selbar.
- `tgu-planning.css`: удалены стили чипа/hover; рельс `tgu-slots-row--strip` переиспользован под
полосу якоря, добавлен `is-strip-anchor`.
- Калибровка `ROLL_EPSILON=0.5` / `CADENCE_GAP_FACTOR=1.5` — по скрину данных; при ручном прогоне
возможна подстройка по фактическим `roll`/`tn`.
- Бэкенд/DTO не тронуты. Правило выполнимости (манёвренность КА) по-прежнему it.2.
Доработки (2026-06-17):
- **Shift+клик ближе к якорю «сжимает» выделение.** `selectStripRange` теперь задаёт выбор полосы
РОВНО диапазоном [from..to]: члены полосы вне диапазона снимаются, выбор в других полосах сохраняется.
Якорь при Shift+клике не двигается (остаётся первым кликом) — можно тянуть конец туда-обратно.
- **Жест выбора полосы мышью на карте (Shift+drag).** Слоты на карте уже кликабельные полигоны
(`slot:${key}` в spatial-index). Карта на жесте отдаёт наверх ключи слотов под курсором
(`onStripAnchor`/`onStripDrag`/`onStripCommit`), а полосу/диапазон считает `TguSlotsTab`
(`pickStripAnchor` — самый ранний по времени слот под курсором = начало; `pickStripEnd` — самый
поздний член ТОЙ ЖЕ полосы под курсором = конец; за границу полосы выделение не уходит). Обычный
drag = панорама, Shift+drag по слоту = выбор полосы (без отдельной кнопки-режима). Подсветка
диапазона живая — через `markedObjectIds` (SVG-оверлей `is-marked`), отдельный слой превью не нужен.
## Решение — Iteration 2 (проверка выполнимости, отдельно)
- Бэкенд: read-only `POST /api/slots/booking/validate` (набор слотов → violations),
переиспользует по-КА `mmi`/`maxSurveyDuration`/`maxChainLength`/`ROLL_EPSILON` из каталога.
Сверить точную семантику (300 vs «150 на полосу», нужна ли отдельная 30с-граница) с
баллистиками. Опционально позже — валидация и в самом `bookReq`.
- Фронт: гонять validate при выборе/перед бронью, показывать violations, решить
блокировать кнопку или предупреждать.
## NOT in scope (it.1)
- Любая физика выполнимости на фронте (перенесено в it.2, за бэкенд).
- Переработка автоподбора покрытия (GREEDY/CONTINUOUS).
- Изменение модели хранения броней.
## Тест-план (it.1) — финал
- `slotStrips.test.ts` (groupStrips): мультиполоса в одном КА; два КА вперемешку по времени
(не склеиваются); один слот → своя полоса; граница ε (`diff==0.01` склеивает, `0.0101` рвёт);
дрейф крена 0.00/0.009/0.018 (с якорным сравнением → две полосы); устойчивость к порядку
входа (равенство РАЗБИЕНИЯ при перемешивании); tie-break по равному `tn`; NaN `tn` → синглтон
без падения; пустой вход → пустая Map.
- **Чистый хелпер выбора полосы** (выделить из компонента, как slotSelection): скрытые члены
не выбираются; BUSY исключён; «все видимые выбраны → снять, иначе добавить»; нет мутации
входного `selectedKeys`.
- Ручная проверка в vite dev: чип `полоса ×N`, рельс в сортировке «время», подсветка по
StripId в «крен»/«вклад», согласованность с Shift+клик/чек/«все сутки», счётчик «Выбрано».
## Смежная находка (вне scope, флаг оператору)
`anchorIndexRef` (якорь Shift-выбора) сбрасывается при смене сортировки, но НЕ при смене
фильтров (статус/КА/ветвь/сутки), хотя они тоже меняют порядок строк → Shift-диапазон после
фильтрации может выбрать не то. Существующий латентный баг, рядом с этой задачей. Дёшево
починить (сбрасывать якорь и при смене фильтров) — включить в it.1 или отдельным фиксом.
---
## Review artifacts (/autoplan, 2026-06-16)
### CEO consensus
| # | Вопрос | Claude | Codex | Консенсус |
|---|--------|--------|-------|-----------|
| 1 | Та ли проблема | да (UX выбора) | да | CONFIRMED |
| 2 | Фронт-физика — регрет? | да, критично | да, критично | CONFIRMED → правило за бэк |
| 3 | Бэкенд уже имеет модель? | да (нашёл chain-логику) | — | подтверждено в коде |
| 4 | Калибровка scope | разбить | разбить | CONFIRMED → it.1 только UX |
### Design consensus
| Тема | Claude | Codex | Консенсус |
|------|--------|-------|-----------|
| Жест | чип в строке | чип в строке | CONFIRMED |
| Grouping vs sort | sort-aware по StripId | sort-aware по StripId | CONFIRMED |
| Скрытые члены | только видимые | только видимые | CONFIRMED |
| Карта | вторично/отложить | вторично/отложить | CONFIRMED |
| Состояния | матрица | матрица | CONFIRMED |
### Eng consensus
| Тема | Claude | Codex | Консенсус |
|------|--------|-------|-----------|
| StripId над displaySlots | да | да | CONFIRMED |
| Identity ≠ selection target | да | да | CONFIRMED |
| Memo на [displaySlots] | да | да | CONFIRMED |
| ε: якорь vs предыдущий | оставил открытым | якорь (анти-дрейф) | DISAGREE → решено: якорь |
| Бэкенд-изменения в it.1 | нет | нет | CONFIRMED |
| Сброс якоря на фильтрах | — | да (смежный баг) | флаг оператору |
### Decision Audit Trail
| # | Фаза | Решение | Класс | Принцип | Обоснование |
|---|------|---------|-------|---------|-------------|
| 1 | premise | Правило выполнимости — за бэкенд (не фронт-хардкод) | User Challenge | — | оба ревьюера + бэкенд уже имеет по-КА модель из каталога |
| 2 | premise | it.1 = только UX выбора; правило → it.2 | User Challenge | P2/P3 | развязать безопасный win от спорной физики |
| 3 | design | Жест = чип `полоса ×N` | Taste | P5 (явное) | обнаружим, не конфликтует с чек/Shift |
| 4 | design | Grouping sort-aware (рельс в «время», подсветка иначе) | Mechanical | P1 | зебра по строкам врёт вне time-сортировки |
| 5 | design | Чип выбирает только видимые не-BUSY члены | Mechanical | P1 | не выбирать невидимое (доверие счётчику) |
| 6 | eng | StripId над `displaySlots`, memo на `[displaySlots]` | Mechanical | P3 | стабильность id + перф на тысячах строк |
| 7 | eng | ε-сравнение с якорным креном полосы | Mechanical | P1 | анти-дрейф, совпадает с бэкендовым sameRoll |
| 8 | eng | Без бэкенд/DTO-изменений в it.1 | Mechanical | P4 | всё нужное уже в SlotDTO |
| 9 | eng | Карта — без раскраски полос в it.1 | Mechanical | P3/P5 | не расширять scope на модель карты |
+15 -1
View File
@@ -13,7 +13,7 @@
> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C).
_Обновлено: 2026-06-11_ _Обновлено: 2026-06-16_
--- ---
@@ -44,6 +44,7 @@ _Обновлено: 2026-06-11_
- **Главный риск — Kafka:** `KafkaConsumerConfig`/сериализаторы завязаны на `com.fasterxml` ObjectMapper. Перед стартом — спайк (~полдня): умеет ли `spring-kafka` в нашем Boot 4 Jackson 3-сериализаторы. Если нет — Kafka-слой мигрируем отдельно, HTTP/внутренний JSON переводим независимо. - **Главный риск — Kafka:** `KafkaConsumerConfig`/сериализаторы завязаны на `com.fasterxml` ObjectMapper. Перед стартом — спайк (~полдня): умеет ли `spring-kafka` в нашем Boot 4 Jackson 3-сериализаторы. Если нет — Kafka-слой мигрируем отдельно, HTTP/внутренний JSON переводим независимо.
- **Масштаб (src/main):** 67 файлов в 10 сервисах — pcp-dynamic-plan-service 22, pcp-ui-service 16 (выводится из эксплуатации, ср. #40 — можно не мигрировать), pcp-request-service 7, slots-service/pcp-tgu-service по 5, остальные 1–4. **Грубая оценка всей репы — ~6–10 человеко-дней**, основной риск — Kafka. Big-bang не нужен: шаг 1 закрывает причину бага, полную миграцию вести фоново сервис за сервисом. - **Масштаб (src/main):** 67 файлов в 10 сервисах — pcp-dynamic-plan-service 22, pcp-ui-service 16 (выводится из эксплуатации, ср. #40 — можно не мигрировать), pcp-request-service 7, slots-service/pcp-tgu-service по 5, остальные 1–4. **Грубая оценка всей репы — ~6–10 человеко-дней**, основной риск — Kafka. Big-bang не нужен: шаг 1 закрывает причину бага, полную миграцию вести фоново сервис за сервисом.
- [ ] **#45** — Собрать native-образ сервиса (Kotlin/Native, `kompile`) и контейнер на его основе. Цель — быстрый старт и меньший footprint в сравнении с JVM-образом. Уточнить: какой сервис(ы) переводим первым, совместимость с рефлексией/AOT-конфигом (reachability metadata), сборку в CI и базовый образ контейнера. - [ ] **#45** — Собрать native-образ сервиса (Kotlin/Native, `kompile`) и контейнер на его основе. Цель — быстрый старт и меньший footprint в сравнении с JVM-образом. Уточнить: какой сервис(ы) переводим первым, совместимость с рефлексией/AOT-конфигом (reachability metadata), сборку в CI и базовый образ контейнера.
- [ ] **#51** — Распространить правило «межсервисные вызовы только через gateway» на остальные сервисы. Для `slots-service` уже сделано (config-only): добавлен маршрут `satellite-catalog` в `pcp-gateway-service.yaml`, а `settings.request-service`/`earth-grid-service`/`satellite-catalog-service` переведены на `${pcp.services.gateway}/api/pcp-<x>` (код уже строит `$base/<path>`, gateway снимает префикс `StripPrefix=2`). Аналогично проверить и перевести прочие сервисы, которые ходят к чужим API напрямую по `pcp.services.<x>` (кандидаты: mission-planing, tgu, complex-mission, dynamic-plan и т.п.) — для каждого целевого API завести gateway-маршрут `/api/pcp-<x>/**` и репойнтить их `settings.*` на gateway. Заодно решить, нужен ли маршрут для тех целей, что сейчас вызываются прямо, но не проксируются (route-processing, coverage-scheme, stations уже без StripPrefix).
- [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:5012:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene``visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. - [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:5012:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene``visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку.
### Проверка сервисов ### Проверка сервисов
@@ -63,6 +64,18 @@ _Обновлено: 2026-06-11_
- [x] **Выбор заявки на карте (вместо списка)** _(2026-06-11): на вкладке «Слоты» убран выпадающий список заявок — он не масштабируется на тысячи заявок. Базово все заявки интервала отображаются на карте; клик по заявке (или по наложению нескольких — список с раскрытием) открывает плавающую панель-инспектор справа над картой (общий `MapObjectInspector`) с данными заявки и кнопкой «Выбрать». До выбора в тулбаре вместо списка — имя выбранной заявки или «—», а левая панель показывает фильтр «Заявки на интервал» (С/По + «Загрузить»/«Сегодня»/«7 дней»/«30 дней», как на «Карте») и подсказку «Кликните заявку на карте». После выбора заявки появляется кнопка «Сброс» справа над картой — возвращает окно в состояние «заявка не выбрана, слоты не показаны». Файлы: `features/tgu-slots/TguSlotsTab.tsx`, `features/tgu-map-2d/MapObjectInspector.tsx` (опц. `onSelectRequest` → кнопка «Выбрать» в карточке заявки), стили `.tgu-slots-request*`/`.tgu-slots-reset`/`.tgu-slots-inspector`/`.tgu-slots-requests-panel`._ - [x] **Выбор заявки на карте (вместо списка)** _(2026-06-11): на вкладке «Слоты» убран выпадающий список заявок — он не масштабируется на тысячи заявок. Базово все заявки интервала отображаются на карте; клик по заявке (или по наложению нескольких — список с раскрытием) открывает плавающую панель-инспектор справа над картой (общий `MapObjectInspector`) с данными заявки и кнопкой «Выбрать». До выбора в тулбаре вместо списка — имя выбранной заявки или «—», а левая панель показывает фильтр «Заявки на интервал» (С/По + «Загрузить»/«Сегодня»/«7 дней»/«30 дней», как на «Карте») и подсказку «Кликните заявку на карте». После выбора заявки появляется кнопка «Сброс» справа над картой — возвращает окно в состояние «заявка не выбрана, слоты не показаны». Файлы: `features/tgu-slots/TguSlotsTab.tsx`, `features/tgu-map-2d/MapObjectInspector.tsx` (опц. `onSelectRequest` → кнопка «Выбрать» в карточке заявки), стили `.tgu-slots-request*`/`.tgu-slots-reset`/`.tgu-slots-inspector`/`.tgu-slots-requests-panel`._
- [x] **Бронь не отражалась в UI + явная подсветка строки** _(2026-06-11): «не работает кнопка бронь» — диагностировано: POST `booking/request` бронирует корректно и в ответе `BookedSlotDTO` несёт верный `cycle`, но `GET booking/by-request/{id}` отдаёт **базовый** слот в другом cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), из-за чего ключ `sat:slotNum:cycle` не совпадал с рассчитанной строкой → бейдж «бронь» не появлялся, на карте — лишний полигон. Фронт-обход: после брони отмечаем забронированными ровно те рассчитанные слоты (с контуром), что вернул POST; by-request на горячем пути не перечитываем; cancel чистит локально. Заодно подсветка активной строки слота сделана явной (фон + левый акцентный бар + bold). Root-fix (на бэке, отдельно): `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime`/перечисление в `request-cover`) — иначе пред-существующие брони не коррелируют с расчётом. Файлы: `features/tgu-slots/TguSlotsTab.tsx`, стили `.tgu-slots-table tbody tr.is-active`._ - [x] **Бронь не отражалась в UI + явная подсветка строки** _(2026-06-11): «не работает кнопка бронь» — диагностировано: POST `booking/request` бронирует корректно и в ответе `BookedSlotDTO` несёт верный `cycle`, но `GET booking/by-request/{id}` отдаёт **базовый** слот в другом cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), из-за чего ключ `sat:slotNum:cycle` не совпадал с рассчитанной строкой → бейдж «бронь» не появлялся, на карте — лишний полигон. Фронт-обход: после брони отмечаем забронированными ровно те рассчитанные слоты (с контуром), что вернул POST; by-request на горячем пути не перечитываем; cancel чистит локально. Заодно подсветка активной строки слота сделана явной (фон + левый акцентный бар + bold). Root-fix (на бэке, отдельно): `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime`/перечисление в `request-cover`) — иначе пред-существующие брони не коррелируют с расчётом. Файлы: `features/tgu-slots/TguSlotsTab.tsx`, стили `.tgu-slots-table tbody tr.is-active`._
- [ ] **#43** — Уточнение статуса слота. На вкладке «Слоты» уже показывается базовый статус слота (`state`: свободен/бронь/занят) и бейдж брони по заявке. Осталось: подтягивать/обновлять актуальный статус занятости по **заложенному** плану (слоты занимаются по `LAID_IN`, а не по `ACCEPT`, ср. #10) и согласовать его источник со slots-service (real-time/после закладки), а также прокинуть статус на вкладки «Карта»/«Редактор» (#3). - [ ] **#43** — Уточнение статуса слота. На вкладке «Слоты» уже показывается базовый статус слота (`state`: свободен/бронь/занят) и бейдж брони по заявке. Осталось: подтягивать/обновлять актуальный статус занятости по **заложенному** плану (слоты занимаются по `LAID_IN`, а не по `ACCEPT`, ср. #10) и согласовать его источник со slots-service (real-time/после закладки), а также прокинуть статус на вкладки «Карта»/«Редактор» (#3).
- [x] **#48** — (баг, бэкенд) Слоты бронируются «не на те даты». _Исправлено 2026-06-15:_ `BookedSlotEntity.toViewDTO()` (единственный билдер за `GET …/booking/by-request/{id}`) отдавал `tn/tk/revolution` из **базового** cycle-фрейма без сдвига, тогда как все остальные пути (`toDetails`, `allByInterval``shiftedToCycle`, `resolveBookedSlotStartTime`) сдвигают на `cycle*durationCalc`. Сдвиг перенесён в `SlotService.bookedByReqId` (новый `toShiftedViewDTO(satellite)`, КА резолвится из каталога; нет в каталоге → базовый фрейм без падения), базовый билдер в сущности удалён. Регрессия: `SlotServiceCancelTest` (cycle=2 → +32 сут / +486 витков; fallback без КА). POST `booking/request` сохраняет только ключ (`bookedSlotId/sat/slotNum/cycle`), tn/tk не пересчитывает — корректно. <br>_Исходный отчёт:_ При бронировании набора слотов фактическое время брони расходится с тем, что оператор видел/выбирал в таблице расчёта. Корень — та же рассинхронизация cycle-фрейма, что разобрана в задаче «Бронь не отражалась в UI» (#42): `GET …/booking/by-request/{id}` отдаёт слот в **базовом** cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), тогда как `request-cover` перечисляет слоты со сдвигом — поэтому время/сутки брони, восстановленные из by-request, не совпадают с рассчитанным слотом. Фронт сейчас обходит это, отмечая бронь по ответу POST (там cycle верный) и не перечитывая by-request на горячем пути, но это лишь маскирует симптом: пред-существующие брони и брони после перезагрузки страницы по-прежнему могут показываться/трактоваться с неверной датой. Root-fix на бэке: `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime` / перечисление в `request-cover`), чтобы время брони было согласовано с расчётом. Проверить также, что POST `booking/request` сохраняет именно те tn/tk, что соответствуют выбранному слоту, а не пересчитывает их в базовом фрейме.
- [ ] **#49** — (баг, фронт) После снятия брони статусы «недоступен» не обновляются. После снятия бронирования со слотов соседние слоты, помеченные «недоступен» (`BUSY`/band — заблокированы из-за пересечения с бронью или длины цепочки), остаются недоступными, пока оператор вручную не нажмёт «Показать все слоты». Причина: снятие брони чистит только локальные отметки брони, а «недоступность» вычисляется бэком при перечислении (`request-cover`/`all=true` с учётом соседних броней) и без повторного запроса не пересчитывается. Нужно после успешного снятия инвалидировать/перечитать затронутые слоты (или пересчитать `state` локально), чтобы освободившиеся «недоступен» сразу становились «свободен» без ручного «Показать все слоты». Файлы: `features/tgu-slots/TguSlotsTab.tsx` (хэндлеры снятия `runCancel`/`performCancelAll`), `api/slotsApi.ts`.
- [ ] **#50** — Ручное бронирование: слой поддержки решений оператора. Маршрут ручного бронирования уже есть (#41/#42), но оператор проживает его вслепую: нет сигнала «достаточно ли покрытия», отбор галочками не масштабируется на тысячи слотов, конфликты (BUSY) не объяснены. Кладём поверх маршрута информационный слой поддержки решений — «достаточно» по-прежнему судит оператор сам (без гейтов, в линии WAITING_DECISION/REJECTED). Подход — frontend-геометрия (union/intersect контуров на клиенте, без раундтрипов; точность вторична, раз судит человек). Дизайн-док: `~/.gstack/projects/missionis-observatio-terrae/dasolovyev-feat-tgu-slots-filters-design-20260616-122013.md`. Прогоняется по цепочке `/plan-ceo-review``/plan-design-review``/plan-eng-review` → код → `/review``/qa`.
- [ ] **M1 — ядро покрытия (frontend, без бэка):** новый `features/tgu-slots/coverage.ts` (площадь заявки, покрытие выбранного набора; юнит-тесты по образцу `slotFilters.test.ts`), `CoveragePanel` в шапке списка (живой % + бар + цель заявки справочно), слой `coverage` на карте (закрашенный накопленный след union контуров, заклипанный полигоном заявки). Лечит «достаточность». Новая npm-зависимость геометрии (`polygon-clipping` + `@turf/area` или `@turf`-subset — решить на M1). Пересчёт дебаунсом (~100 мс), union только по выбранному набору.
- [ ] **M2 — эргономика (frontend):** сортировка списка «по вкладу в покрытие» (жадный маргинальный прирост, по кнопке) + по крену/Солнцу. Лечит объём — набор собирается кратно меньшим числом кликов.
- [ ] **M3 — конфликты (backend + frontend):** обогатить BUSY-слот держателем (`bookedByReqId` уже есть в slots-service + имя/важность заявки) в `SlotDTO`/`reqCover(all=true)`, бейдж конфликта `занят: <заявка> (важн. K)` в строке. Лечит осознанное разруливание. Единственное изменение бэка.
> Задачи #52–#53 — по результатам `/review` ветки `feat/tgu-slots-filters` (2026-06-16). Замечания информационные, лендингу не мешают.
- [ ] **#52** — (производительность, бэкенд) `EarthService.requestHolders` грузит весь каталог заявок на каждый `request-cover` с `all=true`. Чтобы разрешить имя/важность горстки заявок-держателей BUSY-слотов (M3), метод постранично выкачивает **все** заявки (`for page in 1 until firstPage.totalPages`) и фильтрует их в памяти — O(всех заявок) по сети и памяти на каждый обзор с BUSY-держателями. Сейчас деградация безопасна (`runCatching → emptyMap`: при недоступности request-service бейдж показывает только id), но операция будет линейно замедляться с ростом БД заявок. Решение: добавить в `pcp-request-service` выборку заявок **по списку id** (batch-by-ids) и звать её из `requestHolders` вместо полного обхода (либо кэш summary). Файлы: `services/slots-service/.../service/EarthService.kt`, эндпоинт request-service.
- [ ] **#53** — (фронт, низкий приоритет/низкая уверенность) `redrawToken` карты на вкладке «Слоты» — сумма длин. В `TguSlotsTab.tsx` токен перерисовки canvas = `filteredSlots.length + bookedSlots.length + selectedKeys.size + (coverageOn ? 1 : 0)`. Разные состояния с одинаковой суммой теоретически не вызовут перерисовку (риск устаревшей картинки на карте). Реалистичную коллизию воспроизвести не удалось (выбор меняет `selectedKeys.size`, след покрытия его отслеживает), поэтому уверенность низкая — занесено для протокола. При желании заменить сумму на устойчивую сигнатуру (напр. хэш ключей видимого набора) или счётчик «версии сцены», инкрементируемый в `scene`-`useMemo`. Файл: `features/tgu-slots/TguSlotsTab.tsx`.
### Аутентификация и роли ### Аутентификация и роли
- [ ] **#46** — Вывести роли на морду и связать их с аутентификацией. На фронте показывать роль(и) пользователя и разграничивать доступ к действиям/вкладкам по роли; источник ролей — аутентификация (токен/headless-auth, `@nstart/auth`). Уточнить: какие роли есть, где они приходят (claims токена / профиль), как маппятся на UI-разрешения (что скрывать/блокировать), и нужен ли серверный enforcement на стороне сервисов, а не только UI-гейтинг. - [ ] **#46** — Вывести роли на морду и связать их с аутентификацией. На фронте показывать роль(и) пользователя и разграничивать доступ к действиям/вкладкам по роли; источник ролей — аутентификация (токен/headless-auth, `@nstart/auth`). Уточнить: какие роли есть, где они приходят (claims токена / профиль), как маппятся на UI-разрешения (что скрывать/блокировать), и нужен ли серверный enforcement на стороне сервисов, а не только UI-гейтинг.
@@ -101,6 +114,7 @@ _Источник и рекомендации: [аудит-сервис-зада
--- ---
## Выполнено ## Выполнено
- [x] **Чистки по `/review` `feat/tgu-slots-filters`** _(2026-06-16): по результатам код-ревью ветки применены две безопасные правки. (1) Удалён мёртвый `cancelRequestBooking` в `api/slotsApi.ts` — «Снять всё» перешло на fetch-by-request + батчи `cancelBookedSlots` ради прогресса, вызовов во фронте не осталось (бэкенд-эндпоинт `DELETE /booking/request` цел; совпадения в legacy `pcp-ui-service` не затронуты). (2) `SlotDTO` (`pcp-types-lib`) переведён в `data class`, ручной хелпер `withHolders` (перечисление всех полей) заменён на `slot.copy(holders = …)` в `SlotService.enrichHolders` — убраны грабли «новое поле молча теряется при ручном копировании»; от `SlotDTO` никто не наследуется, 17 мест конструирования совместимы. Kotlin-сборку локально не прогоняли (ограничения реестра @nstart) — проверить при сборке. Остальные находки ревью занесены в #52/#53._
- [x] **#34** — (RS6) Проброс требуемого процента покрытия из API + разведение терминов. _Готово: в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` (0 < x ≤ 100, по умолчанию 100), оно пробрасывается в сущность и используется как порог COMPLETED; захардкоженная константа убрана. В KDoc/коде разведены «прогресс покрытия» (`coverageRequiredPercent`) и «важность заявки» (`importance`, карта приоритетов ячеек). Тесты: `RequestServiceTest`, `RequestApiDtoContractTest`._ - [x] **#34** — (RS6) Проброс требуемого процента покрытия из API + разведение терминов. _Готово: в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` (0 < x ≤ 100, по умолчанию 100), оно пробрасывается в сущность и используется как порог COMPLETED; захардкоженная константа убрана. В KDoc/коде разведены «прогресс покрытия» (`coverageRequiredPercent`) и «важность заявки» (`importance`, карта приоритетов ячеек). Тесты: `RequestServiceTest`, `RequestApiDtoContractTest`._
- [x] **#31** — (RS3) DLQ и error-handler для Kafka-листенера маршрутов. _Готово: `KafkaConsumerConfig` — `DefaultErrorHandler` + `DeadLetterPublishingRecoverer` в топик `pcp.route.georeference-dlq.v1`, экспоненциальный bounded retry, контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) → DLQ без ретраев, диагностические заголовки. Настройки `app.kafka.consumer.route.*`, активируется при заданном `spring.kafka.bootstrap-servers`. Тест `KafkaConsumerConfigTest`._ - [x] **#31** — (RS3) DLQ и error-handler для Kafka-листенера маршрутов. _Готово: `KafkaConsumerConfig` — `DefaultErrorHandler` + `DeadLetterPublishingRecoverer` в топик `pcp.route.georeference-dlq.v1`, экспоненциальный bounded retry, контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) → DLQ без ретраев, диагностические заголовки. Настройки `app.kafka.consumer.route.*`, активируется при заданном `spring.kafka.bootstrap-servers`. Тест `KafkaConsumerConfigTest`._
- [x] **#35** — (RS7) Уведомление **geoportal** о финале заявки. _Готово: единый топик `pcp.request.status.v1` (заменил orphan `pcp.request.completed.v1`) с терминальным статусом в payload (`COMPLETED`/`EXPIRED`). Эмиссия из двух мест через тот же transactional outbox: `RouteMatchingService` (по покрытию) и `RequestExpiryService` (по сроку, #29). Сервис `RequestCompletedOutboxService``RequestStatusOutboxService`, eventType `REQUEST_TERMINAL_STATUS`. Outbox получил backoff: колонки `attempts`/`next_attempt_at`, удвоение `initial→max`, предел `max-attempts` (терминальный FAILED). Конфиг `pcp.outbox.*`. Тесты: `OutboxPublisherServiceTest`, `OutboxEventRepositoryJpaTest`, `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RouteMatchingServiceTest`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._ - [x] **#35** — (RS7) Уведомление **geoportal** о финале заявки. _Готово: единый топик `pcp.request.status.v1` (заменил orphan `pcp.request.completed.v1`) с терминальным статусом в payload (`COMPLETED`/`EXPIRED`). Эмиссия из двух мест через тот же transactional outbox: `RouteMatchingService` (по покрытию) и `RequestExpiryService` (по сроку, #29). Сервис `RequestCompletedOutboxService``RequestStatusOutboxService`, eventType `REQUEST_TERMINAL_STATUS`. Outbox получил backoff: колонки `attempts`/`next_attempt_at`, удвоение `initial→max`, предел `max-attempts` (терминальный FAILED). Конфиг `pcp.outbox.*`. Тесты: `OutboxPublisherServiceTest`, `OutboxEventRepositoryJpaTest`, `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RouteMatchingServiceTest`. Док: интеракции обновлены. Известный мелкий зазор: заявка, созданная уже просроченной, события не шлёт (вырожденный кейс)._
@@ -0,0 +1,28 @@
package space.nstart.pcp.pcp_types_lib.dto.requests.slots
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime
/**
* Забронированный слот для отображения и снятия брони (GET /api/slots/booking/by-request/{id}).
*
* В отличие от [SlotDTO] несёт [bookedSlotId] — он нужен фронту, чтобы снимать бронь по списку id
* (DELETE /api/slots/booking/batch). В отличие от [BookedSlotDTO] несёт контур и координаты — нужны
* карте. Критично: [cycle] — это цикл БРОНИ (BookedSlotEntity.cycle), а не базовый slot.cycle,
* иначе ключ sat:slotNum:cycle на фронте не совпадёт с ответом POST booking/request.
*/
class BookedSlotViewDTO(
val bookedSlotId : Long = 0,
val cycle : Long = 0,
val satelliteId : Long = 0,
val tn : LocalDateTime,
val tk : LocalDateTime,
val roll : Double,
val contour : String,
val revolution : Long = 0,
val revolutionSign: RevolutionSign = RevolutionSign.ASC,
val slotNumber : Long = 0,
val state : SlotStatus = SlotStatus.BOOKED,
val latitude : Double = 0.0,
val longitude : Double = 0.0,
)
@@ -3,7 +3,7 @@ package space.nstart.pcp.pcp_types_lib.dto.requests.slots
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import java.time.LocalDateTime import java.time.LocalDateTime
class SlotDTO( data class SlotDTO(
val cycle : Long = 0, val cycle : Long = 0,
val satelliteId : Long = 0, val satelliteId : Long = 0,
val tn : LocalDateTime, val tn : LocalDateTime,
@@ -16,4 +16,7 @@ class SlotDTO(
val state : SlotStatus = SlotStatus.AVAILABLE, val state : SlotStatus = SlotStatus.AVAILABLE,
val latitude : Double = 0.0, val latitude : Double = 0.0,
val longitude : Double = 0.0, val longitude : Double = 0.0,
// Держатели слота — заполняются только для BUSY-слотов в обзорном request-cover (all=true).
// null/пусто для AVAILABLE/BOOKED и для всех прочих путей (обратно совместимо).
val holders : List<SlotHolderDTO>? = null,
) )
@@ -0,0 +1,12 @@
package space.nstart.pcp.pcp_types_lib.dto.requests.slots
/**
* Держатель занятого (BUSY) слота — заявка, чья бронь блокирует слот на этом проходе КА.
* Заполняется только в обзорном ответе request-cover (all=true); name/importance могут быть null,
* если request-service недоступен (деградация — бейдж показывает только идентификатор заявки).
*/
data class SlotHolderDTO(
val requestId: String,
val name: String? = null,
val importance: Double? = null,
)
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env bash
#
# Собирает (опционально) и складывает bootJar всех Spring-сервисов в одну папку
# serviceAllBuild/ в корне проекта, рядом кладёт config-repo и хелпер запуска run.sh.
#
# Использование:
# ./serviceAllBuild.sh # только собрать jar-ы в одно место (сервисы уже собраны)
# ./serviceAllBuild.sh --build # сначала ./gradlew bootJar, потом собрать в одно место
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT="$ROOT/serviceAllBuild"
SERVICES_DIR="$ROOT/services"
DO_BUILD=0
[[ "${1:-}" == "--build" ]] && DO_BUILD=1
if [[ $DO_BUILD -eq 1 ]]; then
echo "==> Сборка bootJar всех сервисов (./gradlew bootJar)..."
"$ROOT/gradlew" bootJar
fi
mkdir -p "$OUT"
echo "==> Сбор jar-ов в $OUT"
count=0
for dir in "$SERVICES_DIR"/*/; do
name="$(basename "$dir")"
libs="$dir/build/libs"
[[ -d "$libs" ]] || { echo " - $name: нет build/libs (не Spring-сервис или не собран), пропуск"; continue; }
# bootJar = единственный *.jar без суффикса -plain.jar; берём самый свежий по имени
jar="$(find "$libs" -maxdepth 1 -name '*.jar' ! -name '*-plain.jar' | sort | tail -n1)"
[[ -n "$jar" ]] || { echo " - $name: bootJar не найден, пропуск"; continue; }
cp -f "$jar" "$OUT/$name.jar"
echo " + $name.jar <- $(basename "$jar")"
count=$((count + 1))
done
echo "==> Копирую config-repo в $OUT/config-repo (самодостаточная папка)"
rm -rf "$OUT/config-repo"
cp -r "$ROOT/config-repo" "$OUT/config-repo"
echo "==> Генерирую $OUT/run.sh"
cat > "$OUT/run.sh" <<'RUNEOF'
#!/usr/bin/env bash
#
# Запуск одного сервиса из собранной папки через java -jar.
#
# ./run.sh # список доступных сервисов
# ./run.sh spring-cloud-config-server # ПЕРВЫМ всегда поднимаем config server
# ./run.sh pcp-tgu-service # затем нужные сервисы (в отдельных терминалах)
#
# Память не ограничена. Задать лимит при необходимости:
# JAVA_OPTS="-Xmx384m" ./run.sh pcp-tgu-service
#
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_REPO="$HERE/config-repo"
JAVA_OPTS="${JAVA_OPTS:-}"
name="${1:-}"
if [[ -z "$name" ]]; then
echo "Использование: ./run.sh <имя-сервиса>"
echo
echo "Сначала запусти config server: ./run.sh spring-cloud-config-server"
echo
echo "Доступные сервисы:"
for j in "$HERE"/*.jar; do basename "$j" .jar; done | sed 's/^/ /'
exit 1
fi
jar="$HERE/$name.jar"
[[ -f "$jar" ]] || { echo "Нет файла: $jar"; exit 1; }
if [[ "$name" == "spring-cloud-config-server" ]]; then
# config server в профиле local работает в native-режиме и читает yaml отсюда
export SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-local}"
export CONFIG_NATIVE_SEARCH_LOCATIONS="${CONFIG_NATIVE_SEARCH_LOCATIONS:-optional:file:$CONFIG_REPO}"
echo "==> config-repo: $CONFIG_REPO"
fi
echo "==> java $JAVA_OPTS -jar $name.jar"
exec java $JAVA_OPTS -jar "$jar"
RUNEOF
chmod +x "$OUT/run.sh"
echo "==> Генерирую $OUT/up.sh"
cat > "$OUT/up.sh" <<'UPEOF'
#!/usr/bin/env bash
#
# Поднять сразу несколько сервисов в фоне (config server — всегда первым).
#
# ./up.sh # запустить ВСЕ сервисы
# ./up.sh pcp-tgu-service slots-service # config server + перечисленные
#
# Имя сервиса = имя jar-файла без .jar. Логи: logs/<name>.log, pid: logs/<name>.pid.
# Погасить всё: ./down.sh
#
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_REPO="$HERE/config-repo"
LOGS="$HERE/logs"
mkdir -p "$LOGS"
JAVA_OPTS="${JAVA_OPTS:-}"
CFG="spring-cloud-config-server"
running() { local p="$LOGS/$1.pid"; [[ -f "$p" ]] && kill -0 "$(cat "$p")" 2>/dev/null; }
start_one() {
local name="$1" jar="$HERE/$1.jar"
[[ -f "$jar" ]] || { echo " ! нет $name.jar, пропуск"; return; }
if running "$name"; then echo " = $name уже запущен (pid $(cat "$LOGS/$name.pid"))"; return; fi
if [[ "$name" == "$CFG" ]]; then
SPRING_PROFILES_ACTIVE="${SPRING_PROFILES_ACTIVE:-local}" \
CONFIG_NATIVE_SEARCH_LOCATIONS="${CONFIG_NATIVE_SEARCH_LOCATIONS:-optional:file:$CONFIG_REPO}" \
nohup java $JAVA_OPTS -jar "$jar" > "$LOGS/$name.log" 2>&1 &
else
nohup java $JAVA_OPTS -jar "$jar" > "$LOGS/$name.log" 2>&1 &
fi
echo $! > "$LOGS/$name.pid"
echo " + $name (pid $!) -> logs/$name.log"
}
wait_config_server() {
printf "==> жду config server на :8888"
for _ in $(seq 1 60); do
if curl -sf http://localhost:8888/actuator/health >/dev/null 2>&1; then echo " — UP"; return 0; fi
printf "."; sleep 2
done
echo " — не дождался; смотри logs/$CFG.log"; exit 1
}
targets=("$@")
if [[ ${#targets[@]} -eq 0 ]]; then
for j in "$HERE"/*.jar; do targets+=("$(basename "$j" .jar)"); done
fi
echo "==> config server"
start_one "$CFG"
running "$CFG" && wait_config_server
echo "==> остальные сервисы"
for name in "${targets[@]}"; do
[[ "$name" == "$CFG" ]] && continue
start_one "$name"
done
echo "Готово. Логи: tail -f logs/<name>.log Стоп: ./down.sh"
UPEOF
chmod +x "$OUT/up.sh"
echo "==> Генерирую $OUT/down.sh"
cat > "$OUT/down.sh" <<'DOWNEOF'
#!/usr/bin/env bash
#
# Погасить сервисы, запущенные через up.sh.
# ./down.sh # погасить все
# ./down.sh pcp-tgu-service ... # погасить только перечисленные
#
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOGS="$HERE/logs"
names=("$@")
if [[ ${#names[@]} -eq 0 ]]; then
shopt -s nullglob
for p in "$LOGS"/*.pid; do names+=("$(basename "$p" .pid)"); done
fi
for name in "${names[@]}"; do
p="$LOGS/$name.pid"
[[ -f "$p" ]] || { echo " - $name: pid-файла нет"; continue; }
pid="$(cat "$p")"
if kill -0 "$pid" 2>/dev/null; then kill "$pid"; echo " x $name (pid $pid) остановлен"; else echo " - $name (pid $pid) уже не жив"; fi
rm -f "$p"
done
DOWNEOF
chmod +x "$OUT/down.sh"
echo "==> Генерирую $OUT/README.md"
cat > "$OUT/README.md" <<'MDEOF'
# serviceAllBuild — локальный запуск сервисов через `java -jar`
Папку формирует скрипт `../serviceAllBuild.sh`. Здесь лежат bootJar всех
Spring-сервисов, копия `config-repo` и хелпер `run.sh`.
## Куда класть config-repo
`spring-cloud-config-server` в профиле `local` работает в **native**-режиме и
читает yaml-файлы из локальной папки (а не из git). Папка `config-repo`
**уже скопирована сюда** скриптом сборки, и `run.sh` сам передаёт в config server
абсолютный путь к ней через `CONFIG_NATIVE_SEARCH_LOCATIONS`.
То есть ничего вручную раскладывать не нужно — достаточно запускать config server
через `./run.sh spring-cloud-config-server`. Если правишь конфиги — правь
`serviceAllBuild/config-repo/*.yaml` (или пересобери скриптом, чтобы подтянуть
изменения из корневого `config-repo`).
## Перед запуском: инфраструктура
Сервисам нужны (см. `config-repo/application-local.yaml`):
- PostgreSQL на `localhost:5432`
- Kafka на `localhost:29092`
Подними их (например, через docker-compose проекта) и создай БД скриптами из корня
(`create-service-dbs.sh` / `create_pcp_databases.sh`).
## Порядок запуска
Config server слушает порт **8888** — остальные сервисы ходят за конфигом именно
туда, поэтому он всегда первый. Имя сервиса = имя jar-файла без `.jar`.
### Несколько сервисов сразу (в фоне)
```bash
./up.sh # ВСЕ сервисы
./up.sh pcp-tgu-service slots-service # config server + перечисленные
```
`up.sh` сам поднимает config server первым, ждёт его готовности на :8888, затем
запускает остальные в фоне. Логи — в `logs/<name>.log`, pid — в `logs/<name>.pid`.
```bash
tail -f logs/pcp-tgu-service.log # смотреть лог
./down.sh # погасить все
./down.sh slots-service # погасить один
```
### По одному (на переднем плане, каждый в своём терминале)
```bash
# терминал 1
./run.sh spring-cloud-config-server
# терминал 2 (после того как config server поднялся)
./run.sh pcp-tgu-service
```
Порты сервисов заданы в `config-repo/application-local.yaml` (`pcp.ports.*`):
tgu=7011, slots=7006, ui=7008, gateway=7080, request=7005, mission=7010 и т.д.
## Память
Лимит heap не задан — JVM сама возьмёт до 25% физической RAM на каждый процесс.
Запускай только то, что нужно прямо сейчас. При нехватке памяти можно ограничить
конкретный сервис:
```bash
JAVA_OPTS="-Xmx384m" ./run.sh pcp-mission-planing-service
```
MDEOF
echo
echo "Готово: $count сервисов в $OUT"
echo "Запуск: cd serviceAllBuild && ./run.sh spring-cloud-config-server"
+1
View File
@@ -0,0 +1 @@
requests.json
+1
View File
@@ -13,6 +13,7 @@
"@nstart/ui": "^0.4.0", "@nstart/ui": "^0.4.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"@tanstack/react-virtual": "^3.14.2",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { bookRequestSlots, fetchRequestSlots } from "./slotsApi"; import {
bookRequestSlots,
cancelBookedSlots,
fetchBookedSlotsByRequest,
fetchRequestSlots,
} from "./slotsApi";
function jsonResponse(body: unknown): Response { function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { return new Response(JSON.stringify(body), {
@@ -8,6 +13,10 @@ function jsonResponse(body: unknown): Response {
}); });
} }
function textResponse(body: string): Response {
return new Response(body, { status: 200 });
}
const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" }; const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" };
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
@@ -27,6 +36,18 @@ describe("fetchRequestSlots", () => {
expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy"); expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy");
}); });
it("прокидывает all=true в query (показать и занятые бронью)", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots({ ...baseParams, all: true });
expect(String(fetchMock.mock.calls[0][0])).toContain("all=true");
});
it("не добавляет all, если не задан", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots(baseParams);
expect(String(fetchMock.mock.calls[0][0])).not.toContain("all=");
});
it("пробрасывает AbortSignal в fetch", async () => { it("пробрасывает AbortSignal в fetch", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -61,3 +82,59 @@ describe("bookRequestSlots", () => {
expect(res).toHaveLength(1); expect(res).toHaveLength(1);
}); });
}); });
describe("cancelBookedSlots", () => {
it("DELETE booking/batch с requestId и списком ids в query, возвращает count", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("3"));
const res = await cancelBookedSlots("r1", [11, 12, 13]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("booking/batch");
expect(String(url)).toContain("requestId=r1");
expect(String(url)).toContain("ids=11");
expect(String(url)).toContain("ids=12");
expect(String(url)).toContain("ids=13");
expect(init?.method).toBe("DELETE");
expect(res).toBe(3);
});
it("пустой список ids — не зовёт fetch и возвращает 0", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("0"));
const res = await cancelBookedSlots("r1", []);
expect(fetchMock).not.toHaveBeenCalled();
expect(res).toBe(0);
});
it("на не-ok бросает с текстом ошибки сервера", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
await expect(cancelBookedSlots("r1", [1])).rejects.toThrow("boom");
});
});
describe("fetchBookedSlotsByRequest", () => {
it("возвращает брони с bookedSlotId и контуром", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
jsonResponse([
{
bookedSlotId: 42,
cycle: 3,
satelliteId: 12,
slotNumber: 340,
tn: "2026-01-01T10:00:00",
tk: "2026-01-01T10:01:00",
roll: 0,
contour: "POLYGON ((0 0, 1 0, 1 1, 0 0))",
revolution: 1,
revolutionSign: "ASC",
state: "BOOKED",
latitude: 0,
longitude: 0,
},
]),
);
const res = await fetchBookedSlotsByRequest("r1");
expect(res).toHaveLength(1);
expect(res[0].bookedSlotId).toBe(42);
expect(res[0].cycle).toBe(3);
expect(res[0].contour).toContain("POLYGON");
});
});
+26 -10
View File
@@ -1,5 +1,6 @@
import type { import type {
BookedSlotDTO, BookedSlotDTO,
BookedSlotView,
RequestSlotsParams, RequestSlotsParams,
SlotBookingRequest, SlotBookingRequest,
SlotDTO, SlotDTO,
@@ -45,6 +46,7 @@ export async function fetchRequestSlots(
if (params.cov != null) query.set('cov', String(params.cov)) if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun)) if (params.sun != null) query.set('sun', String(params.sun))
if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy) if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy)
if (params.all != null) query.set('all', String(params.all))
params.satellites?.forEach((id) => query.append('satellites', String(id))) params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, { const response = await apiFetch(`${BASE}/request-cover?${query}`, {
@@ -84,16 +86,29 @@ export async function bookRequestSlots(
} }
/** /**
* Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request). * Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE
* /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий
* (runChunked), как при бронировании. Список id передаётся в query (?ids=1&ids=2), а не в теле —
* тело у DELETE через gateway ненадёжно. requestId обязателен: снятие request-aware, чтобы
* шаренный между заявками слот не сносился у других.
* @param requestId Идентификатор заявки. * @param requestId Идентификатор заявки.
* @param bookedSlotIds Идентификаторы броней (bookedSlotId) одного чанка.
* *
* @returns Сколько броней снято. * @returns Сколько броней снято в этом чанке.
*/ */
export async function cancelRequestBooking(requestId: string): Promise<number> { export async function cancelBookedSlots(
const response = await apiFetch( requestId: string,
`${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`, bookedSlotIds: number[],
{ method: 'DELETE', headers: { Accept: 'application/json' } }, ): Promise<number> {
) if (bookedSlotIds.length === 0) return 0
const query = new URLSearchParams()
query.set('requestId', requestId)
bookedSlotIds.forEach((id) => query.append('ids', String(id)))
const response = await apiFetch(`${BASE}/booking/batch?${query}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
})
if (!response.ok) { if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось снять бронь')) throw new Error(await readErrorMessage(response, 'Не удалось снять бронь'))
@@ -107,9 +122,10 @@ export async function cancelRequestBooking(requestId: string): Promise<number> {
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}). * Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
* @param requestId Идентификатор заявки. * @param requestId Идентификатор заявки.
* *
* @returns Список забронированных слотов с контурами. * @returns Список забронированных слотов с контурами и bookedSlotId (для снятия по id);
* cycle здесь — цикл брони, поэтому slotKey совпадает с ответом POST booking/request.
*/ */
export async function fetchBookedSlotsByRequest(requestId: string): Promise<SlotDTO[]> { export async function fetchBookedSlotsByRequest(requestId: string): Promise<BookedSlotView[]> {
const response = await apiFetch( const response = await apiFetch(
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`, `${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
{ headers: { Accept: 'application/json' } }, { headers: { Accept: 'application/json' } },
@@ -119,5 +135,5 @@ export async function fetchBookedSlotsByRequest(requestId: string): Promise<Slot
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки')) throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
} }
return response.json() as Promise<SlotDTO[]> return response.json() as Promise<BookedSlotView[]>
} }
@@ -42,21 +42,34 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1; return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
}; };
const makeHandlers = (isFrom: boolean) => ({ // Какой ползунок реально тянем. При наложении (fromIndex === toIndex) хват попадает на верхний
// (to), но на правом краю он заперт fromIndex и не может уйти влево — поэтому при наложении
// выбираем ползунок по направлению движения: влево → from (открыть диапазон), вправо → to.
const dragRef = useRef<"from" | "to" | null>(null);
const makeHandlers = (which: "from" | "to") => ({
onPointerDown(e: React.PointerEvent<HTMLDivElement>) { onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.stopPropagation(); e.stopPropagation();
dragRef.current = which;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}, },
onPointerMove(e: React.PointerEvent<HTMLDivElement>) { onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return; if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
const idx = indexAt(e.clientX); const idx = indexAt(e.clientX);
if (isFrom) { let active = dragRef.current;
if (fromIndex === toIndex) {
if (idx < fromIndex) active = "from";
else if (idx > toIndex) active = "to";
dragRef.current = active;
}
if (active === "from") {
onFromChange(Math.max(1, Math.min(idx, toIndex))); onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else { } else {
onToChange(Math.min(n, Math.max(idx, fromIndex))); onToChange(Math.min(n, Math.max(idx, fromIndex)));
} }
}, },
onPointerUp(e: React.PointerEvent<HTMLDivElement>) { onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
dragRef.current = null;
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
}, },
}); });
@@ -111,13 +124,13 @@ export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onTo
<div <div
className="map-pass-selector__thumb" className="map-pass-selector__thumb"
style={{ left: `${fromPct}%` }} style={{ left: `${fromPct}%` }}
{...makeHandlers(true)} {...makeHandlers("from")}
/> />
{/* To handle */} {/* To handle */}
<div <div
className="map-pass-selector__thumb" className="map-pass-selector__thumb"
style={{ left: `${toPct}%` }} style={{ left: `${toPct}%` }}
{...makeHandlers(false)} {...makeHandlers("to")}
/> />
</div> </div>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan"; import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawCoverage } from "./canvas/drawCoverage";
import { drawHeatmap } from "./canvas/drawHeatmap"; import { drawHeatmap } from "./canvas/drawHeatmap";
import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests"; import { drawRequests } from "./canvas/drawRequests";
@@ -26,6 +27,8 @@ type Tgu2DMapViewProps = {
selectedObjectId?: string; selectedObjectId?: string;
/** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */ /** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */
markedObjectIds?: ReadonlySet<string>; markedObjectIds?: ReadonlySet<string>;
/** Приглушённые объекты — напр. слоты, конфликтующие с текущим выбором (подсказка «недоступно»). */
dimmedObjectIds?: ReadonlySet<string>;
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint; focusPoint?: GeoPoint;
focusToken?: number; focusToken?: number;
@@ -37,6 +40,16 @@ type Tgu2DMapViewProps = {
enableObjectChooser?: boolean; enableObjectChooser?: boolean;
/** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */
onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void; onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void;
/**
* Жест «выбор полосы» мышью: Shift+перетаскивание по слотам. Карта остаётся «глупой» — отдаёт
* наверх ключи слотов под курсором (id вида `slot:${key}`), а группировку в полосу и диапазон
* считает владелец выбора (TguSlotsTab). onStripAnchor — Shift+нажатие на слот (начало жеста),
* onStripDrag — движение (текущие слоты под курсором), onStripCommit — отпускание (конец жеста).
* Если жест не задан или Shift зажат над пустым местом — работает обычная панорама.
*/
onStripAnchor?: (slotKeysUnderCursor: string[]) => void;
onStripDrag?: (slotKeysUnderCursor: string[]) => void;
onStripCommit?: () => void;
pickMode?: boolean; pickMode?: boolean;
onPickPoint?: (point: { lat: number; lon: number }) => void; onPickPoint?: (point: { lat: number; lon: number }) => void;
targets?: Tgu2DMapTarget[]; targets?: Tgu2DMapTarget[];
@@ -87,10 +100,14 @@ export function Tgu2DMapView({
selectedRequestPolygonId, selectedRequestPolygonId,
selectedObjectId, selectedObjectId,
markedObjectIds, markedObjectIds,
dimmedObjectIds,
focusPoint, focusPoint,
focusToken, focusToken,
enableObjectChooser = false, enableObjectChooser = false,
onObjectsAtPoint, onObjectsAtPoint,
onStripAnchor,
onStripDrag,
onStripCommit,
pickMode = false, pickMode = false,
onPickPoint, onPickPoint,
targets = [], targets = [],
@@ -105,6 +122,8 @@ export function Tgu2DMapView({
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const dragRef = useRef<DragState | undefined>(undefined); const dragRef = useRef<DragState | undefined>(undefined);
const hasDraggedRef = useRef(false); const hasDraggedRef = useRef(false);
// Активен жест «выбор полосы» (Shift+drag по слотам): пока true — панорама и обычный клик подавлены.
const stripGestureRef = useRef(false);
const hoverRef = useRef<MapPolygon | undefined>(undefined); const hoverRef = useRef<MapPolygon | undefined>(undefined);
const hoverMarkerRef = useRef<MapMarker | undefined>(undefined); const hoverMarkerRef = useRef<MapMarker | undefined>(undefined);
const selectedMarkerRef = useRef<MapMarker | undefined>(undefined); const selectedMarkerRef = useRef<MapMarker | undefined>(undefined);
@@ -213,6 +232,11 @@ export function Tgu2DMapView({
if (layers.complexPlan) { if (layers.complexPlan) {
drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size); drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size);
} }
// След покрытия (вкладка «Слоты») — отдельный пасс поверх полос слотов; кольца приходят
// через scene.coverageRings (тумблер на стороне TguSlotsTab очищает их, когда слой выключен).
if (scene.coverageRings && scene.coverageRings.length > 0) {
drawCoverage(context, scene.coverageRings, projection, size);
}
if (layers.planWorks) { if (layers.planWorks) {
drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size);
} }
@@ -225,7 +249,7 @@ export function Tgu2DMapView({
}); });
return () => window.cancelAnimationFrame(frameId); return () => window.cancelAnimationFrame(frameId);
}, [handleTileLoad, layers, projection, redrawToken, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]); }, [handleTileLoad, layers, projection, redrawToken, scene.coverageRings, scene.lines, scene.markers, size, tileVersion, view, visiblePolygons]);
const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => { const screenPointFromEvent = useCallback((event: React.PointerEvent | React.MouseEvent): ScreenPoint => {
const bounds = wrapperRef.current?.getBoundingClientRect(); const bounds = wrapperRef.current?.getBoundingClientRect();
@@ -262,12 +286,39 @@ export function Tgu2DMapView({
[activeMarkers, polygonIndex, projection, screenPointFromEvent] [activeMarkers, polygonIndex, projection, screenPointFromEvent]
); );
// Ключи слотов (id `slot:${key}`) под экранной точкой — для жеста выбора полосы. Брони вне окна
// (`booked:${key}`) и прочие слои не берём: полосой выбираем только кандидатов-слоты.
const slotKeysAtPoint = useCallback(
(screenPoint: ScreenPoint): string[] => {
const geoPoint = projection.unproject(screenPoint);
const keys: string[] = [];
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.id.startsWith("slot:")) keys.push(polygon.id.slice("slot:".length));
}
return keys;
},
[polygonIndex, projection]
);
const onPointerDown = (event: React.PointerEvent) => { const onPointerDown = (event: React.PointerEvent) => {
// Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание // Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание
// и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри // и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри
// карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали). // карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали).
if ((event.target as HTMLElement).closest("button")) return; if ((event.target as HTMLElement).closest("button")) return;
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
// Shift+нажатие на слот → жест выбора полосы (вместо панорамы). Над пустым местом Shift не мешает.
if (event.shiftKey && onStripAnchor) {
const keys = slotKeysAtPoint(screenPoint);
if (keys.length > 0) {
event.preventDefault(); // не даём Shift+drag начать выделение текста / перетаскивание
stripGestureRef.current = true;
hasDraggedRef.current = false;
dragRef.current = undefined;
wrapperRef.current?.setPointerCapture(event.pointerId);
onStripAnchor(keys);
return;
}
}
hasDraggedRef.current = false; hasDraggedRef.current = false;
dragRef.current = { dragRef.current = {
startX: screenPoint.x, startX: screenPoint.x,
@@ -280,6 +331,14 @@ export function Tgu2DMapView({
const DRAG_THRESHOLD = 4; const DRAG_THRESHOLD = 4;
const onPointerMove = (event: React.PointerEvent) => { const onPointerMove = (event: React.PointerEvent) => {
if (stripGestureRef.current) {
// Жест выбора полосы: тянем — отдаём слоты под курсором наверх (там клампится по границе полосы).
hasDraggedRef.current = true;
const screenPoint = screenPointFromEvent(event);
setCursorGeo(projection.unproject(screenPoint));
onStripDrag?.(slotKeysAtPoint(screenPoint));
return;
}
const drag = dragRef.current; const drag = dragRef.current;
if (!drag) { if (!drag) {
updateHover(event); updateHover(event);
@@ -310,6 +369,14 @@ export function Tgu2DMapView({
}; };
const onPointerUp = (event: React.PointerEvent) => { const onPointerUp = (event: React.PointerEvent) => {
if (stripGestureRef.current) {
stripGestureRef.current = false;
// hasDraggedRef оставляем true — иначе следующий onClick выбрал бы слот «сверху» жеста.
hasDraggedRef.current = true;
wrapperRef.current?.releasePointerCapture(event.pointerId);
onStripCommit?.();
return;
}
dragRef.current = undefined; dragRef.current = undefined;
wrapperRef.current?.releasePointerCapture(event.pointerId); wrapperRef.current?.releasePointerCapture(event.pointerId);
}; };
@@ -540,6 +607,14 @@ export function Tgu2DMapView({
: [], : [],
[markedObjectIds, activePolygons] [markedObjectIds, activePolygons]
); );
// Приглушённые (конфликтующие с выбором) объекты — рисуем под отмеченными/выбранными.
const dimmedPolygons = useMemo(
() =>
dimmedObjectIds && dimmedObjectIds.size > 0
? activePolygons.filter((p) => dimmedObjectIds.has(p.id))
: [],
[dimmedObjectIds, activePolygons]
);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
// Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга. // Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга.
@@ -574,6 +649,24 @@ export function Tgu2DMapView({
> >
<canvas className="tgu-2d-map__canvas" ref={canvasRef} /> <canvas className="tgu-2d-map__canvas" ref={canvasRef} />
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true"> <svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
{dimmedPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, 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 (
<polygon
key={`dimmed:${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-dimmed"
/>
);
});
})}
{markedPolygons.flatMap((polygon) => { {markedPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point)); const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize); const pts = unwrapX(raw, projection.worldSize);
@@ -0,0 +1,82 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { GeoPoint } from "../model/mapTypes";
// Слой следа покрытия (вкладка «Слоты»): заливка union контуров выбранных/забронированных слотов.
// Отдельный draw-пасс (D3) — НЕ MapPolygon, не в spatialIndex/hit-тесте. Цвет — violet flat .22
// без обводки (выбран на /plan-design-review: отличим от swath/complexPlan/requests + CVD).
//
// «Flat» (а не additive): все кольца кладём в ОДИН путь и заливаем одним ctx.fill() на проход —
// перекрытия не темнеют (это одна операция заливки при одной opacity). Чтобы перекрывающиеся кольца
// при nonzero-намотке не «вычитались» в дыры, нормализуем ориентацию колец к одному знаку площади.
type Pt = { x: number; y: number };
const FILL = "rgba(167, 139, 219, 0.22)";
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]!];
let prevX = points[0]!.x;
for (let i = 1; i < points.length; i++) {
let x = points[i]!.x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i]!.y });
prevX = x;
}
return out;
}
function signedArea(points: Pt[]): number {
let area = 0;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
area += (points[j]!.x - points[i]!.x) * (points[j]!.y + points[i]!.y);
}
return area;
}
function inView(points: Pt[], dx: number, vw: number, vh: number): boolean {
return points.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= vw + 64 && p.y >= -64 && p.y <= vh + 64;
});
}
export function drawCoverage(
ctx: CanvasRenderingContext2D,
rings: GeoPoint[][],
projection: MapProjection,
viewport: { width: number; height: number }
) {
if (rings.length === 0) return;
// Готовим экранные кольца с единой ориентацией (CW), чтобы nonzero-заливка объединяла, а не вычитала.
const screenRings: Pt[][] = [];
for (const ring of rings) {
if (ring.length < 3) continue;
const raw = ring.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
if (signedArea(pts) < 0) pts.reverse();
screenRings.push(pts);
}
if (screenRings.length === 0) return;
ctx.save();
ctx.fillStyle = FILL;
// Один путь со всеми кольцами как подпути + одна заливка на сдвиг мира → плоская opacity.
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
let any = false;
ctx.beginPath();
for (const pts of screenRings) {
if (!inView(pts, dx, viewport.width, viewport.height)) continue;
any = true;
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i]!.x + dx, pts[i]!.y);
else ctx.lineTo(pts[i]!.x + dx, pts[i]!.y);
}
ctx.closePath();
}
if (any) ctx.fill("nonzero");
}
ctx.restore();
}
@@ -76,5 +76,11 @@ export type Tgu2DMapScene = {
polygons: MapPolygon[]; polygons: MapPolygon[];
lines: MapLine[]; lines: MapLine[];
markers: MapMarker[]; markers: MapMarker[];
/**
* Кольца следа покрытия (вкладка «Слоты») — рисуются отдельным пассом drawCoverage заливкой,
* НЕ как MapPolygon: вне polygons/spatialIndex, поэтому не участвуют в hit-тесте/z-порядке.
* Пусто/не задано — слой не рисуется (тумблер выключен либо нечего показывать).
*/
coverageRings?: GeoPoint[][];
}; };
@@ -0,0 +1,35 @@
// Визуальный прогресс-бар чанковой операции (бронь и снятие). Общий компонент, чтобы обе
// операции показывали прогресс одинаково (DRY) — раньше прогресс брони был только текстом в кнопке.
type ChunkProgressProps = {
/** Подпись операции, напр. «Бронирование» / «Снятие брони». */
label: string;
/** Сколько элементов обработано. */
done: number;
/** Всего элементов в операции. */
total: number;
};
export function ChunkProgress({ label, done, total }: ChunkProgressProps) {
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
return (
<div
className="tgu-chunk-progress"
role="progressbar"
aria-valuenow={done}
aria-valuemin={0}
aria-valuemax={total}
aria-label={label}
>
<div className="tgu-chunk-progress__head">
<span>{label}</span>
<span className="tgu-chunk-progress__count">
{done}/{total}
</span>
</div>
<div className="tgu-chunk-progress__track">
<div className="tgu-chunk-progress__fill" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
@@ -0,0 +1,91 @@
import type { CoverageResult } from "./coverage";
type CoveragePanelProps = {
/** Покрытие только забронированным набором. null — покрытие недоступно (нет геометрии/0 сэмплов). */
booked: CoverageResult | null;
/** Покрытие union(забронированное ∪ выбранное галочками). */
withSelection: CoverageResult | null;
/** Есть ли выбор сверх брони (показывать второй сегмент «с выбором»). */
hasSelection: boolean;
/** Геометрия заявки пригодна для расчёта (false → «недоступно»). */
available: boolean;
/** Тумблер следа покрытия на карте (D4 — локальный, вкл. по умолчанию). */
coverageOn: boolean;
onToggleCoverage: (on: boolean) => void;
/** Тумблер подсветки конфликтов выбора (тусклый слой) — рядом со следом на карте. */
showConflicts: boolean;
onToggleConflicts: (on: boolean) => void;
};
// Панель покрытия над списком слотов (D-D1): «забронировано NN% · с выбором MM%» + двухсегментный
// бар + явные состояния. Цвет = цвет слоя coverage на карте (violet) — единый язык «покрыто».
export function CoveragePanel({
booked,
withSelection,
hasSelection,
available,
coverageOn,
onToggleCoverage,
showConflicts,
onToggleConflicts,
}: CoveragePanelProps) {
const bookedPct = booked?.percent ?? 0;
const withPct = withSelection?.percent ?? bookedPct;
// Прирост от выбора (второй сегмент) — поверх забронированного.
const gainPct = Math.max(0, withPct - bookedPct);
const lowPrecision = !!(booked?.lowPrecision || withSelection?.lowPrecision);
return (
<div className="tgu-coverage">
<div className="tgu-coverage__head">
<span className="tgu-coverage__title">Покрытие заявки</span>
<div className="tgu-coverage__toggles">
<label className="tgu-coverage__toggle" title="Показывать след покрытия на карте">
<input
type="checkbox"
checked={coverageOn}
onChange={(event) => onToggleCoverage(event.target.checked)}
/>
След на карте
</label>
<label
className="tgu-coverage__toggle"
title="Гасить и блокировать выбор слотов, недоступных из-за текущего выбора (тот же КА, не хватает времени на манёвр). Выкл. — выбирать можно любые."
>
<input
type="checkbox"
checked={showConflicts}
onChange={(event) => onToggleConflicts(event.target.checked)}
/>
Конфликты
</label>
</div>
</div>
{!available ? (
<div className="tgu-coverage__unavailable">Покрытие недоступно у заявки нет геометрии.</div>
) : (
<>
<div className="tgu-coverage__bar" role="presentation">
<div className="tgu-coverage__seg tgu-coverage__seg--booked" style={{ width: `${bookedPct}%` }} />
{hasSelection && gainPct > 0 && (
<div className="tgu-coverage__seg tgu-coverage__seg--sel" style={{ width: `${gainPct}%` }} />
)}
</div>
<div className="tgu-coverage__legend">
<span className="tgu-coverage__booked">забронировано {bookedPct}%</span>
{hasSelection && (
<span className="tgu-coverage__sel"> · с выбором {withPct}%</span>
)}
{lowPrecision && (
<span className="tgu-coverage__low" title="Заявка слишком мала для надёжной оценки">
{" "}
· низкая точность
</span>
)}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,72 @@
type PassStepperProps = {
/** Индекс текущего витка (0-based) или -1, если виток не выбран. */
index: number;
/** Всего витков в наборе. */
total: number;
/** Подпись текущего витка (КА · оборот · ветвь · время) — пустая, если виток не выбран. */
label: string;
canPrev: boolean;
canNext: boolean;
onPrev: () => void;
onNext: () => void;
/** Сброс курсора витка — вернуться к показу всего набора. */
onClear: () => void;
};
/**
* Степпер по виткам (проходам): ◀/▶ переводят курсор на предыдущий/следующий проход в хронологии,
* таблица скроллится к нему. Пока прохождение выбрано — отображение изолировано до него; «Все»
* возвращает к показу всего набора. Стоит под фильтром «Сутки»: сутки — грубый диапазон, проход —
* точный курсор внутри.
*/
export function PassStepper({
index,
total,
label,
canPrev,
canNext,
onPrev,
onNext,
onClear,
}: PassStepperProps) {
const active = index >= 0;
return (
<div className="map-pass-selector tgu-pass-stepper">
<div className="map-pass-selector__header">
<span className="map-pass-selector__title">Прохождение</span>
<span className="map-pass-selector__nums">{active ? `${index + 1} / ${total}` : `— / ${total}`}</span>
<span className="map-pass-selector__times">{active ? "" : "выберите прохождение ◀ ▶"}</span>
{active && (
<button type="button" className="map-pass-selector__reset" title="Показать все прохождения" onClick={onClear}>
Все
</button>
)}
</div>
{active && <div className="tgu-pass-stepper__label">{label}</div>}
<div className="tgu-pass-stepper__nav">
<button
type="button"
className="tgu-pass-stepper__btn"
onClick={onPrev}
disabled={!canPrev}
title="Предыдущее прохождение"
aria-label="Предыдущее прохождение"
>
</button>
<button
type="button"
className="tgu-pass-stepper__btn"
onClick={onNext}
disabled={!canNext}
title="Следующее прохождение"
aria-label="Следующее прохождение"
>
</button>
</div>
</div>
);
}
@@ -37,21 +37,34 @@ export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToCh
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1; return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
}; };
const makeHandlers = (isFrom: boolean) => ({ // Какой ползунок реально тянем. При наложении (fromIndex === toIndex) хват попадает на верхний
// (to), но на правом краю он заперт fromIndex и не может уйти влево — поэтому при наложении
// выбираем ползунок по направлению движения: влево → from (открыть диапазон), вправо → to.
const dragRef = useRef<"from" | "to" | null>(null);
const makeHandlers = (which: "from" | "to") => ({
onPointerDown(e: React.PointerEvent<HTMLDivElement>) { onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.stopPropagation(); e.stopPropagation();
dragRef.current = which;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}, },
onPointerMove(e: React.PointerEvent<HTMLDivElement>) { onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return; if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
const idx = indexAt(e.clientX); const idx = indexAt(e.clientX);
if (isFrom) { let active = dragRef.current;
if (fromIndex === toIndex) {
if (idx < fromIndex) active = "from";
else if (idx > toIndex) active = "to";
dragRef.current = active;
}
if (active === "from") {
onFromChange(Math.max(1, Math.min(idx, toIndex))); onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else { } else {
onToChange(Math.min(n, Math.max(idx, fromIndex))); onToChange(Math.min(n, Math.max(idx, fromIndex)));
} }
}, },
onPointerUp(e: React.PointerEvent<HTMLDivElement>) { onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
dragRef.current = null;
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
}, },
}); });
@@ -101,12 +114,12 @@ export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToCh
<div <div
className="map-pass-selector__thumb" className="map-pass-selector__thumb"
style={{ left: `${fromPct}%` }} style={{ left: `${fromPct}%` }}
{...makeHandlers(true)} {...makeHandlers("from")}
/> />
<div <div
className="map-pass-selector__thumb" className="map-pass-selector__thumb"
style={{ left: `${toPct}%` }} style={{ left: `${toPct}%` }}
{...makeHandlers(false)} {...makeHandlers("to")}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,147 @@
import type { RevolutionSign } from "../../model/slotTypes";
import type { SlotStatusKey } from "./slotFilters";
/** Сортировка таблицы слотов: время (порядок расчёта) · вклад (ранжирование по покрытию) · крен. */
export type SlotSortMode = "time" | "gain" | "roll";
type SlotFilterBarProps = {
/** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */
statusCounts: Record<SlotStatusKey, number>;
statusFilter: ReadonlySet<SlotStatusKey>;
onToggleStatus: (key: SlotStatusKey) => void;
/** Список КА в наборе (отсортирован). Чип КА не показываем, если спутник один. */
satellites: number[];
satFilter: ReadonlySet<number>;
onToggleSat: (id: number) => void;
branchFilter: ReadonlySet<RevolutionSign>;
onToggleBranch: (branch: RevolutionSign) => void;
/** Текущая сортировка таблицы (одиночный выбор). */
sortMode: SlotSortMode;
onSortMode: (mode: SlotSortMode) => void;
/** Доступна ли сортировка «по вкладу» (есть геометрия заявки) — иначе чип заблокирован. */
gainAvailable: boolean;
/** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */
isDefault: boolean;
onReset: () => void;
};
const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [
{ key: "free", label: "свободен", variant: "" },
{ key: "booked", label: "бронь", variant: " is-booked" },
{ key: "occupied", label: "недоступен", variant: " is-occupied" },
];
const SORT_META: { key: SlotSortMode; label: string }[] = [
{ key: "time", label: "время" },
{ key: "gain", label: "вклад" },
{ key: "roll", label: "крен" },
];
/**
* Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый
* filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы
* настоящие кнопки aria-pressed; цвет статус-чипа совпадает с бейджем статуса в таблице,
* на каждом текст-метка (не только цвет дальтоники).
*/
export function SlotFilterBar({
statusCounts,
statusFilter,
onToggleStatus,
satellites,
satFilter,
onToggleSat,
branchFilter,
onToggleBranch,
sortMode,
onSortMode,
gainAvailable,
isDefault,
onReset,
}: SlotFilterBarProps) {
return (
<div className="tgu-slots-filterbar">
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">Статус</span>
{STATUS_META.map(({ key, label, variant }) => {
const active = statusFilter.has(key);
return (
<button
key={key}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${variant}${active ? " is-active" : ""}`}
onClick={() => onToggleStatus(key)}
>
{label} {statusCounts[key]}
</button>
);
})}
</div>
{satellites.length > 1 && (
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">КА</span>
{satellites.map((id) => {
const active = satFilter.has(id);
return (
<button
key={id}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${active ? " is-active" : ""}`}
onClick={() => onToggleSat(id)}
>
{id}
</button>
);
})}
</div>
)}
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">Ветвь</span>
{(["ASC", "DESC"] as RevolutionSign[]).map((branch) => {
const active = branchFilter.has(branch);
return (
<button
key={branch}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${active ? " is-active" : ""}`}
onClick={() => onToggleBranch(branch)}
>
{branch === "ASC" ? "восх" : "нисх"}
</button>
);
})}
</div>
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">Сортировка</span>
{SORT_META.map(({ key, label }) => {
const active = sortMode === key;
const disabled = key === "gain" && !gainAvailable;
return (
<button
key={key}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${active ? " is-active" : ""}`}
disabled={disabled}
title={disabled ? "Сортировка по вкладу недоступна — у заявки нет геометрии" : undefined}
onClick={() => onSortMode(key)}
>
{label}
</button>
);
})}
</div>
{!isDefault && (
<button type="button" className="tgu-button tgu-button--sm tgu-slots-filterbar__reset" onClick={onReset}>
Сбросить фильтры
</button>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
import {
COVERAGE_GRID_N,
COVERAGE_MIN_SAMPLES,
buildCoverageGrid,
coverageOf,
coverageOfMasks,
rankByMarginalGain,
sampleSlotMask,
unionMasks,
} from "./coverage";
/** Прямоугольное кольцо (замкнутое) — основной строительный блок фикстур. */
function rect(minLon: number, minLat: number, maxLon: number, maxLat: number): GeoPoint[] {
return [
{ lon: minLon, lat: minLat },
{ lon: maxLon, lat: minLat },
{ lon: maxLon, lat: maxLat },
{ lon: minLon, lat: maxLat },
{ lon: minLon, lat: minLat },
];
}
describe("buildCoverageGrid", () => {
it("квадрат-заявка → все центры ячеек внутри (sampleCount = N²)", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)]);
expect(grid).not.toBeNull();
expect(grid!.n).toBe(COVERAGE_GRID_N);
expect(grid!.sampleCount).toBe(COVERAGE_GRID_N * COVERAGE_GRID_N);
expect(grid!.requestWeight).toBeGreaterThan(0);
});
it("пустой набор колец → null", () => {
expect(buildCoverageGrid([])).toBeNull();
});
it("кольцо <3 точек → null (дегенерат)", () => {
expect(buildCoverageGrid([[{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }]])).toBeNull();
});
it("нулевая площадь (коллинеарные точки) → 0 сэмплов → null", () => {
expect(
buildCoverageGrid([[{ lon: 0, lat: 0 }, { lon: 10, lat: 0 }, { lon: 5, lat: 0 }]]),
).toBeNull();
});
});
describe("sampleSlotMask + coverageOf", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
it("слот = заявка → ~100% (центр-сэмплинг не теряет краевые ячейки)", () => {
const mask = sampleSlotMask(grid, [rect(0, 0, 10, 10)]);
expect(coverageOf(grid, mask)!.percent).toBeGreaterThanOrEqual(99);
});
it("слот = левая половина → ~50%", () => {
const mask = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
const pct = coverageOf(grid, mask)!.percent;
expect(pct).toBeGreaterThanOrEqual(45);
expect(pct).toBeLessThanOrEqual(55);
});
it("слот вне bbox заявки (bbox-прунинг) → 0%", () => {
const mask = sampleSlotMask(grid, [rect(100, 100, 110, 110)]);
expect(coverageOf(grid, mask)!.percent).toBe(0);
expect(mask.every((v) => v === 0)).toBe(true);
});
it("битый контур (кольцо <3 точек) → пустая маска, слот не участвует", () => {
const mask = sampleSlotMask(grid, [[{ lon: 0, lat: 0 }, { lon: 5, lat: 5 }]]);
expect(mask.every((v) => v === 0)).toBe(true);
});
});
describe("coverageOfMasks (bookedselected)", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
it("пустой набор → 0%", () => {
expect(coverageOfMasks(grid, [])!.percent).toBe(0);
});
it("две одинаковые маски (OR de-dup) → ~100%, не 200%", () => {
const full = sampleSlotMask(grid, [rect(0, 0, 10, 10)]);
const res = coverageOfMasks(grid, [full, full])!;
expect(res.percent).toBeLessThanOrEqual(100);
expect(res.percent).toBeGreaterThanOrEqual(99);
});
it("левая ∪ правая половины → ~100%", () => {
const left = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
const right = sampleSlotMask(grid, [rect(5, 0, 10, 10)]);
expect(coverageOfMasks(grid, [left, right])!.percent).toBeGreaterThanOrEqual(99);
});
it("union частично перекрывающихся слотов не двойного счёта", () => {
const a = sampleSlotMask(grid, [rect(0, 0, 6, 10)]);
const b = sampleSlotMask(grid, [rect(4, 0, 10, 10)]);
const union = unionMasks(grid, [a, b]);
expect(coverageOf(grid, union)!.percent).toBeGreaterThanOrEqual(99);
});
});
describe("rankByMarginalGain (weighted)", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
const big = sampleSlotMask(grid, [rect(0, 0, 6, 10)]); // 60%
const small = sampleSlotMask(grid, [rect(0, 0, 4, 10)]); // 40%
it("жадный порядок: больший вклад — первым", () => {
const ranked = rankByMarginalGain(grid, [
{ key: "small", mask: small },
{ key: "big", mask: big },
]);
expect(ranked[0]!.key).toBe("big");
expect(ranked[0]!.gainPercent).toBeGreaterThan(ranked[1]!.gainPercent);
});
it("тай-брейк: равный вклад → исходный порядок", () => {
const left = sampleSlotMask(grid, [rect(0, 0, 5, 10)]);
const right = sampleSlotMask(grid, [rect(5, 0, 10, 10)]);
const ranked = rankByMarginalGain(grid, [
{ key: "left", mask: left },
{ key: "right", mask: right },
]);
expect(ranked.map((r) => r.key)).toEqual(["left", "right"]);
});
it("вклад 0: кандидат — подмножество уже покрытого → gainPercent 0 («—»)", () => {
const base = sampleSlotMask(grid, [rect(0, 0, 10, 10)]); // всё покрыто
const ranked = rankByMarginalGain(grid, [{ key: "redundant", mask: small }], base);
expect(ranked[0]!.gainPercent).toBe(0);
});
});
describe("антимеридиан (собственный unwrap-фрейм)", () => {
it("заявка и слот через 180° → корректные ~100%", () => {
// Кольцо пересекает антимеридиан: долготы 170 и -170 (=190 в unwrap-фрейме).
const ring: GeoPoint[] = [
{ lon: 170, lat: 0 },
{ lon: -170, lat: 0 },
{ lon: -170, lat: 10 },
{ lon: 170, lat: 10 },
{ lon: 170, lat: 0 },
];
const grid = buildCoverageGrid([ring]);
expect(grid).not.toBeNull();
expect(grid!.sampleCount).toBe(COVERAGE_GRID_N * COVERAGE_GRID_N);
const mask = sampleSlotMask(grid!, [ring]);
expect(coverageOf(grid!, mask)!.percent).toBeGreaterThanOrEqual(99);
});
});
describe("WKT holes — лимит MVP-парсера зафиксирован", () => {
it("полигон с дырой парсится только во внешнее кольцо (1 кольцо)", () => {
const rings = parseWktPolygon(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))",
);
expect(rings).toHaveLength(1);
});
it("дыра игнорируется → покрытие считает её закрытой (~100%)", () => {
const rings = parseWktPolygon(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 4 2, 4 4, 2 4, 2 2))",
);
const grid = buildCoverageGrid(rings)!;
const mask = sampleSlotMask(grid, rings);
expect(coverageOf(grid, mask)!.percent).toBeGreaterThanOrEqual(99);
});
});
describe("низкая точность (тонкий полигон)", () => {
it("крошечные фигуры в большом bbox → мало сэмплов → lowPrecision", () => {
// Два маленьких треугольника в противоположных углах: bbox 40×40, но суммарная площадь
// ничтожна → < COVERAGE_MIN_SAMPLES сэмплов внутри (тонкий полигон → абсурдный %).
const grid = buildCoverageGrid([
[
{ lon: 0, lat: 0 },
{ lon: 2, lat: 0 },
{ lon: 0, lat: 2 },
{ lon: 0, lat: 0 },
],
[
{ lon: 40, lat: 40 },
{ lon: 40, lat: 38 },
{ lon: 38, lat: 40 },
{ lon: 40, lat: 40 },
],
]);
expect(grid).not.toBeNull();
expect(grid!.sampleCount).toBeGreaterThan(0);
expect(grid!.sampleCount).toBeLessThan(COVERAGE_MIN_SAMPLES);
const res = coverageOf(grid!, new Uint8Array(grid!.lons.length));
expect(res!.lowPrecision).toBe(true);
});
it("полный квадрат → точность нормальная", () => {
const grid = buildCoverageGrid([rect(0, 0, 10, 10)])!;
expect(coverageOf(grid, new Uint8Array(grid.lons.length))!.lowPrecision).toBe(false);
});
});
@@ -0,0 +1,264 @@
// Растровое (Monte-Carlo) покрытие заявки на клиенте — ядро «слоя поддержки решений оператора».
// Чистые функции под юнит-тесты (см. coverage.test.ts, образец slotFilters.test.ts).
//
// Идея: вместо точного векторного union контуров считаем % по детерминированной сетке сэмплов
// внутри bbox заявки. Сэмпл «закрыт», если попал в union контуров выбранных/забронированных слотов.
// % = Σ cos(lat)-вес закрытых сэмплов внутри заявки / Σ вес всех сэмплов внутри заявки. Стабильно,
// дёшево на чек/снятие (per-slot маска кэшируется), ноль зависимостей.
//
// выбор/бронь слотов ─▶ [сетка сэмплов заявки, cos-lat вес] ─▶ маски слотов (OR) ─▶ % + слой coverage
// [0 валидных→null] [битый WKT→skip] [bbox-прунинг] [<порога→низкая точность]
import { pointInPolygon } from "../tgu-map-2d/geometry/pointInPolygon";
import type { GeoPoint } from "../tgu-map-2d/model/mapTypes";
/** Сторона сетки сэмплов (D1): фикс 64×64 ≈ 4096 точек по bbox заявки, независимо от размера. */
export const COVERAGE_GRID_N = 64;
/** Порог «низкой точности» (codex P1): тонкий полигон даёт мало сэмплов → % абсурдный. */
export const COVERAGE_MIN_SAMPLES = 16;
/** Детерминированная сетка сэмплов по bbox заявки (кэш на заявку). */
export type CoverageGrid = {
readonly n: number;
/** Опорная долгота фрейма unwrap (антимеридиан): все долготы приведены к [refLon-180, refLon+180]. */
readonly refLon: number;
/** Долготы сэмплов в unwrap-фрейме (length n*n). */
readonly lons: Float64Array;
/** Широты сэмплов (length n*n). */
readonly lats: Float64Array;
/** Вес сэмпла = max(cos(lat), 0) — компенсация искажения площади к полюсам. */
readonly weights: Float64Array;
/** 1, если сэмпл внутри заявки (union колец); только такие участвуют в %. */
readonly inRequest: Uint8Array;
/** Σ weight по сэмплам внутри заявки (знаменатель %). */
readonly requestWeight: number;
/** Кол-во сэмплов внутри заявки (для порога точности). */
readonly sampleCount: number;
};
/** Маска слота: 1, если сэмпл внутри заявки И закрыт контуром слота (length n*n). */
export type SlotMask = Uint8Array;
/** Результат расчёта покрытия набора. */
export type CoverageResult = {
/** Доля 0..1. */
fraction: number;
/** Целый процент 0..100 (для панели). */
percent: number;
/** Кол-во сэмплов заявки (диагностика). */
sampleCount: number;
/** sampleCount < COVERAGE_MIN_SAMPLES — % ненадёжен, панель помечает «низкая точность». */
lowPrecision: boolean;
};
const DEG2RAD = Math.PI / 180;
/** Приводит долготу к непрерывному фрейму вокруг ref (унос через антимеридиан). */
function unwrapLonTo(lon: number, ref: number): number {
let x = lon;
while (x - ref > 180) x -= 360;
while (ref - x > 180) x += 360;
return x;
}
/** Оставляет кольца ≥3 точек и уносит их долготы в фрейм ref (битые/мелкие — отбрасываем). */
function unwrapRings(rings: GeoPoint[][], ref: number): GeoPoint[][] {
const out: GeoPoint[][] = [];
for (const ring of rings) {
if (ring.length < 3) continue;
out.push(ring.map((p) => ({ lon: unwrapLonTo(p.lon, ref), lat: p.lat })));
}
return out;
}
type Bounds = { minLon: number; minLat: number; maxLon: number; maxLat: number };
function boundsOfRings(rings: GeoPoint[][]): Bounds {
let minLon = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLon = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const ring of rings) {
for (const p of ring) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
}
return { minLon, minLat, maxLon, maxLat };
}
/**
* Строит сетку сэмплов по bbox заявки. Дегенерат (нет валидных колец или 0 сэмплов внутри) null
* («покрытие недоступно»). Антимеридиан собственный unwrap-фрейм (примитив spatialIndex приватный).
*/
export function buildCoverageGrid(requestRings: GeoPoint[][]): CoverageGrid | null {
const valid0 = requestRings.filter((r) => r.length >= 3);
if (valid0.length === 0) return null;
const refLon = valid0[0][0]!.lon;
const rings = unwrapRings(valid0, refLon);
if (rings.length === 0) return null;
const b = boundsOfRings(rings);
if (!Number.isFinite(b.minLon) || !Number.isFinite(b.minLat)) return null;
const n = COVERAGE_GRID_N;
const total = n * n;
const lons = new Float64Array(total);
const lats = new Float64Array(total);
const weights = new Float64Array(total);
const inRequest = new Uint8Array(total);
const spanLon = b.maxLon - b.minLon;
const spanLat = b.maxLat - b.minLat;
let requestWeight = 0;
let sampleCount = 0;
for (let gy = 0; gy < n; gy++) {
// Сэмплируем по центрам ячеек ((gy+0.5)/n), чтобы не попадать ровно на границу bbox/контура.
const lat = b.minLat + ((gy + 0.5) / n) * spanLat;
const w = Math.max(Math.cos(lat * DEG2RAD), 0);
for (let gx = 0; gx < n; gx++) {
const idx = gy * n + gx;
const lon = b.minLon + ((gx + 0.5) / n) * spanLon;
lons[idx] = lon;
lats[idx] = lat;
weights[idx] = w;
const pt = { lon, lat };
let inside = false;
// union колец: нечётное число попаданий не требуется — это отдельные внешние контуры (MULTIPOLYGON),
// достаточно попасть хотя бы в одно. Дыры (внутренние кольца WKT) игнорируются (лимит MVP-парсера).
for (const ring of rings) {
if (pointInPolygon(pt, ring)) {
inside = true;
break;
}
}
if (inside) {
inRequest[idx] = 1;
requestWeight += w;
sampleCount += 1;
}
}
}
if (sampleCount === 0) return null;
return { n, refLon, lons, lats, weights, inRequest, requestWeight, sampleCount };
}
/**
* Маска слота на сетке заявки: 1 там, где сэмпл внутри заявки И закрыт контуром слота.
* bbox-прунинг (D2): сначала отбрасываем сэмплы вне bbox слота, потом точный pointInPolygon.
* Битый WKT / кольца <3 пустая маска (слот не участвует в покрытии, без падения).
*/
export function sampleSlotMask(grid: CoverageGrid, slotRings: GeoPoint[][]): SlotMask {
const mask = new Uint8Array(grid.lons.length);
const rings = unwrapRings(slotRings, grid.refLon);
if (rings.length === 0) return mask;
const b = boundsOfRings(rings);
if (!Number.isFinite(b.minLon)) return mask;
for (let idx = 0; idx < mask.length; idx++) {
if (grid.inRequest[idx] === 0) continue;
const lon = grid.lons[idx]!;
const lat = grid.lats[idx]!;
if (lon < b.minLon || lon > b.maxLon || lat < b.minLat || lat > b.maxLat) continue;
const pt = { lon, lat };
for (const ring of rings) {
if (pointInPolygon(pt, ring)) {
mask[idx] = 1;
break;
}
}
}
return mask;
}
/** OR нескольких масок в одну (union покрытия набора). */
export function unionMasks(grid: CoverageGrid, masks: readonly SlotMask[]): SlotMask {
const out = new Uint8Array(grid.lons.length);
for (const mask of masks) {
for (let i = 0; i < out.length; i++) if (mask[i]) out[i] = 1;
}
return out;
}
/** Покрытие по готовому union-маске. requestWeight<=0 (заявка у полюса) → null. */
export function coverageOf(grid: CoverageGrid, union: SlotMask): CoverageResult | null {
if (grid.requestWeight <= 0) return null;
let covered = 0;
for (let idx = 0; idx < union.length; idx++) {
if (union[idx]) covered += grid.weights[idx]!;
}
const fraction = covered / grid.requestWeight;
return {
fraction,
percent: Math.round(fraction * 100),
sampleCount: grid.sampleCount,
lowPrecision: grid.sampleCount < COVERAGE_MIN_SAMPLES,
};
}
/** Покрытие набора масок (bookedselected): union → coverageOf. Пустой набор → 0%. */
export function coverageOfMasks(grid: CoverageGrid, masks: readonly SlotMask[]): CoverageResult | null {
return coverageOf(grid, unionMasks(grid, masks));
}
/** Маргинальный вклад слота поверх текущего union: Σ cos-lat весов НОВЫХ закрытых сэмплов (не popcount). */
export function marginalGainWeight(grid: CoverageGrid, union: SlotMask, mask: SlotMask): number {
let gain = 0;
for (let idx = 0; idx < mask.length; idx++) {
if (mask[idx] && !union[idx]) gain += grid.weights[idx]!;
}
return gain;
}
/** Кандидат для ранжирования по вкладу. */
export type RankCandidate = { key: string; mask: SlotMask };
/** Ранжированный кандидат: вклад на момент ранжирования (жадный порядок), доля и целый %. */
export type RankedCandidate = { key: string; gainFraction: number; gainPercent: number };
/**
* Жадное ранжирование по маргинальному вкладу (взвешенному, codex P1): на каждом шаге берём
* кандидата с максимальным приростом покрытия поверх уже отобранного union (старт baseUnion,
* напр. забронированное). Тай-брейк исходный порядок (stable). Вклад фиксируется на момент
* ранжирования (для колонки «+N%», подпись «вклад на момент ранжирования», не live).
*/
export function rankByMarginalGain(
grid: CoverageGrid,
candidates: readonly RankCandidate[],
baseUnion?: SlotMask,
): RankedCandidate[] {
const denom = grid.requestWeight > 0 ? grid.requestWeight : 1;
const union = baseUnion ? Uint8Array.from(baseUnion) : new Uint8Array(grid.lons.length);
const remaining = candidates.map((c, order) => ({ ...c, order }));
const ranked: RankedCandidate[] = [];
while (remaining.length > 0) {
let bestIdx = 0;
let bestGain = -1;
for (let i = 0; i < remaining.length; i++) {
const gain = marginalGainWeight(grid, union, remaining[i]!.mask);
// Строгое > сохраняет исходный порядок при равенстве (стабильный тай-брейк).
if (gain > bestGain) {
bestGain = gain;
bestIdx = i;
}
}
const [picked] = remaining.splice(bestIdx, 1);
const gainFraction = bestGain / denom;
ranked.push({
key: picked!.key,
gainFraction,
gainPercent: Math.round(gainFraction * 100),
});
for (let idx = 0; idx < union.length; idx++) if (picked!.mask[idx]) union[idx] = 1;
}
return ranked;
}
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import { runChunked } from "./runChunked";
describe("runChunked", () => {
it("бьёт набор на чанки и шлёт их по очереди", async () => {
const items = Array.from({ length: 450 }, (_, i) => i);
const sizes: number[] = [];
const fn = vi.fn(async (part: number[]) => {
sizes.push(part.length);
return part.length; // снятие: число снятых в чанке
});
const results = await runChunked(items, 200, fn);
expect(fn).toHaveBeenCalledTimes(3);
expect(sizes).toEqual([200, 200, 50]);
expect(results).toEqual([200, 200, 50]);
// суммирование на стороне вызова
expect(results.reduce((a, b) => a + b, 0)).toBe(450);
});
it("сообщает прогресс (обработано/всего) после каждого чанка", async () => {
const items = Array.from({ length: 450 }, (_, i) => i);
const progress: Array<[number, number]> = [];
await runChunked(items, 200, async () => 0, (done, total) => progress.push([done, total]));
expect(progress).toEqual([
[200, 450],
[400, 450],
[450, 450],
]);
});
it("пустой набор — не зовёт fn и возвращает пусто", async () => {
const fn = vi.fn(async () => 0);
const results = await runChunked([], 200, fn);
expect(fn).not.toHaveBeenCalled();
expect(results).toEqual([]);
});
it("ошибка чанка пробрасывается наружу", async () => {
const items = [1, 2, 3];
await expect(
runChunked(items, 2, async (part) => {
if (part.includes(3)) throw new Error("boom");
return part.length;
}),
).rejects.toThrow("boom");
});
it("аккумулирует списки (бронь) с сохранением порядка", async () => {
const items = [1, 2, 3, 4, 5];
const results = await runChunked(items, 2, async (part) => part.map((n) => n * 10));
expect(results.flat()).toEqual([10, 20, 30, 40, 50]);
});
});
@@ -0,0 +1,33 @@
// Общий чанковый раннер для крупных операций над слотами (бронь и снятие). Бэк работает по
// одному слоту в транзакции, поэтому крупный набор бьём на части (см. BOOK_CHUNK) и шлём
// последовательно, сообщая прогресс. Выделено из TguSlotsTab, чтобы покрыть юнит-тестами и не
// дублировать цикл в performBook/performCancel (DRY).
import { chunk } from "./slotSelection";
/**
* Прогоняет `fn` по чанкам `items` последовательно, после каждого чанка зовёт `onProgress`.
* Возвращает по одному результату на чанк (порядок сохраняется): для брони это списки
* забронированных слотов, для снятия числа снятых. Сведение (flat/сумма) на стороне вызова.
*
* @param items Полный набор элементов операции.
* @param size Размер чанка; <= 0 один чанк со всем набором (см. chunk()).
* @param fn Операция над одним чанком; ошибка пробрасывается наружу (частичный прогресс уже учтён).
* @param onProgress Коллбек прогресса: (обработано, всего) после каждого успешного чанка.
*/
export async function runChunked<TItem, TChunk>(
items: readonly TItem[],
size: number,
fn: (part: TItem[]) => Promise<TChunk>,
onProgress?: (done: number, total: number) => void,
): Promise<TChunk[]> {
const total = items.length;
const results: TChunk[] = [];
let done = 0;
for (const part of chunk(items, size)) {
results.push(await fn(part));
done += part.length;
onProgress?.(done, total);
}
return results;
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { computeConflicts } from "./slotConflicts";
/** Минимальный слот: КА + окно [tn,tk] + крен + ключ. */
type S = { satelliteId: number; n: number; tn: string; tk: string; roll: number; state?: string };
const keyOf = (s: S) => `${s.satelliteId}:${s.n}`;
const pad = (v: number) => String(v).padStart(2, "0");
/** ISO-момент 10:MM:SS от секунд смещения относительно 10:00:00. */
function fmt(sec: number): string {
return `2026-06-17T10:${pad(Math.floor(sec / 60))}:${pad(sec % 60)}`;
}
/** Слот КА на окне [startSec, endSec] (секунды от 10:00:00), с креном roll. */
function slot(satelliteId: number, n: number, startSec: number, endSec: number, roll: number, state = "AVAILABLE"): S {
return { satelliteId, n, tn: fmt(startSec), tk: fmt(endSec), roll, state };
}
const notBlocked = () => false;
// Выбран ровно слот A (sat 1, n 1) — источник занятого интервала во всех кейсах.
const selectedIsA = (s: S) => s.satelliteId === 1 && s.n === 1;
// A: [0,30], крен 10.
const A = slot(1, 1, 0, 30, 10);
describe("computeConflicts (зазор на манёвр)", () => {
it("без выбора — конфликтов нет", () => {
const arr = [A, slot(1, 2, 38, 60, 10)];
expect(computeConflicts(arr, arr, keyOf, () => false, notBlocked).size).toBe(0);
});
it("тот же крен: зазор < 10 с → конфликт, ≥ 10 с → ок", () => {
const close = slot(1, 2, 38, 60, 10); // зазор 8 с → конфликт
const ok = slot(1, 3, 41, 60, 10); // зазор 11 с → ок
const arr = [A, close, ok];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
expect(res.has("1:2")).toBe(true);
expect(res.has("1:3")).toBe(false);
});
it("смена крена: тот же зазор 25 с конфликтует (нужно 30), при том же крене — нет (хватает 10)", () => {
const diffRoll = slot(1, 4, 55, 80, 20); // зазор 25 с, крен сменился → конфликт
const sameRoll = slot(1, 5, 55, 80, 10); // зазор 25 с, крен тот же → ок
const arr = [A, diffRoll, sameRoll];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
expect(res.has("1:4")).toBe(true);
expect(res.has("1:5")).toBe(false);
});
it("смена крена: зазор ≥ 30 с → ок", () => {
const ok = slot(1, 6, 61, 90, 20); // зазор 31 с → ок
const arr = [A, ok];
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("1:6")).toBe(false);
});
it("пересечение по времени → конфликт; сам выбранный не помечается", () => {
const overlap = slot(1, 7, 20, 50, 20); // перекрывает A
const arr = [A, overlap];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked);
expect(res.has("1:7")).toBe(true);
expect(res.has("1:1")).toBe(false);
});
it("другой КА не конфликтует", () => {
const other = slot(2, 8, 38, 60, 10); // тот же зазор, но КА 2
const arr = [A, other];
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("2:8")).toBe(false);
});
it("кандидат ПЕРЕД выбранным тоже ловится (тот же крен, зазор < 10 с)", () => {
const later = slot(1, 10, 100, 130, 10); // выбран, позже по времени
const before = slot(1, 11, 88, 95, 10); // зазор 5 с до later → конфликт
const farBefore = slot(1, 12, 50, 70, 10); // зазор 30 с до later → ок
const arr = [later, before, farBefore];
const res = computeConflicts(arr, arr, keyOf, (s) => s.n === 10, notBlocked);
expect(res.has("1:11")).toBe(true);
expect(res.has("1:12")).toBe(false);
});
it("бронь/BUSY исключены из конфликтов", () => {
const busy = slot(1, 9, 38, 60, 10, "BUSY"); // зазор 8 с, но BUSY
const arr = [A, busy];
const res = computeConflicts(arr, arr, keyOf, selectedIsA, (s) => s.state === "BUSY");
expect(res.has("1:9")).toBe(false);
});
it("слоты ОДНОЙ полосы между собой не конфликтуют (groupOf)", () => {
const sameStrip = slot(1, 2, 38, 60, 10); // зазор 8 с, тот же крен — конфликт по умолчанию
const arr = [A, sameStrip];
// Без groupOf — конфликт.
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked).has("1:2")).toBe(true);
// A и sameStrip в одной полосе — конфликт снимается.
const sameGroup = () => "polosa:1";
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked, sameGroup).has("1:2")).toBe(false);
});
it("groupOf: разные полосы того же КА по-прежнему конфликтуют", () => {
const other = slot(1, 2, 38, 60, 10); // зазор 8 с, тот же крен
const arr = [A, other];
const groupOf = (s: S) => (s.n === 1 ? "polosa:a" : "polosa:b");
expect(computeConflicts(arr, arr, keyOf, selectedIsA, notBlocked, groupOf).has("1:2")).toBe(true);
});
});
@@ -0,0 +1,123 @@
// Приближённая клиентская подсказка «конфликтует с текущим выбором». Слот гаснет, если он того же
// КА, что и какой-то выбранный под бронь слот, и не выдержан зазор на манёвр между ними:
// • крен НЕ меняется → между концом одного и началом другого нужно ≥ 10 с;
// • крен меняется → нужно ≥ 30 с (на перенацеливание дольше).
// Меньший зазор (вплоть до пересечения) → конфликт. «Крен меняется» = расхождение крена > ROLL_EPSILON
// (тот же допуск, по которому слоты кластеризуются в полосы).
//
// Это ПРИБЛИЖЁННАЯ клиентская оценка, не полная проверка выполнимости: реальная физика (профиль slew,
// maxChainLength, maxSurveyDuration) живёт в slots-service по-КА и проверяется бэкенд-эндпоинтом
// (итерация 2). Слоты ОДНОЙ полосы (одна непрерывная съёмка) между собой не конфликтуют — см. groupOf.
//
// Конфликтные слоты не только гаснут, но и НЕ ДОБАВЛЯЮТСЯ в выбор под бронь (жёсткая блокировка на
// стороне UI). Уже выбранные слоты не трогаем (конфликт считается только для НЕвыбранных кандидатов),
// поэтому снять выбор можно всегда. Окончательный арбитр выполнимости — бэкенд.
import type { SlotDTO } from "../../model/slotTypes"
import { parseLocal } from "../../utils/localTime"
import { ROLL_EPSILON } from "./slotStrips"
type Conflictable = Pick<SlotDTO, "satelliteId" | "tn" | "tk" | "roll">
/** Требуемый зазор между концом одного и началом следующего слота того же КА (мс). */
export const SAME_ROLL_GAP_MS = 10_000
export const DIFF_ROLL_GAP_MS = 30_000
/** Выбранный интервал слота: окно [start,end) (мс), крен и id полосы — для выбора нужного зазора. */
type SelInterval = { start: number; end: number; roll: number; group?: string }
/** Занятые интервалы выбранных слотов одного КА: отсортированы по началу + макс. длительность. */
type SatSelection = { intervals: SelInterval[]; maxDur: number }
/**
* Конфликтует ли кандидат [cStart,cEnd) с креном cRoll с выбранным интервалом s того же КА:
* упорядочиваем по началу и проверяем зазор (начало позднего конец раннего) против требуемого
* (10 с при том же крене, 30 с при смене крена). Пересечение даёт отрицательный зазор конфликт.
*/
function conflictsPair(cStart: number, cEnd: number, cRoll: number, s: SelInterval): boolean {
const required = Math.abs(cRoll - s.roll) <= ROLL_EPSILON ? SAME_ROLL_GAP_MS : DIFF_ROLL_GAP_MS
const [earlierEnd, laterStart] = cStart <= s.start ? [cEnd, s.start] : [s.end, cStart]
return laterStart - earlierEnd < required
}
/** Первый индекс, у которого intervals[i].start >= target (бинарный поиск по отсортированному). */
function lowerBound(intervals: SelInterval[], target: number): number {
let lo = 0
let hi = intervals.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if (intervals[mid].start < target) lo = mid + 1
else hi = mid
}
return lo
}
/**
* Возвращает множество ключей кандидатов, конфликтующих с текущим выбором (тот же КА + недостаточный
* зазор на манёвр). Выбранные и «заблокированные» (бронь/BUSY) слоты в результат не попадают.
*
* Производительность: занятые интервалы строим из source (полный набор; выбор обычно мал), сортируем
* по началу и для каждого кандидата проверяем не всех выбранных, а лишь окно по времени ( зазора +
* макс. длительности) через бинарный поиск ~O(кандидаты·log(выбор)). За кандидатов берём видимый
* набор, а не весь невидимое всё равно не рисуется.
*
* @param source Полный набор слотов источник занятых интервалов выбранных.
* @param candidates Слоты, которые помечаем (обычно видимые: таблица/карта).
* @param keyOf Аксессор ключа слота.
* @param isSelected Входит ли слот в текущий набор под бронь.
* @param isBlocked Бронь/BUSY такой слот не помечаем как конфликт (у него своя индикация).
* @param groupOf Id полосы слота (одна непрерывная съёмка): слоты ОДНОЙ полосы между собой не
* конфликтуют (это один манёвр, без перенацеливания), поэтому такую пару пропускаем. Без аксессора
* полосы не учитываются.
*/
export function computeConflicts<T extends Conflictable>(
source: readonly T[],
candidates: readonly T[],
keyOf: (slot: T) => string,
isSelected: (slot: T) => boolean,
isBlocked: (slot: T) => boolean,
groupOf?: (slot: T) => string | undefined,
): Set<string> {
const bySat = new Map<number, SatSelection>()
for (const slot of source) {
if (!isSelected(slot)) continue
const start = parseLocal(slot.tn)
const end = parseLocal(slot.tk)
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
let bucket = bySat.get(slot.satelliteId)
if (!bucket) {
bucket = { intervals: [], maxDur: 0 }
bySat.set(slot.satelliteId, bucket)
}
bucket.intervals.push({ start, end, roll: slot.roll, group: groupOf?.(slot) })
bucket.maxDur = Math.max(bucket.maxDur, end - start)
}
const conflicts = new Set<string>()
if (bySat.size === 0) return conflicts
for (const bucket of bySat.values()) bucket.intervals.sort((a, b) => a.start - b.start)
for (const slot of candidates) {
if (isSelected(slot) || isBlocked(slot)) continue
const bucket = bySat.get(slot.satelliteId)
if (!bucket) continue
const start = parseLocal(slot.tn)
const end = parseLocal(slot.tk)
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
const group = groupOf?.(slot)
// Конфликтовать может только выбранный интервал в окне по времени: его start в диапазоне
// [start maxGap maxDur, end + maxGap]. Вне окна зазор заведомо больше требуемого.
const ints = bucket.intervals
const highStart = end + DIFF_ROLL_GAP_MS
for (let i = lowerBound(ints, start - DIFF_ROLL_GAP_MS - bucket.maxDur); i < ints.length; i++) {
if (ints[i].start > highStart) break
// Одна полоса — одна непрерывная съёмка: её слоты между собой не конфликтуют.
if (group !== undefined && ints[i].group === group) continue
if (conflictsPair(start, end, slot.roll, ints[i])) {
conflicts.add(keyOf(slot))
break
}
}
}
return conflicts
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { SlotDTO } from "../../model/slotTypes";
import { applySlotFilters, slotStatusKey, type SlotFilterState } from "./slotFilters";
function slot(over: Partial<SlotDTO>): SlotDTO {
return {
cycle: 1,
satelliteId: 2,
tn: "2026-06-15T10:00:00",
tk: "2026-06-15T10:00:10",
roll: 30,
contour: "POLYGON EMPTY",
revolution: 281,
revolutionSign: "ASC",
slotNumber: 1,
state: "AVAILABLE",
latitude: 55,
longitude: 37,
...over,
};
}
const allStatuses = new Set(["free", "booked", "occupied"] as const);
const allBranches = new Set(["ASC", "DESC"] as const);
describe("slotStatusKey", () => {
it("бронь важнее state (booked, даже если state=AVAILABLE)", () => {
expect(slotStatusKey({ state: "AVAILABLE" }, true)).toBe("booked");
});
it("BUSY → occupied, когда не бронь", () => {
expect(slotStatusKey({ state: "BUSY" }, false)).toBe("occupied");
});
it("AVAILABLE → free", () => {
expect(slotStatusKey({ state: "AVAILABLE" }, false)).toBe("free");
});
});
describe("applySlotFilters", () => {
const rows = [
slot({ slotNumber: 1, satelliteId: 2, revolutionSign: "ASC", state: "AVAILABLE" }),
slot({ slotNumber: 2, satelliteId: 2, revolutionSign: "DESC", state: "BUSY" }),
slot({ slotNumber: 3, satelliteId: 5, revolutionSign: "ASC", state: "AVAILABLE" }),
];
const booked = new Set([1]); // слот №1 — бронь
const isBooked = (s: SlotDTO) => booked.has(s.slotNumber);
const base: SlotFilterState = {
status: allStatuses,
satellites: new Set([2, 5]),
branches: allBranches,
};
it("дефолт (все оси) пропускает всё", () => {
expect(applySlotFilters(rows, base, isBooked)).toHaveLength(3);
});
it("статус free убирает бронь и занятый", () => {
const out = applySlotFilters(rows, { ...base, status: new Set(["free"]) }, isBooked);
expect(out.map((s) => s.slotNumber)).toEqual([3]);
});
it("фильтр по КА оставляет только выбранный спутник", () => {
const out = applySlotFilters(rows, { ...base, satellites: new Set([5]) }, isBooked);
expect(out.map((s) => s.slotNumber)).toEqual([3]);
});
it("ветвь ASC оставляет только восходящие", () => {
const out = applySlotFilters(rows, { ...base, branches: new Set(["ASC"]) }, isBooked);
expect(out.map((s) => s.slotNumber)).toEqual([1, 3]);
});
it("снятие последней галочки оси (пустое множество) → ничего не видно", () => {
expect(applySlotFilters(rows, { ...base, status: new Set() }, isBooked)).toHaveLength(0);
expect(applySlotFilters(rows, { ...base, satellites: new Set() }, isBooked)).toHaveLength(0);
});
});
@@ -0,0 +1,46 @@
// Чистая логика фильтрации отображения слотов — выделена из TguSlotsTab, чтобы покрыть
// юнит-тестами без рендера компонента. Едина для таблицы и карты: обе читают результат
// applySlotFilters (filteredSlots).
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"
/** Производный статус слота для фильтра видимости. */
export type SlotStatusKey = "free" | "booked" | "occupied"
/** Состояние фильтров отображения. Единая модель «selected = visible» на всех осях:
* множество = что ВИДНО; пустое множество = не видно ничего (одинаково для всех осей). */
export type SlotFilterState = {
status: ReadonlySet<SlotStatusKey>
satellites: ReadonlySet<number>
branches: ReadonlySet<RevolutionSign>
}
/**
* Производный статус слота. Бронь определяется снаружи (isBooked), а не по slot.state,
* потому что свежезабронированный кандидат приходит со state===AVAILABLE до перечитывания
* (бронь важнее state). Иначе BUSY (занят бронью на проходе КА) occupied, остальное free.
*/
export function slotStatusKey(slot: Pick<SlotDTO, "state">, isBooked: boolean): SlotStatusKey {
if (isBooked) return "booked"
if (slot.state === "BUSY") return "occupied"
return "free"
}
/**
* Фильтрует слоты по статусу/КА/ветви одним проходом (selected=visible на каждой оси).
* @param rows Уже суженные по окну суток слоты.
* @param filters Множества видимых значений по каждой оси.
* @param isBooked Признак брони по слоту (bookedKeys в компоненте).
*/
export function applySlotFilters(
rows: readonly SlotDTO[],
filters: SlotFilterState,
isBooked: (slot: SlotDTO) => boolean,
): SlotDTO[] {
return rows.filter(
(slot) =>
filters.status.has(slotStatusKey(slot, isBooked(slot))) &&
filters.satellites.has(slot.satelliteId) &&
filters.branches.has(slot.revolutionSign),
)
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { RevolutionSign } from "../../model/slotTypes";
import { groupPasses } from "./slotPasses";
/** Минимальный слот для группировки в витки. */
type S = {
satelliteId: number;
slotNumber: number;
cycle: number;
revolution: number;
revolutionSign: RevolutionSign;
tn: string;
};
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm от базовой даты; виток задаётся (revolution, sign, cycle). */
function slot(
satelliteId: number,
slotNumber: number,
mm: number,
revolution: number,
revolutionSign: RevolutionSign = "ASC",
cycle = 1,
): S {
return {
satelliteId,
slotNumber,
cycle,
revolution,
revolutionSign,
tn: `2026-06-17T10:${String(mm).padStart(2, "0")}:00`,
};
}
describe("groupPasses", () => {
it("пустой вход → пустой список", () => {
expect(groupPasses([], keyOf)).toEqual([]);
});
it("веер кренов одного оборота → один виток (крен не дробит)", () => {
// Три нацеливания одного прохода КА 1, оборот 100, восх — все в один виток.
const passes = groupPasses(
[slot(1, 1, 0, 100), slot(1, 2, 0, 100), slot(1, 3, 1, 100)],
keyOf,
);
expect(passes).toHaveLength(1);
expect(passes[0].id).toBe("1:1:100:ASC");
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
expect(passes[0].satelliteId).toBe(1);
expect(passes[0].revolution).toBe(100);
});
it("разные обороты / ветви / КА → разные витки", () => {
const passes = groupPasses(
[
slot(1, 1, 0, 100, "ASC"),
slot(1, 2, 5, 100, "DESC"), // та же revolution, другая ветвь
slot(1, 3, 10, 101, "ASC"), // другой оборот
slot(2, 4, 2, 100, "ASC"), // другой КА
],
keyOf,
);
expect(passes.map((p) => p.id)).toEqual([
"1:1:100:ASC", // 10:00
"2:1:100:ASC", // 10:02
"1:1:100:DESC", // 10:05
"1:1:101:ASC", // 10:10
]);
});
it("витки упорядочены по началу прохода (хронология по всем КА)", () => {
const passes = groupPasses(
[slot(1, 1, 30, 100), slot(2, 2, 5, 200), slot(1, 3, 15, 101)],
keyOf,
);
expect(passes.map((p) => p.startMs)).toEqual(
[...passes.map((p) => p.startMs)].sort((a, b) => a - b),
);
expect(passes[0].satelliteId).toBe(2); // самый ранний (10:05)
});
it("ключи внутри витка отсортированы по времени", () => {
const passes = groupPasses(
[slot(1, 3, 2, 100), slot(1, 1, 0, 100), slot(1, 2, 1, 100)],
keyOf,
);
expect(passes[0].keys).toEqual(["1:1:1", "1:2:1", "1:3:1"]);
});
});
@@ -0,0 +1,80 @@
// Группировка слотов в «витки» (проходы) для пошаговой навигации курсором ◀/▶.
//
// Виток (проход над районом) = слоты ОДНОГО КА на ОДНОМ обороте: ключ (satelliteId, cycle,
// revolution, revolutionSign) — ровно тот кортеж, что показан в колонке «Виток» таблицы. В отличие
// от полосы (slotStrips.ts: один КА + один крен + непрерывность по времени) виток НЕ дробится по
// крену — внутри одного прохода «веер» нацеливаний на разных кренах принадлежит одному витку.
//
// Это identity-группировка для навигации (встать на проход, шагнуть на следующий), НЕ проверка
// выполнимости брони. Порядок витков — хронологический по началу прохода (по всем КА вперемешку),
// чтобы «от первого к последнему» работало при нескольких КА.
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"
import { parseLocal } from "../../utils/localTime"
/** Минимальный набор полей слота, нужный для группировки в витки. */
type Passable = Pick<SlotDTO, "satelliteId" | "revolution" | "revolutionSign" | "cycle" | "tn">
/** Один виток (проход): кортеж витка + время начала и ключи слотов (упорядочены по времени). */
export type Pass = {
/** Стабильный id витка: `${sat}:${cycle}:${revolution}:${sign}`. */
id: string
satelliteId: number
revolution: number
revolutionSign: RevolutionSign
cycle: number
/** Начало прохода — минимальный tn среди слотов витка (мс, локально). */
startMs: number
/** Ключи слотов витка, отсортированы по времени начала. */
keys: string[]
}
/** Ключ витка из полей слота. */
function passId(slot: Passable): string {
return `${slot.satelliteId}:${slot.cycle}:${slot.revolution}:${slot.revolutionSign}`
}
/**
* Размечает слоты по виткам и возвращает витки в хронологическом порядке (по началу прохода).
*
* Детерминизм: порядок не зависит от входного массива внутри сортируем ключи витка по времени,
* сами витки по (startMs, id). Слоты без валидного времени игнорируем для startMs (берут + при
* сравнении внутри витка), но в keys попадают, чтобы виток не «потерял» слот.
*
* @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как slotKey в TguSlotsTab) чтобы не дублировать формулу.
*/
export function groupPasses<T extends Passable>(slots: readonly T[], keyOf: (slot: T) => string): Pass[] {
type Bucket = { slot: Passable; entries: { key: string; tnMs: number }[] }
const buckets = new Map<string, Bucket>()
for (const slot of slots) {
const id = passId(slot)
let bucket = buckets.get(id)
if (!bucket) {
bucket = { slot, entries: [] }
buckets.set(id, bucket)
}
bucket.entries.push({ key: keyOf(slot), tnMs: parseLocal(slot.tn) })
}
const passes: Pass[] = []
for (const [id, bucket] of buckets) {
bucket.entries.sort((a, b) => a.tnMs - b.tnMs || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
const startMs = bucket.entries.reduce(
(min, e) => (Number.isFinite(e.tnMs) ? Math.min(min, e.tnMs) : min),
Number.POSITIVE_INFINITY,
)
passes.push({
id,
satelliteId: bucket.slot.satelliteId,
revolution: bucket.slot.revolution,
revolutionSign: bucket.slot.revolutionSign,
cycle: bucket.slot.cycle,
startMs,
keys: bucket.entries.map((e) => e.key),
})
}
passes.sort((a, b) => a.startMs - b.startMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
return passes
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chunk, selectionAfterCheck } from "./slotSelection"; import { chunk, deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection";
const keyAt = (i: number) => `k${i}`; const keyAt = (i: number) => `k${i}`;
@@ -42,6 +42,83 @@ describe("selectionAfterCheck", () => {
}); });
}); });
describe("selectStripRange", () => {
const members = ["a", "b", "c", "d"]; // в порядке времени
it("выделяет членов полосы от первого к последнему включительно", () => {
const next = selectStripRange(new Set(), members, "b", "d");
expect([...next].sort()).toEqual(["b", "c", "d"]);
});
it("работает при обратном порядке кликов (последний → первый)", () => {
const next = selectStripRange(new Set(), members, "d", "b");
expect([...next].sort()).toEqual(["b", "c", "d"]);
});
it("один и тот же ключ → один член", () => {
const next = selectStripRange(new Set(), members, "c", "c");
expect([...next]).toEqual(["c"]);
});
it("сжимает выделение: повторный диапазон ближе к якорю снимает хвост полосы", () => {
// якорь "a", сначала растянули до "d" (a..d), потом Shift+клик по "b" → остаётся a..b, c и d сняты.
const expanded = selectStripRange(new Set(), members, "a", "d");
expect([...expanded].sort()).toEqual(["a", "b", "c", "d"]);
const shrunk = selectStripRange(expanded, members, "a", "b");
expect([...shrunk].sort()).toEqual(["a", "b"]);
});
it("снимает хвост полосы, но не трогает выбор в других полосах", () => {
const current = new Set(["a", "b", "c", "d", "x"]);
const next = selectStripRange(current, members, "a", "b");
expect([...next].sort()).toEqual(["a", "b", "x"]);
});
it("сохраняет уже выбранное за пределами полосы", () => {
const next = selectStripRange(new Set(["x"]), members, "a", "b");
expect([...next].sort()).toEqual(["a", "b", "x"]);
});
it("ключ вне полосы → выбор без изменений", () => {
const next = selectStripRange(new Set(["x"]), members, "a", "zzz");
expect([...next]).toEqual(["x"]);
});
it("не мутирует исходное множество", () => {
const current = new Set(["x"]);
selectStripRange(current, members, "a", "b");
expect([...current]).toEqual(["x"]);
});
});
describe("deselectStripRange", () => {
const members = ["a", "b", "c", "d"]; // в порядке времени
it("снимает ровно члены диапазона, остальной выбор не трогает", () => {
const current = new Set(["a", "b", "c", "d", "x"]);
const next = deselectStripRange(current, members, "b", "d");
expect([...next].sort()).toEqual(["a", "x"]);
});
it("повторный Shift+клик по тому же концу снимает то, что выделил selectStripRange", () => {
const selected = selectStripRange(new Set(["x"]), members, "a", "c"); // a,b,c,x
const cleared = deselectStripRange(selected, members, "a", "c");
expect([...cleared].sort()).toEqual(["x"]);
});
it("работает при обратном порядке (toKey раньше fromKey)", () => {
const next = deselectStripRange(new Set(["a", "b", "c", "d"]), members, "d", "b");
expect([...next].sort()).toEqual(["a"]);
});
it("ключ вне полосы → выбор без изменений; вход не мутируется", () => {
const current = new Set(["a", "b"]);
const next = deselectStripRange(current, members, "a", "zzz");
expect([...next].sort()).toEqual(["a", "b"]);
expect([...current].sort()).toEqual(["a", "b"]);
});
});
describe("chunk", () => { describe("chunk", () => {
it("бьёт массив на части заданного размера, последняя — остаток", () => { it("бьёт массив на части заданного размера, последняя — остаток", () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
@@ -39,6 +39,65 @@ export function selectionAfterCheck(
return next return next
} }
/**
* Новое множество выбора после жеста «полоса от первого к последнему»: выбор полосы становится
* РОВНО диапазоном [fromKey..toKey] (в порядке времени). Члены полосы вне диапазона снимаются
* так Shift+клик по слоту ближе к якорю «сжимает» выделение. Выбор за пределами полосы (другие
* полосы/КА) сохраняется: снимаем/добавляем только ключи из members. Жест ограничен одной полосой:
* вызывающий передаёт members именно её (один КА, один крен, подряд по времени); граница полосы
* гарантия «нельзя выделить не полосу». Если fromKey или toKey не из members выбор не меняется
* (страховка). Вход не мутируется.
*
* @param current Текущее множество выбранных ключей.
* @param members Ключи членов полосы в порядке времени.
* @param fromKey Ключ якоря (первый клик).
* @param toKey Ключ второго клика.
*/
export function selectStripRange(
current: ReadonlySet<string>,
members: readonly string[],
fromKey: string,
toKey: string,
): Set<string> {
const next = new Set(current)
const from = members.indexOf(fromKey)
const to = members.indexOf(toKey)
if (from < 0 || to < 0) return next
const lo = Math.min(from, to)
const hi = Math.max(from, to)
members.forEach((key, i) => {
if (i >= lo && i <= hi) next.add(key)
else next.delete(key)
})
return next
}
/**
* Снимает выбор с диапазона полосы [fromKey..toKey] (в порядке времени). Зеркало selectStripRange:
* убирает ровно члены диапазона, не трогая остальной выбор. Нужно для повторного Shift+клика по тому
* же концу он снимает выделенный диапазон. Если fromKey/toKey не из members выбор не меняется.
*
* @param current Текущее множество выбранных ключей.
* @param members Ключи членов полосы в порядке времени.
* @param fromKey Ключ якоря (первый клик).
* @param toKey Ключ второго клика.
*/
export function deselectStripRange(
current: ReadonlySet<string>,
members: readonly string[],
fromKey: string,
toKey: string,
): Set<string> {
const next = new Set(current)
const from = members.indexOf(fromKey)
const to = members.indexOf(toKey)
if (from < 0 || to < 0) return next
const lo = Math.min(from, to)
const hi = Math.max(from, to)
for (let i = lo; i <= hi; i++) next.delete(members[i])
return next
}
/** /**
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов). * Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
* @param items Исходный массив. * @param items Исходный массив.
@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import {
ROLL_EPSILON,
groupStrips,
pickStripAnchor,
pickStripEnd,
type StripGrouping,
} from "./slotStrips";
/** Минимальный слот для группировки. tn задаём как локальную ISO-строку (как отдаёт бэкенд). */
type S = {
satelliteId: number;
slotNumber: number;
cycle: number;
tn: string;
tk: string;
roll: number;
};
const keyOf = (s: S) => `${s.satelliteId}:${s.slotNumber}:${s.cycle}`;
/** Слот на минуту mm (от базовой даты) с длительностью 30 c. cycle ≈ «виток» (в границу не входит). */
function slot(satelliteId: number, slotNumber: number, roll: number, mm: number, cycle = 1): S {
const tn = `2026-06-17T10:${String(mm).padStart(2, "0")}:00`;
const tk = `2026-06-17T10:${String(mm).padStart(2, "0")}:30`;
return { satelliteId, slotNumber, cycle, tn, tk, roll };
}
/** Разбиение на полосы как множество множеств ключей — для сравнения, не зависящего от имён PolosaId. */
function partition(g: StripGrouping): string[][] {
const byStrip = new Map<string, string[]>();
for (const [key, strip] of g.idByKey) {
const arr = byStrip.get(strip) ?? [];
arr.push(key);
byStrip.set(strip, arr);
}
return [...byStrip.values()].map((keys) => keys.slice().sort()).sort((a, b) => (a[0]! < b[0]! ? -1 : 1));
}
describe("groupStrips", () => {
it("пустой вход → пустая разметка", () => {
const g = groupStrips([], keyOf);
expect(g.idByKey.size).toBe(0);
expect(g.membersOf.size).toBe(0);
});
it("один слот → своя полоса", () => {
const g = groupStrips([slot(1, 1, 5, 0)], keyOf);
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1"]);
});
it("веер кренов в один момент → разные полосы; равный крен вдоль времени → одна полоса", () => {
// КА 1: два нацеливания (крен 36.5 и 39.5, шаг 3° > ε), каждое снято в моменты mm=0 и mm=1.
const slots = [
slot(1, 1, 36.5, 0),
slot(1, 2, 39.5, 0),
slot(1, 3, 36.5, 1),
slot(1, 4, 39.5, 1),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:3:1"], // крен 36.5 вдоль времени
["1:2:1", "1:4:1"], // крен 39.5 вдоль времени
]);
});
it("смена витка (cycle) внутри одного крена и каденции НЕ рвёт полосу", () => {
const slots = [
slot(1, 1, 5, 0, 1),
slot(1, 2, 5, 1, 1),
slot(1, 3, 5, 2, 2), // виток сменился у экватора — но это та же полоса
slot(1, 4, 5, 3, 2),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1", "1:3:2", "1:4:2"],
]);
});
it("большой разрыв по времени рвёт полосу на две (другой проход)", () => {
const slots = [
slot(1, 1, 5, 0),
slot(1, 2, 5, 1),
slot(1, 3, 5, 2),
slot(1, 4, 5, 3),
slot(1, 5, 5, 30), // разрыв ≫ каденции (1 мин) → новый проход
slot(1, 6, 5, 31),
slot(1, 7, 5, 32),
];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1", "1:3:1", "1:4:1"],
["1:5:1", "1:6:1", "1:7:1"],
]);
});
it("разные КА — разные полосы (даже при одном крене и времени)", () => {
const slots = [slot(1, 1, 5, 0), slot(2, 1, 5, 0), slot(1, 2, 5, 1), slot(2, 2, 5, 1)];
expect(partition(groupStrips(slots, keyOf))).toEqual([
["1:1:1", "1:2:1"],
["2:1:1", "2:2:1"],
]);
});
it("граница ε по крену: разрыв == 0.5 склеивает, 0.6 рвёт", () => {
const same = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON, 1)], keyOf);
expect(partition(same)).toEqual([["1:1:1", "1:2:1"]]);
const broken = groupStrips([slot(1, 1, 5.0, 0), slot(1, 2, 5 + ROLL_EPSILON + 0.1, 1)], keyOf);
expect(partition(broken)).toEqual([["1:1:1"], ["1:2:1"]]);
});
it("устойчивость к порядку входа: разбиение одинаково при перемешивании", () => {
const slots = [slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 20, 0), slot(2, 1, 5, 0)];
const a = partition(groupStrips(slots, keyOf));
const b = partition(groupStrips([...slots].reverse(), keyOf));
const c = partition(groupStrips([slots[2]!, slots[0]!, slots[3]!, slots[1]!], keyOf));
expect(b).toEqual(a);
expect(c).toEqual(a);
});
it("PolosaId = polosa:${ключ первого по времени члена}; membersOf — в порядке времени", () => {
const g = groupStrips([slot(1, 2, 5, 1), slot(1, 1, 5, 0)], keyOf);
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
expect(g.membersOf.get("polosa:1:1:1")).toEqual(["1:1:1", "1:2:1"]);
});
it("NaN tn → синглтон без падения, не клеит и не рвёт валидных соседей", () => {
const broken: S = { satelliteId: 1, slotNumber: 9, cycle: 1, tn: "битый", tk: "битый", roll: 5 };
const slots = [slot(1, 1, 5, 0), broken, slot(1, 2, 5, 1)];
const g = groupStrips(slots, keyOf);
expect(g.idByKey.get("1:9:1")).toBe("polosa:1:9:1");
expect(g.idByKey.get("1:1:1")).toBe("polosa:1:1:1");
expect(g.idByKey.get("1:2:1")).toBe("polosa:1:1:1");
});
});
describe("pickStripAnchor", () => {
const tn = (m: Record<string, number>) => (key: string) => m[key] ?? Number.NaN;
it("берёт самый ранний по времени слот под курсором", () => {
const got = pickStripAnchor(["b", "a", "c"], tn({ a: 200, b: 100, c: 300 }));
expect(got).toBe("b");
});
it("равное время (веер кренов в один момент) → тай-брейк по ключу", () => {
const got = pickStripAnchor(["b", "a"], tn({ a: 100, b: 100 }));
expect(got).toBe("a");
});
it("слоты без валидного времени игнорируются", () => {
const got = pickStripAnchor(["bad", "x"], tn({ x: 500 }));
expect(got).toBe("x");
});
it("пустой / полностью невалидный вход → null", () => {
expect(pickStripAnchor([], tn({}))).toBeNull();
expect(pickStripAnchor(["bad"], tn({}))).toBeNull();
});
});
describe("pickStripEnd", () => {
// Полоса 1:1:1 = [1:1:1, 1:2:1, 1:3:1] по времени; другой КА 2 — отдельная полоса.
const g = groupStrips(
[slot(1, 1, 5, 0), slot(1, 2, 5, 1), slot(1, 3, 5, 2), slot(2, 1, 5, 0)],
keyOf,
);
const anchorId = g.idByKey.get("1:1:1")!;
it("берёт самого позднего по времени члена полосы под курсором", () => {
expect(pickStripEnd(g, anchorId, ["1:1:1", "1:3:1"])).toBe("1:3:1");
expect(pickStripEnd(g, anchorId, ["1:1:1", "1:2:1"])).toBe("1:2:1");
});
it("игнорирует ключи не из своей полосы (граница полосы)", () => {
expect(pickStripEnd(g, anchorId, ["2:1:1"])).toBeNull();
expect(pickStripEnd(g, anchorId, ["1:2:1", "2:1:1"])).toBe("1:2:1");
});
it("никого из полосы под курсором → null (клампим к прежнему концу)", () => {
expect(pickStripEnd(g, anchorId, [])).toBeNull();
expect(pickStripEnd(g, "polosa:нет", ["1:1:1"])).toBeNull();
});
});
@@ -0,0 +1,208 @@
// Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью.
//
// Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва
// каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в
// одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени).
//
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости
// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service
// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать
// физику на клиенте.
import type { SlotDTO } from "../../model/slotTypes";
import { parseLocal } from "../../utils/localTime";
/**
* Допуск сравнения крена (градусы): слоты считаются «одним креном», если разрыв по крену между
* соседями кластера ROLL_EPSILON. Подобран по реальным данным «веер» нацеливаний идёт с шагом
* ~3°, поэтому 0.5° надёжно разделяет соседние нацеливания и терпит дрейф крена внутри полосы.
*/
export const ROLL_EPSILON = 0.5;
/**
* Во сколько раз разрыв по времени должен превысить типичную каденцию полосы, чтобы считаться
* пропуском и разорвать полосу на две. Полоса это слоты подряд (через равные шаги по времени);
* заметно больший разрыв означает «другой проход», т.е. другую полосу.
*/
export const CADENCE_GAP_FACTOR = 1.5;
/** Идентификатор полосы. Стабилен и читаем: `polosa:${slotKey первого по времени члена}`. */
export type PolosaId = string;
/**
* Результат разметки: id полосы по ключу слота и упорядоченные по времени члены каждой полосы
* (членение нужно жесту «выбрать от первого к последнему» взять срез между двумя кликами).
*/
export type StripGrouping = {
idByKey: Map<string, PolosaId>;
membersOf: Map<PolosaId, string[]>;
};
/** Минимальный набор полей слота, нужный для группировки. */
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;
type Item = { key: string; sat: number; roll: number; tnMs: number };
function cmpStr(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
/** Медиана массива (для типичной каденции полосы). Пустой массив → 0. */
function median(values: readonly number[]): number {
if (values.length === 0) return 0;
const s = [...values].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
/**
* Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по
* времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × CADENCE_GAP_FACTOR).
* До двух членов каденцию определить нельзя считаем их одной полосой.
*/
function splitByCadence(cluster: readonly Item[]): Item[][] {
const sorted = [...cluster].sort((a, b) => a.tnMs - b.tnMs || cmpStr(a.key, b.key));
if (sorted.length <= 2) return [sorted];
const gaps: number[] = [];
for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i].tnMs - sorted[i - 1].tnMs);
const cadence = median(gaps.filter((g) => g > 0));
const threshold = cadence > 0 ? cadence * CADENCE_GAP_FACTOR : Infinity;
const runs: Item[][] = [];
let run: Item[] = [sorted[0]];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].tnMs - sorted[i - 1].tnMs > threshold) {
runs.push(run);
run = [];
}
run.push(sorted[i]);
}
runs.push(run);
return runs;
}
/**
* Размечает слоты по полосам. Полоса слоты одного КА с одним креном (кластеризация по крену с
* допуском ROLL_EPSILON), идущие подряд по времени (разрыв каденции рвёт полосу). Виток/цикл не
* учитываются полоса может пересекать смену витка.
*
* Детерминизм: результат не зависит от порядка входного массива внутри сортируем по крену и времени,
* id полосы = ключ её первого по времени члена.
*
* @param slots Слоты для разметки (любой порядок).
* @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) чтобы не дублировать
* формулу `${satelliteId}:${slotNumber}:${cycle}`.
*/
export function groupStrips<T extends Stripable>(
slots: readonly T[],
keyOf: (slot: T) => string,
): StripGrouping {
const idByKey = new Map<string, PolosaId>();
const membersOf = new Map<PolosaId, string[]>();
if (slots.length === 0) return { idByKey, membersOf };
const assign = (members: readonly Item[]) => {
const keys = members.map((m) => m.key);
const id: PolosaId = `polosa:${keys[0]}`;
membersOf.set(id, keys);
for (const k of keys) idByKey.set(k, id);
};
const bySat = new Map<number, Item[]>();
for (const slot of slots) {
const item: Item = {
key: keyOf(slot),
sat: slot.satelliteId,
roll: slot.roll,
tnMs: parseLocal(slot.tn),
};
// Слот без валидного времени не встроить в полосу по непрерывности — отдельная полоса-синглтон.
if (Number.isNaN(item.tnMs)) {
assign([item]);
continue;
}
const arr = bySat.get(item.sat) ?? [];
arr.push(item);
bySat.set(item.sat, arr);
}
for (const sat of Array.from(bySat.keys()).sort((a, b) => a - b)) {
const items = bySat.get(sat)!;
// Кластеризация по крену: сортируем по roll, начинаем новый кластер при разрыве > ROLL_EPSILON.
items.sort((a, b) => a.roll - b.roll || a.tnMs - b.tnMs || cmpStr(a.key, b.key));
let cluster: Item[] = [];
let prevRoll = Number.NaN;
const flush = () => {
if (cluster.length > 0) for (const run of splitByCadence(cluster)) assign(run);
};
for (const item of items) {
if (cluster.length > 0 && item.roll - prevRoll > ROLL_EPSILON) {
flush();
cluster = [];
}
cluster.push(item);
prevRoll = item.roll;
}
flush();
}
return { idByKey, membersOf };
}
/**
* Якорь полосы для жеста на карте: из ключей слотов под курсором берём самый ранний по времени
* это «первый слот полосы» (правило оператора: при наложении начало = первый по времени). Слоты без
* валидного времени игнорируем; тай-брейк при равном времени (веер кренов в один момент) по ключу
* (детерминизм). Пустой/полностью невалидный вход null.
*
* @param candidateKeys Ключи слотов под курсором (порядок не важен).
* @param tnMsOf Время начала слота в мс (как parseLocal(tn)); NaN слот пропускаем.
*/
export function pickStripAnchor(
candidateKeys: readonly string[],
tnMsOf: (key: string) => number,
): string | null {
let best: string | null = null;
let bestTn = Number.POSITIVE_INFINITY;
for (const key of candidateKeys) {
const tn = tnMsOf(key);
if (Number.isNaN(tn)) continue;
if (best === null || tn < bestTn || (tn === bestTn && key < best)) {
best = key;
bestTn = tn;
}
}
return best;
}
/**
* Конец полосы для жеста на карте: из ключей под курсором берём члена полосы anchorId, самого
* ПОЗДНЕГО по времени (правило оператора: при наложении конец = последний по времени). Время = индекс
* в membersOf (он уже отсортирован по времени), поэтому берём максимальный индекс. Ключи не из этой
* полосы игнорируем так выделение не уезжает за границу полосы. Никого из полосы под курсором null
* (вызывающий оставляет прежний конец «клампим» к последнему валидному).
*
* @param grouping Разметка полос (из groupStrips).
* @param anchorId Полоса якоря.
* @param candidateKeys Ключи слотов под курсором.
*/
export function pickStripEnd(
grouping: StripGrouping,
anchorId: PolosaId,
candidateKeys: readonly string[],
): string | null {
const members = grouping.membersOf.get(anchorId);
if (!members) return null;
let best: string | null = null;
let bestIdx = -1;
for (const key of candidateKeys) {
if (grouping.idByKey.get(key) !== anchorId) continue;
const idx = members.indexOf(key);
if (idx > bestIdx) {
bestIdx = idx;
best = key;
}
}
return best;
}
@@ -10,8 +10,19 @@ export type RevolutionSign = "ASC" | "DESC";
*/ */
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS"; export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
/** Статус слота из расчёта покрытия. */ /** Статус слота из расчёта покрытия (slots-service: SlotStatus). BUSY слот занят чужой/этой
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string; * бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string;
/**
* Держатель занятого (BUSY) слота заявка, чья бронь блокирует слот (slots-service: SlotHolderDTO).
* name/importance могут отсутствовать, если request-service недоступен (деградация показываем id).
*/
export type SlotHolder = {
requestId: string;
name?: string | null;
importance?: number | null;
};
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */ /** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
export type SlotDTO = { export type SlotDTO = {
@@ -28,6 +39,8 @@ export type SlotDTO = {
state: SlotStatus; state: SlotStatus;
latitude: number; latitude: number;
longitude: number; longitude: number;
/** Держатели BUSY-слота (обзор конфликтов, all=true). Пусто/нет для свободных и брони. */
holders?: SlotHolder[] | null;
}; };
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */ /** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
@@ -38,6 +51,13 @@ export type BookedSlotDTO = {
cycle: number; cycle: number;
}; };
/**
* Забронированный слот для отображения и снятия (slots-service: BookedSlotViewDTO,
* GET booking/by-request). Поля слота (контур, координаты) + bookedSlotId для снятия по id.
* cycle здесь цикл БРОНИ, поэтому slotKey совпадает с ответом POST booking/request.
*/
export type BookedSlotView = SlotDTO & { bookedSlotId: number };
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */ /** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
export type SlotBookingRequest = { export type SlotBookingRequest = {
satelliteId: number; satelliteId: number;
@@ -60,4 +80,6 @@ export type RequestSlotsParams = {
sun?: number; sun?: number;
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */ /** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
coverageStrategy?: SlotCoverageStrategy; coverageStrategy?: SlotCoverageStrategy;
/** true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»). */
all?: boolean;
}; };
@@ -468,6 +468,65 @@
line-height: 1; line-height: 1;
} }
/* Контейнер контролов поверх карты слотов: фильтр «Сутки» + степпер витков столбиком.
Нейтрализуем абсолютное позиционирование вложенных .map-pass-selector стопку держит контейнер. */
.tgu-slots-map-controls {
position: absolute;
top: 0.55rem;
left: 0.55rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.tgu-slots-map-controls .map-pass-selector {
position: static;
}
/* Подпись прохождения (КА · оборот · ветвь · дата-время) во всю ширину, без обрезки
дата/время чувствительны для оператора, должны быть видны целиком. */
.tgu-pass-stepper__label {
padding: 0 0.65rem;
color: var(--text);
font-family: var(--mono);
font-size: 0.72rem;
line-height: 1.35;
white-space: normal;
overflow-wrap: anywhere;
}
/* Степпер витков: ряд навигации ◀ ▶ под шапкой-подписью. */
.tgu-pass-stepper__nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1.2rem;
padding: 0.1rem 0.65rem 0.5rem;
}
.tgu-pass-stepper__btn {
flex-shrink: 0;
width: 2rem;
height: 1.7rem;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--bg-elevated);
color: var(--text);
font-size: 0.8rem;
cursor: pointer;
}
.tgu-pass-stepper__btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.tgu-pass-stepper__btn:disabled {
opacity: 0.4;
cursor: default;
}
.tgu-editor-map-stage .tgu-2d-map { .tgu-editor-map-stage .tgu-2d-map {
min-height: 6rem; min-height: 6rem;
} }
@@ -963,12 +963,162 @@
gap: 0.35rem; gap: 0.35rem;
} }
/* Панель покрытия над списком (D-D1). Цвет = слой coverage на карте (violet) — единый язык «покрыто». */
.tgu-coverage {
padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
font-size: 0.78rem;
}
.tgu-coverage__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.tgu-coverage__title {
color: var(--text-muted);
font-weight: 600;
}
.tgu-coverage__toggles {
display: inline-flex;
align-items: center;
gap: 0.75rem;
}
.tgu-coverage__toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--text-dim);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.tgu-coverage__toggle input {
accent-color: rgb(167, 139, 219);
}
.tgu-coverage__bar {
position: relative;
display: flex;
height: 0.5rem;
border-radius: 999px;
overflow: hidden;
background: rgba(167, 139, 219, 0.12);
border: 1px solid var(--line);
}
.tgu-coverage__seg--booked {
background: rgba(167, 139, 219, 0.65);
}
.tgu-coverage__seg--sel {
background: rgba(167, 139, 219, 0.32);
}
.tgu-coverage__legend {
margin-top: 0.3rem;
color: var(--text-dim);
}
.tgu-coverage__booked {
color: rgb(186, 164, 230);
font-weight: 600;
}
.tgu-coverage__sel {
color: var(--text-muted);
}
.tgu-coverage__low {
color: var(--text-dim);
font-style: italic;
}
.tgu-coverage__unavailable {
color: var(--text-dim);
}
.tgu-slots-empty { .tgu-slots-empty {
padding: 1rem; padding: 1rem;
color: var(--text-dim); color: var(--text-dim);
font-size: 0.82rem; font-size: 0.82rem;
} }
.tgu-slots-empty__action {
margin-top: 0.6rem;
}
/* Панель фильтров видимости (статус/КА/ветвь) — над строкой выбора. Сужает таблицу и карту. */
.tgu-slots-filterbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.7rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-slots-filterbar__group {
display: flex;
align-items: center;
gap: 0.3rem;
}
.tgu-slots-filterbar__label {
color: var(--text-dim);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tgu-slots-filterbar__reset {
margin-left: auto;
}
/* Чип фильтра паттерн пилюли тулбара (.tgu-map-toolbar__toggles). Неактивный = скрыто
фильтром (приглушён). Статус-чипы в активном виде повторяют цвет бейджа статуса в таблице. */
.tgu-slots-chip {
min-height: 1.7rem;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.18rem 0.6rem;
background: #101214;
color: var(--text-muted);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
opacity: 0.5;
}
.tgu-slots-chip:hover {
opacity: 0.8;
}
.tgu-slots-chip.is-active {
opacity: 1;
color: var(--text);
}
.tgu-slots-chip.is-booked.is-active {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
.tgu-slots-chip.is-occupied.is-active {
border-color: rgba(214, 69, 69, 0.5);
background: rgba(214, 69, 69, 0.14);
color: #e98a8a;
}
/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */ /* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */
.tgu-slots-request { .tgu-slots-request {
align-items: center; align-items: center;
@@ -1099,6 +1249,8 @@
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.78rem; font-size: 0.78rem;
/* Моноширинные цифры — чтобы время, длительность и крен выравнивались по разрядам. */
font-variant-numeric: tabular-nums;
} }
.tgu-slots-table th, .tgu-slots-table th,
@@ -1109,12 +1261,32 @@
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
/* Числовые колонки (Длит., Крен) — по правому краю, чтобы значения сравнивались взглядом. */
.tgu-slots-table th:nth-child(5),
.tgu-slots-table td:nth-child(5),
.tgu-slots-table th:nth-child(6),
.tgu-slots-table td:nth-child(6) {
text-align: right;
}
/* Колонка «Вклад» (+N%) — по правому краю, цвет совпадает со слоем покрытия (violet). */
.tgu-slots-gain-col,
.tgu-slots-gain {
text-align: right;
}
.tgu-slots-gain {
color: rgb(167, 139, 219);
font-weight: 600;
}
.tgu-slots-table thead th { .tgu-slots-table thead th {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
background: var(--bg-panel); background: var(--bg-panel);
color: var(--text-dim); /* --text-muted (#a4b0b1) даёт ~6:1 на панели — заголовки читаются (AA), --text-dim был ~4.3:1. */
color: var(--text-muted);
font-weight: 600; font-weight: 600;
} }
@@ -1140,6 +1312,37 @@
background: rgba(104, 195, 189, 0.36); background: rgba(104, 195, 189, 0.36);
} }
/* Строки таблицы поверхность жеста выбора полосы (клик + Shift+клик), поэтому гасим выделение
текста, чтобы Shift+клик не подсвечивал текст между строками. */
.tgu-slots-table tbody {
user-select: none;
}
/* Рельс полосы текущего якоря: приглушённая цветная полоса слева у всех членов полосы, в которой
стоит якорь жеста «полоса от первого к последнему». Оттенок детерминирован PolosaId (--strip-hue).
Так оператор видит границу полосы что выделит Shift+клик и за что выйти нельзя. У активной
строки рельс перекрывается акцентным (is-active), это норма. */
.tgu-slots-table tbody tr.tgu-slots-row--strip {
box-shadow: inset 3px 0 0 hsl(var(--strip-hue, 0) 45% 55% / 0.55);
}
/* Строка-якорь полосы (первый клик жеста) — чуть заметнее остальных членов полосы. */
.tgu-slots-table tbody tr.is-strip-anchor {
background: rgba(104, 195, 189, 0.12);
}
/* Подсветка строк текущего витка (курсор / степпера): мягкая заливка прохода целиком, чтобы
оператор видел все слоты одного прохода. Активная строка (is-active) перекрывает её это норма. */
.tgu-slots-table tbody tr.tgu-slots-row--pass {
background: rgba(120, 150, 220, 0.14);
}
/* Конфликт с текущим выбором (тот же КА, пересечение по времени): строка тусклая оператор видит,
что выбор делает её недоступной. Слот остаётся выбираемым (правило приближённое, запрет за бэком). */
.tgu-slots-table tbody tr.tgu-slots-row--conflict td {
opacity: 0.4;
}
.tgu-slots-badge { .tgu-slots-badge {
display: inline-block; display: inline-block;
padding: 0.1rem 0.45rem; padding: 0.1rem 0.45rem;
@@ -1156,6 +1359,37 @@
color: var(--accent-strong); color: var(--accent-strong);
} }
/* Занятый слот приглушённо-красный: оператор сразу видит, что бронировать его нельзя.
Свободные остаются нейтральными (их большинство), цветом выделяем именно исключения. */
.tgu-slots-badge--occupied {
border-color: rgba(214, 69, 69, 0.5);
background: rgba(214, 69, 69, 0.14);
color: #e98a8a;
}
/* Держатель BUSY-слота (M3): кто блокирует слот. Тот же приглушённо-красный регистр, что и
бейдж «недоступен» (--occupied), но мягче это уточнение к статусу, не отдельный сигнал. */
.tgu-slots-holder {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid rgba(214, 69, 69, 0.32);
background: rgba(214, 69, 69, 0.08);
color: #e98a8a;
font-size: 0.7rem;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgu-slots-holder__imp {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.tgu-map-stage { .tgu-map-stage {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -1216,6 +1450,14 @@
stroke-dasharray: 6 4; stroke-dasharray: 6 4;
} }
/* Конфликтующие с выбором слоты приглушённый серый «налёт»: слот гаснет, оператор видит, что он
стал недоступен из-за текущего выбора. Отличается и от зелёной брони, и от красного BUSY. */
.tgu-2d-map__overlay polygon.is-dimmed {
fill: rgba(20, 24, 28, 0.55);
stroke: rgba(120, 130, 140, 0.5);
stroke-width: 1;
}
.tgu-2d-map__overlay polyline.is-selected { .tgu-2d-map__overlay polyline.is-selected {
stroke: #f0ad2e; stroke: #f0ad2e;
stroke-width: 7; stroke-width: 7;
@@ -1385,7 +1627,8 @@
position: absolute; position: absolute;
z-index: 5; z-index: 5;
top: 0.85rem; top: 0.85rem;
left: 0.85rem; left: 50%;
transform: translateX(-50%);
max-width: 23rem; max-width: 23rem;
border: 1px solid rgba(240, 173, 46, 0.42); border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px; border-radius: 7px;
@@ -1402,23 +1645,6 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* Подсказка о перегрузе карты слотами (карту не блокируем). */
.tgu-slots-map-hint {
position: absolute;
z-index: 5;
left: 50%;
bottom: 0.85rem;
transform: translateX(-50%);
max-width: 90%;
padding: 0.4rem 0.7rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
/* Баннер подтверждения крупной брони. */ /* Баннер подтверждения крупной брони. */
.tgu-slots-confirm { .tgu-slots-confirm {
position: absolute; position: absolute;
@@ -1443,6 +1669,83 @@
gap: 0.4rem; gap: 0.4rem;
} }
/* Прогресс-бар чанковой операции (бронь и снятие). В одной палитре и позиции с оверлеем
«Расчёт слотов»: сверху по центру, янтарный на тёмном чтобы не перекрывать нижнюю подсказку
«много слотов в окне» и читаться как одно семейство уведомлений. */
.tgu-chunk-progress {
position: absolute;
z-index: 6;
top: 0.85rem;
left: 50%;
transform: translateX(-50%);
width: min(23rem, 80%);
padding: 0.65rem 0.75rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.82rem;
backdrop-filter: blur(6px);
}
.tgu-chunk-progress__head {
display: flex;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.tgu-chunk-progress__count {
color: rgba(255, 231, 181, 0.7);
font-variant-numeric: tabular-nums;
}
.tgu-chunk-progress__track {
height: 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.tgu-chunk-progress__fill {
height: 100%;
border-radius: 3px;
background: #f0ad2e;
transition: width 0.15s ease;
}
/* Ячейка статуса брони с кнопкой снятия одного слота. */
.tgu-slots-status-cell {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.tgu-slots-cancel-one {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
padding: 0;
border: 1px solid var(--line);
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
}
.tgu-slots-cancel-one:hover:not(:disabled) {
border-color: var(--danger, #c0524f);
color: var(--danger, #c0524f);
}
.tgu-slots-cancel-one:disabled {
opacity: 0.4;
cursor: default;
}
.tgu-map-errors { .tgu-map-errors {
display: grid; display: grid;
gap: 0.4rem; gap: 0.4rem;
@@ -31,6 +31,18 @@ export function formatLocalDateTime(ms: number): string {
}); });
} }
/** Дата-время с секундами (дд.мм.гггг, чч:мм:сс) — для коротких слотов, где важны секунды. */
export function formatLocalDateTimeSeconds(ms: number): string {
return new Date(ms).toLocaleString("ru-RU", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
/** Дата (дд.мм) в локальной зоне. */ /** Дата (дд.мм) в локальной зоне. */
export function formatLocalDate(ms: number): string { export function formatLocalDate(ms: number): string {
return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
@@ -50,8 +50,16 @@ class BookingController {
@PostMapping() @PostMapping()
fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req) fun bookSlot(@RequestBody req : SlotBookingRequestDTO) = slotService.bookSlot(req)
// Снятие одной брони: request-aware (см. SlotService.cancelSlot) — нужен requestId,
// чтобы при шаренном слоте порвать только связь этой заявки, а не снести бронь у других.
@DeleteMapping @DeleteMapping
fun cancelSlot(@RequestParam("id") id : Long) = slotService.cancelSlot(id) fun cancelSlot(@RequestParam("id") id : Long, @RequestParam requestId : String) =
slotService.cancelSlot(requestId, id)
// Снятие выбранных броней пачкой (фронт чанкует по 200). Список id в query (?ids=1&ids=2),
// а не в теле — тело у DELETE через gateway ненадёжно.
@DeleteMapping("batch")
fun cancelBatch(@RequestParam requestId : String, @RequestParam ids : List<Long>) =
slotService.cancelReqSlots(requestId, ids)
@PostMapping("request") @PostMapping("request")
fun bookReq(@RequestBody req : BookingRequestDTO) = fun bookReq(@RequestBody req : BookingRequestDTO) =
slotService.bookReq(req) slotService.bookReq(req)
@@ -103,8 +103,10 @@ class SlotController {
@RequestParam("sun") sun : Double?, @RequestParam("sun") sun : Double?,
@Parameter(description = "Вариант расчета покрытия") @Parameter(description = "Вариант расчета покрытия")
@RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?, @RequestParam("coverageStrategy", required = false) coverageStrategy: SlotCoverageStrategy?,
@Parameter(description = "Вернуть и занятые бронью слоты (BUSY) — для обзора; по умолчанию только доступные/забронированные")
@RequestParam("all", required = false) all: Boolean?,
) : List<SlotDTO> ) : List<SlotDTO>
= slotService.reqCover(requestId, timeStart, timeStop, sign, cov, satellites, sun, coverageStrategy).toList() = slotService.reqCover(requestId, timeStart, timeStop, sign, cov, satellites, sun, coverageStrategy, all == true).toList()
@GetMapping("interval") @GetMapping("interval")
@Operation(summary = "Получение всех слотов на заданном интервале времени") @Operation(summary = "Получение всех слотов на заданном интервале времени")
@@ -51,4 +51,8 @@ class BookedSlotEntity(
slotNum = slot.slotNum, slotNum = slot.slotNum,
cycle = cycle cycle = cycle
) )
// Представление брони для фронта (контур/координаты + bookedSlotId + cycle брони) строится в
// SlotService.toShiftedViewDTO — там доступен КА для сдвига tn/tk/витка на cycle брони. В сущности
// строить нельзя: без сдвига получался базовый cycle-фрейм и дата брони расходилась с расчётом.
} }
@@ -6,6 +6,7 @@ import org.hibernate.annotations.FetchMode
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser import space.nstart.pcp.pcp_types_lib.utils.wkt.WKTParser
import space.nstart.pcp.slots_service.model.Mar import space.nstart.pcp.slots_service.model.Mar
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -104,7 +105,7 @@ class SlotEntity(
longitude longitude
) )
fun toDTO(booked : Boolean, band : Boolean) = SlotDTO( fun toDTO(booked : Boolean, band : Boolean, holders : List<SlotHolderDTO>? = null) = SlotDTO(
cycle, cycle,
satelliteId, satelliteId,
tn, tn,
@@ -116,6 +117,7 @@ class SlotEntity(
slotNum, slotNum,
state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE, state = if (booked) SlotStatus.BOOKED else if (band) SlotStatus.BUSY else SlotStatus.AVAILABLE,
latitude, latitude,
longitude longitude,
holders = holders
) )
} }
@@ -5,6 +5,7 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
import space.nstart.pcp.slots_service.entity.BookedSlotEntity import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.BookedSlot import space.nstart.pcp.slots_service.model.BookedSlot
@@ -38,11 +39,28 @@ class TestSatelliteImpl(
states : List<SlotStatus>? states : List<SlotStatus>?
): List<PreparedCoverageSlot> { ): List<PreparedCoverageSlot> {
class BookedInfo(val id : Long, val cycle : Long) class BookedInfo(val id : Long, val cycle : Long)
// Держатель блокирующей брони: сдвинутый на cycle интервал + крен + заявки-держатели.
// Нужен, чтобы у BUSY-слота показать, чья бронь его блокирует (M3 — обзор конфликтов).
class HolderInterval(
val start : LocalDateTime,
val end : LocalDateTime,
val roll : Double,
val requestIds : List<String>
)
val selectedSlots = mutableListOf<PreparedCoverageSlot>() val selectedSlots = mutableListOf<PreparedCoverageSlot>()
val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc
val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc
val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES } val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES }
val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) }
val holderIntervals = activeBookedSlots.map { bookedSlot ->
val offsetDays = bookedSlot.cycle * durationCalc
HolderInterval(
bookedSlot.slot.tn.plusDays(offsetDays),
bookedSlot.slot.tk.plusDays(offsetDays),
bookedSlot.slot.roll,
bookedSlot.requests.orEmpty().map { it.requestId }.distinct()
)
}
val bookedData = activeBookedSlots.map { bookedSlot -> val bookedData = activeBookedSlots.map { bookedSlot ->
val offsetDays = bookedSlot.cycle * durationCalc val offsetDays = bookedSlot.cycle * durationCalc
BookedSlot( BookedSlot(
@@ -94,11 +112,27 @@ class TestSatelliteImpl(
} }
} }
} }
// Для BUSY-слота собираем держателей: активные брони, чей сдвинутый интервал
// пересекается со слотом (с учётом mmi) — та же логика наложения, что и в band.
// distinct по requestId: одну бронь могут держать несколько заявок (множ. держатель).
val holders = if (band) {
holderIntervals
.filter { holder ->
intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), holder.start, holder.end)
}
.flatMap { holder -> holder.requestIds }
.distinct()
.map { reqId -> SlotHolderDTO(reqId) }
.takeIf { holders -> holders.isNotEmpty() }
} else {
null
}
PreparedCoverageSlot( PreparedCoverageSlot(
sourceSlotId = it.slotId ?: 0L, sourceSlotId = it.slotId ?: 0L,
slot = it.toDTO( slot = it.toDTO(
isBooked, isBooked,
band band,
holders
) )
) )
}) })
@@ -9,6 +9,7 @@ import reactor.core.publisher.Flux
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.RequestDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO import space.nstart.pcp.pcp_types_lib.dto.requests.RequestWithCellsDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotHolderDTO
import java.util.UUID import java.util.UUID
@@ -67,6 +68,32 @@ class EarthService(
return Flux.fromIterable(requests) return Flux.fromIterable(requests)
} }
/**
* Имя/важность заявок-держателей по идентификаторам (M3 обзор конфликтов BUSY-слотов).
* Один батч-проход по списку заявок (summary уже несёт name+importance без N+1 на деталь).
* Деградация: при недоступности request-service возвращаем пустую карту бейдж покажет
* только идентификатор заявки, без падения request-cover.
*/
fun requestHolders(ids: Set<String>): Map<String, SlotHolderDTO> {
if (ids.isEmpty()) return emptyMap()
return runCatching {
val firstPage = requestsPage(page = 0)
val items = firstPage.items.toMutableList()
for (page in 1 until firstPage.totalPages) {
items += requestsPage(page).items
}
items.asSequence()
.filter { ids.contains(it.id.toString()) }
.associate { summary ->
summary.id.toString() to SlotHolderDTO(
requestId = summary.id.toString(),
name = summary.name.takeIf { it.isNotBlank() },
importance = summary.importance,
)
}
}.getOrElse { emptyMap() }
}
private fun requestsPage(page: Int): RequestsListResponseDto = private fun requestsPage(page: Int): RequestsListResponseDto =
webClientBuilder.build() webClientBuilder.build()
.get() .get()
@@ -1216,7 +1216,8 @@ class SlotService {
states: List<SlotStatus>? = null, states: List<SlotStatus>? = null,
sats : List<Long>? = null, sats : List<Long>? = null,
sun : Double? = null, sun : Double? = null,
coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY,
includeBusy: Boolean = false
): Iterable<SlotDTO> { ): Iterable<SlotDTO> {
val target = buildCoverageTargets( val target = buildCoverageTargets(
items = listOf(SlotCoverageTargetDTO(targetId = 1, wkt = wkt)) items = listOf(SlotCoverageTargetDTO(targetId = 1, wkt = wkt))
@@ -1235,7 +1236,8 @@ class SlotService {
batchContext = batchContext, batchContext = batchContext,
cov = cov, cov = cov,
sun = sun, sun = sun,
coverageStrategy = coverageStrategy coverageStrategy = coverageStrategy,
includeBusy = includeBusy
) )
logger.info( logger.info(
"polySlots finish: targetId={}, candidateSlots={}, sunFilteredSlots={}, returnedSlots={}, sun={}", "polySlots finish: targetId={}, candidateSlots={}, sunFilteredSlots={}, returnedSlots={}, sun={}",
@@ -1668,10 +1670,14 @@ class SlotService {
batchContext: CoverageBatchContext, batchContext: CoverageBatchContext,
cov: Boolean?, cov: Boolean?,
sun: Double?, sun: Double?,
coverageStrategy: SlotCoverageStrategy? coverageStrategy: SlotCoverageStrategy?,
// true — добавить в результат и занятые бронью слоты (BUSY) для обзора. В расчёт покрытия
// (solver) они не попадают: selectedSlots остаётся только из AVAILABLE/BOOKED.
includeBusy: Boolean = false
): CoverageSelectionResult { ): CoverageSelectionResult {
val totalStartedAtMs = System.currentTimeMillis() val totalStartedAtMs = System.currentTimeMillis()
val selectedSlots = mutableListOf<SlotDTO>() val selectedSlots = mutableListOf<SlotDTO>()
val busySlots = mutableListOf<SlotDTO>()
val targetSlotIdsBySatelliteId = batchContext.rawSlotIdsByTargetIdAndSatelliteId[target.targetId].orEmpty() val targetSlotIdsBySatelliteId = batchContext.rawSlotIdsByTargetIdAndSatelliteId[target.targetId].orEmpty()
val sourceSlotIds = targetSlotIdsBySatelliteId.values.sumOf { it.size } val sourceSlotIds = targetSlotIdsBySatelliteId.values.sumOf { it.size }
var preparedSlotsBeforeOccupiedFilter = 0 var preparedSlotsBeforeOccupiedFilter = 0
@@ -1710,6 +1716,12 @@ class SlotService {
val sunFilterResult = filterBySunAngle(filteredSlots, sun) val sunFilterResult = filterBySunAngle(filteredSlots, sun)
sunFilteredSlots += sunFilterResult.filteredCount sunFilteredSlots += sunFilterResult.filteredCount
selectedSlots.addAll(sunFilterResult.slots) selectedSlots.addAll(sunFilterResult.slots)
// «Показать все слоты»: добавляем и занятые бронью (BUSY) для обзора — тот же
// sun-фильтр, чтобы не показывать заведомо непригодные по Солнцу.
if (includeBusy) {
val busyPrepared = preparedSlotsIncludingBlocked.filter { it.state == SlotStatus.BUSY }
busySlots.addAll(filterBySunAngle(busyPrepared, sun).slots)
}
} }
} }
logger.debug( logger.debug(
@@ -1815,15 +1827,17 @@ class SlotService {
candidateSelectionMs, candidateSelectionMs,
totalMs totalMs
) )
// Без покрытия («Показать все слоты»): при includeBusy возвращаем и занятые бронью слоты.
val resultSlots = if (includeBusy) selectedSlots + busySlots else selectedSlots
return CoverageSelectionResult( return CoverageSelectionResult(
slots = selectedSlots, slots = resultSlots,
sourceSlotIds = sourceSlotIds, sourceSlotIds = sourceSlotIds,
preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter, preparedSlotsBeforeOccupiedFilter = preparedSlotsBeforeOccupiedFilter,
occupiedFilteredSlots = occupiedFilteredSlots, occupiedFilteredSlots = occupiedFilteredSlots,
candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter, candidateSlotsBeforeSunFilter = candidateSlotsBeforeSunFilter,
sunFilteredSlots = sunFilteredSlots, sunFilteredSlots = sunFilteredSlots,
solverInputSlots = selectedSlots.size, solverInputSlots = selectedSlots.size,
solverOutputSlots = selectedSlots.size, solverOutputSlots = resultSlots.size,
candidateSelectionDurationMs = candidateSelectionMs, candidateSelectionDurationMs = candidateSelectionMs,
solverDurationMs = 0L, solverDurationMs = 0L,
totalDurationMs = totalMs totalDurationMs = totalMs
@@ -1870,22 +1884,46 @@ class SlotService {
cov: Boolean?, cov: Boolean?,
sats : List<Long>? = null, sats : List<Long>? = null,
sun : Double? = null, sun : Double? = null,
coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY coverageStrategy: SlotCoverageStrategy? = SlotCoverageStrategy.GREEDY,
// true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»).
// Бронировать их нельзя, в расчёт покрытия они не идут.
includeBusy: Boolean = false
): Iterable<SlotDTO> { ): Iterable<SlotDTO> {
logger.info("Запрос покрытия заявки $id") logger.info("Запрос покрытия заявки $id (includeBusy=$includeBusy)")
val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!") val req = earthService.reqcells(id)?.request?.contour ?: throw CustomErrorException("Нет такой заявки!!!!")
logger.info("Получена заявка : $req") logger.info("Получена заявка : $req")
return polySlots( val slots = polySlots(
req, req,
timeStart, timeStart,
timeStop, timeStop,
sign, sign,
cov, cov,
listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED), if (includeBusy) listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED, SlotStatus.BUSY)
else listOf(SlotStatus.AVAILABLE, SlotStatus.BOOKED),
sats, sats,
sun, sun,
coverageStrategy coverageStrategy,
includeBusy
) )
return if (includeBusy) enrichHolders(slots) else slots
}
// Обогащает держателей BUSY-слотов именем/важностью заявки одним батч-запросом (M3 — обзор
// конфликтов). prepareCoverageSlots проставляет держателям только requestId; здесь добавляем
// name/importance. Деградация (request-service недоступен) — держатель остаётся с одним id.
private fun enrichHolders(slots: Iterable<SlotDTO>): Iterable<SlotDTO> {
val slotList = slots.toList()
val holderIds = slotList
.flatMap { it.holders.orEmpty() }
.map { it.requestId }
.toSet()
if (holderIds.isEmpty()) return slotList
val infoById = earthService.requestHolders(holderIds)
if (infoById.isEmpty()) return slotList
return slotList.map { slot ->
val holders = slot.holders ?: return@map slot
slot.copy(holders = holders.map { infoById[it.requestId] ?: it })
}
} }
@@ -2439,8 +2477,17 @@ class SlotService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun bookedByReqId(id : String) : List<SlotDTO> { fun bookedByReqId(id : String) : List<BookedSlotViewDTO> {
return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.slot.toDTO(SlotStatus.valueOf(req.bookedSlot.status)) } // Время/виток брони сдвигаем на cycle БРОНИ (как resolveBookedSlotStartTime / shiftedToCycle /
// toDetails). Иначе by-request отдаёт слот в БАЗОВОМ cycle-фрейме, а request-cover перечисляет
// слоты уже сдвинутыми — отсюда баг «слоты бронируются на не те даты»: дата брони, восстановленная
// из by-request, на cycle*durationCalc суток раньше расчётной. cycle в DTO остаётся циклом брони —
// чтобы ключ sat:slotNum:cycle на фронте совпал с ответом POST booking/request.
return requestRepository.findByRequestId(id).map { req ->
val booked = req.bookedSlot
val satellite = runCatching { satelliteById(booked.slot.satelliteId) }.getOrNull()
booked.toShiftedViewDTO(satellite)
}
} }
@@ -2485,24 +2532,50 @@ class SlotService {
} }
/**
* Снимает одну бронь-связь заявки. Если слот забронирован только под эту заявку
* (requests.size == 1) удаляем сам слот; иначе рвём только связь заявки, а слот
* остаётся у других заявок. Единый источник правды для cancelReq / cancelReqSlots /
* cancelSlot нельзя удалять BookedSlot по id напрямую, иначе снос брони у чужой заявки.
*/
private fun removeBookedRequest(link: BookedRequestEntity) {
if (link.bookedSlot.requests?.size == 1)
bookedSlotsRepository.deleteByBookedSlotId(link.bookedSlot.bookedSlotId ?: -1)
else
requestRepository.deleteByBookedSlotRequestId(link.bookedSlotRequestId ?: -1)
}
@Transactional @Transactional
fun cancelReq(req : String) : Int{ fun cancelReq(req : String) : Int{
logger.info("Запрос на отмену бронирование по заявке ${req}") logger.info("Запрос на отмену бронирование по заявке ${req}")
var cnt = 0 var cnt = 0
requestRepository.findByRequestId(req).forEach { booked -> requestRepository.findByRequestId(req).forEach { link ->
if (booked.bookedSlot.requests?.size == 1) removeBookedRequest(link)
bookedSlotsRepository.deleteByBookedSlotId(booked.bookedSlot.bookedSlotId?:-1)
else
requestRepository.deleteByBookedSlotRequestId( booked.bookedSlotRequestId?:-1)
++cnt ++cnt
} }
return cnt return cnt
} }
/**
* Снимает брони заявки по списку bookedSlotId (фронт «Снять выбранные», чанк 200).
* request-aware через removeBookedRequest безопасно для броней, шаренных между заявками.
* id без брони у этой заявки молча пропускаются; возвращает число фактически снятых.
*/
@Transactional
fun cancelReqSlots(requestId : String, bookedSlotIds : List<Long>) : Int {
logger.info("Запрос на отмену ${bookedSlotIds.size} броней заявки ${requestId}")
var cnt = 0
for (id in bookedSlotIds) {
val link = requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(id, requestId) ?: continue
removeBookedRequest(link)
++cnt
}
return cnt
}
@Transactional @Transactional
fun cancelSlot(bookedSlotId : Long) = fun cancelSlot(requestId : String, bookedSlotId : Long) : Int =
bookedSlotsRepository.deleteByBookedSlotId(bookedSlotId) cancelReqSlots(requestId, listOf(bookedSlotId))
fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? { fun resolveBookedSlotStartTime(bookedSlot: BookedSlotEntity): LocalDateTime? {
val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null val satellite = runCatching { satelliteById(bookedSlot.slot.satelliteId) }.getOrNull() ?: return null
@@ -2582,6 +2655,29 @@ class SlotService {
f81 = f81 ?: InitialConditionsDTO().f81 f81 = f81 ?: InitialConditionsDTO().f81
) )
// Бронь для фронта со сдвигом на cycle брони (виток + tn/tk). satellite == null (КА не в каталоге) —
// отдаём базовый фрейм без сдвига: лучше показать слот, чем уронить весь список by-request. Контур и
// координаты не сдвигаем — наземный след повторяется каждый цикл (симметрично shiftedToCycle).
private fun BookedSlotEntity.toShiftedViewDTO(satellite: AbstractSatellite?): BookedSlotViewDTO {
val shiftDays = satellite?.let { cycle * it.durationCalc } ?: 0L
val revShift = satellite?.let { cycle * it.cycleRevs } ?: 0L
return BookedSlotViewDTO(
bookedSlotId = bookedSlotId ?: 0,
cycle = cycle,
satelliteId = slot.satelliteId,
tn = slot.tn.plusDays(shiftDays),
tk = slot.tk.plusDays(shiftDays),
roll = slot.roll,
contour = slot.contour,
revolution = slot.revolution + revShift,
revolutionSign = RevolutionSign.valueOf(slot.revolutionSign),
slotNumber = slot.slotNum,
state = SlotStatus.BOOKED,
latitude = slot.latitude,
longitude = slot.longitude
)
}
private fun SlotEntity.shiftedToCycle(cycle: Long, satellite: AbstractSatellite): SlotDTO = private fun SlotEntity.shiftedToCycle(cycle: Long, satellite: AbstractSatellite): SlotDTO =
SlotDTO( SlotDTO(
cycle = cycle, cycle = cycle,
@@ -3,10 +3,13 @@ package space.nstart.pcp.slots_service.model.satellite
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.slots_service.entity.BookedRequestEntity
import space.nstart.pcp.slots_service.entity.BookedSlotEntity import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity import space.nstart.pcp.slots_service.entity.SlotEntity
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TestSatelliteImplBookedBlockingTest { class TestSatelliteImplBookedBlockingTest {
@@ -40,6 +43,50 @@ class TestSatelliteImplBookedBlockingTest {
assertEquals(SlotStatus.BUSY, result.getValue(2L).state) assertEquals(SlotStatus.BUSY, result.getValue(2L).state)
} }
@Test
fun `busy slot carries holders from blocking booking, booked and available slots carry none`() {
val baseTime = LocalDateTime.of(2026, 5, 21, 0, 0)
val satellite = TestSatelliteImpl(
id = 1L,
tnCalc = baseTime,
durationCalc = 1,
maxChainLength = 3,
maxSurveyDuration = 300,
mmi = 10
)
val bookedRawSlot = slot(slotId = 1L, slotNum = 1L, baseTime = baseTime, startSecond = 0, endSecond = 10)
val candidateInsideBooked = slot(slotId = 2L, slotNum = 2L, baseTime = baseTime, startSecond = 2, endSecond = 8)
// Свободный кандидат вне интервала и mmi-окна брони — держателей быть не должно.
val freeSlot = slot(slotId = 3L, slotNum = 3L, baseTime = baseTime, startSecond = 100, endSecond = 110)
// Множественный держатель: одну бронь держат две заявки.
val bookedWithRequests = BookedSlotEntity(
slot = bookedRawSlot,
cycle = 0,
requests = mutableListOf(
BookedRequestEntity(requestId = "req-A"),
BookedRequestEntity(requestId = "req-B"),
),
status = BookedSlotStatus.PROCESSED.name,
)
val result = satellite.prepareCoverageSlots(
slots = listOf(bookedRawSlot, candidateInsideBooked, freeSlot),
bookedSlots = listOf(bookedWithRequests),
timeStart = baseTime,
timeStop = baseTime.plusSeconds(120),
sign = null,
states = null
).map { it.slot }.associateBy { it.slotNumber }
val busy = result.getValue(2L)
assertEquals(SlotStatus.BUSY, busy.state)
assertEquals(listOf("req-A", "req-B"), busy.holders.orEmpty().map { it.requestId }.sorted())
// На уровне прогноза держателям проставлен только requestId — name/importance добавит reqCover.
assertTrue(busy.holders.orEmpty().all { it.name == null && it.importance == null })
assertNull(result.getValue(1L).holders, "у самой брони (BOOKED) держателей не показываем")
assertNull(result.getValue(3L).holders, "у свободного слота держателей нет")
}
private fun slot( private fun slot(
slotId: Long, slotId: Long,
slotNum: Long, slotNum: Long,
@@ -0,0 +1,180 @@
package space.nstart.pcp.slots_service.service
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import org.springframework.test.util.ReflectionTestUtils
import space.nstart.pcp.slots_service.entity.BookedRequestEntity
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl
import space.nstart.pcp.slots_service.repository.BookedRequestRepository
import space.nstart.pcp.slots_service.repository.BookedSlotsRepository
import java.time.LocalDateTime
import kotlin.test.assertEquals
/**
* Снятие брони request-aware. Ключевой инвариант: снятие НИКОГДА не удаляет BookedSlot,
* шаренный между заявками, по id напрямую рвёт только связь текущей заявки.
*/
class SlotServiceCancelTest {
private val bookedSlotsRepository = mock(BookedSlotsRepository::class.java)
private val requestRepository = mock(BookedRequestRepository::class.java)
private val satelliteCatalogClient = mock(SatelliteCatalogClient::class.java)
private fun service(): SlotService {
val service = SlotService()
ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository)
ReflectionTestUtils.setField(service, "requestRepository", requestRepository)
ReflectionTestUtils.setField(service, "satelliteCatalogClient", satelliteCatalogClient)
return service
}
@Test
fun `cancelReqSlots deletes the slot when booked by a single request`() {
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(10L, "R1")).thenReturn(link)
val cnt = service().cancelReqSlots("R1", listOf(10L))
assertEquals(1, cnt)
verify(bookedSlotsRepository).deleteByBookedSlotId(10L)
verify(requestRepository, never()).deleteByBookedSlotRequestId(org.mockito.ArgumentMatchers.anyLong())
}
@Test
fun `cancelReqSlots only unlinks when slot is shared across requests`() {
// Один и тот же слот забронирован под R1 и R2 — снятие для R1 не должно трогать сам слот.
val link = sharedLink(bookedSlotId = 20L, linkId = 200L, otherLinkId = 201L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(20L, "R1")).thenReturn(link)
val cnt = service().cancelReqSlots("R1", listOf(20L))
assertEquals(1, cnt)
verify(requestRepository).deleteByBookedSlotRequestId(200L)
verify(bookedSlotsRepository, never()).deleteByBookedSlotId(org.mockito.ArgumentMatchers.anyLong())
}
@Test
fun `cancelReqSlots skips ids not booked by the request`() {
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(99L, "R1")).thenReturn(null)
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(10L, "R1")).thenReturn(link)
val cnt = service().cancelReqSlots("R1", listOf(10L, 99L))
assertEquals(1, cnt) // 99 пропущен
verify(bookedSlotsRepository).deleteByBookedSlotId(10L)
}
@Test
fun `cancelSlot delegates to request-aware single removal`() {
val link = sharedLink(bookedSlotId = 20L, linkId = 200L, otherLinkId = 201L, requestId = "R1")
`when`(requestRepository.findByBookedSlot_BookedSlotIdAndRequestId(20L, "R1")).thenReturn(link)
val cnt = service().cancelSlot("R1", 20L)
assertEquals(1, cnt)
verify(requestRepository).deleteByBookedSlotRequestId(200L) // только связь, слот цел
verify(bookedSlotsRepository, never()).deleteByBookedSlotId(org.mockito.ArgumentMatchers.anyLong())
}
@Test
fun `bookedByReqId uses booking cycle and carries id plus contour`() {
// slot.cycle (5) != booking cycle (3) — view ДОЛЖЕН вернуть 3, иначе ключ на фронте не сойдётся.
val link = soleLink(
bookedSlotId = 10L, linkId = 100L, requestId = "R1",
bookingCycle = 3L, slotCycle = 5L, contour = "POLYGON ((0 0, 1 0, 1 1, 0 0))"
)
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
val view = service().bookedByReqId("R1")
assertEquals(1, view.size)
assertEquals(10L, view[0].bookedSlotId)
assertEquals(3L, view[0].cycle) // cycle БРОНИ, не slot.cycle (5)
assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 0))", view[0].contour)
}
@Test
fun `bookedByReqId shifts tn-tk and revolution by booking cycle`() {
// Регрессия на «слоты бронируются на не те даты»: by-request обязан отдавать слот, сдвинутый
// на cycle брони (как request-cover), а не в базовом cycle-фрейме. cycle=2, durationCalc=16,
// cycleRevs=243 -> сдвиг +32 суток и +486 витков относительно базового slot.tn/revolution.
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1", bookingCycle = 2L)
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
`when`(satelliteCatalogClient.satellite(77L))
.thenReturn(TestSatelliteImpl(id = 77L, cycleRevs = 243L, durationCalc = 16L))
val view = service().bookedByReqId("R1")
assertEquals(1, view.size)
assertEquals(2L, view[0].cycle)
// базовый slot.tn/tk из builder slot(): 2026-03-31 10:00 / 10:10, базовый revolution = 0.
assertEquals(LocalDateTime.of(2026, 3, 31, 10, 0).plusDays(32), view[0].tn)
assertEquals(LocalDateTime.of(2026, 3, 31, 10, 10).plusDays(32), view[0].tk)
assertEquals(486L, view[0].revolution)
}
@Test
fun `bookedByReqId without satellite in catalog falls back to base frame`() {
// КА нет в каталоге -> сдвиг невозможен, отдаём базовый фрейм без падения (лучше слот, чем 500).
val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1", bookingCycle = 2L)
`when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link))
`when`(satelliteCatalogClient.satellite(77L)).thenThrow(RuntimeException("нет в каталоге"))
val view = service().bookedByReqId("R1")
assertEquals(1, view.size)
assertEquals(LocalDateTime.of(2026, 3, 31, 10, 0), view[0].tn) // без сдвига
assertEquals(2L, view[0].cycle) // cycle брони сохранён даже без КА
}
// --- builders ---
private fun slot(
slotId: Long, slotCycle: Long = 0L, contour: String = ""
) = SlotEntity(
slotId = slotId,
slotNum = slotId,
cycle = slotCycle,
satelliteId = 77L,
contour = contour,
tn = LocalDateTime.of(2026, 3, 31, 10, 0),
tk = LocalDateTime.of(2026, 3, 31, 10, 10)
)
private fun soleLink(
bookedSlotId: Long, linkId: Long, requestId: String,
bookingCycle: Long = 0L, slotCycle: Long = 0L, contour: String = ""
): BookedRequestEntity {
val booked = BookedSlotEntity(
bookedSlotId = bookedSlotId,
slot = slot(bookedSlotId, slotCycle, contour),
cycle = bookingCycle,
requests = mutableListOf()
)
val link = BookedRequestEntity(bookedSlotRequestId = linkId, bookedSlot = booked, requestId = requestId)
booked.requests!!.add(link)
return link
}
private fun sharedLink(
bookedSlotId: Long, linkId: Long, otherLinkId: Long, requestId: String
): BookedRequestEntity {
val booked = BookedSlotEntity(
bookedSlotId = bookedSlotId,
slot = slot(bookedSlotId),
cycle = 0L,
requests = mutableListOf()
)
val link = BookedRequestEntity(bookedSlotRequestId = linkId, bookedSlot = booked, requestId = requestId)
val other = BookedRequestEntity(bookedSlotRequestId = otherLinkId, bookedSlot = booked, requestId = "R2")
booked.requests!!.add(link)
booked.requests!!.add(other)
return link
}
}