diff --git a/.gitignore b/.gitignore index 322804c..39d4a73 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ out/ /deploy/local-bundle/pcp-local-bundle-*.tar.gz ### Node / Vite ### +/node_modules/ /services/pcp-tgu-ops-ui/node_modules/ /services/pcp-tgu-ops-ui/dist/ /services/pcp-tgu-ops-ui/.cache/ diff --git a/changeIpInCurrentFolder.sh b/changeIpInCurrentFolder.sh new file mode 100755 index 0000000..a130d41 --- /dev/null +++ b/changeIpInCurrentFolder.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " + echo "Example: $0 192.168.60.146 192.168.60.146" + exit 1 +fi + +OLD_IP="$1" +NEW_IP="$2" + +is_valid_ip() { + local ip="$1" + + [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || return 1 + + IFS='.' read -r a b c d <<< "$ip" + + for part in "$a" "$b" "$c" "$d"; do + (( part >= 0 && part <= 255 )) || return 1 + done +} + +if ! is_valid_ip "$OLD_IP"; then + echo "Invalid old IP: $OLD_IP" + exit 1 +fi + +if ! is_valid_ip "$NEW_IP"; then + echo "Invalid new IP: $NEW_IP" + exit 1 +fi + +export OLD_IP +export NEW_IP + +changed_files=0 +skipped_files=0 + +while IFS= read -r -d '' file; do + # Пропускаем бинарные файлы + if ! grep -Iq . "$file"; then + skipped_files=$((skipped_files + 1)) + continue + fi + + if perl -0ne ' + BEGIN { $old = quotemeta($ENV{"OLD_IP"}); } + exit 0 if /(? $NEW_IP" +echo "Files updated: $changed_files" +echo "Files skipped: $skipped_files" diff --git a/config-repo/application-dev.yaml b/config-repo/application-dev.yaml index c0ba214..947eea6 100644 --- a/config-repo/application-dev.yaml +++ b/config-repo/application-dev.yaml @@ -1,16 +1,16 @@ # TODO before merge request: вернуть старые адреса окружения. -# pcp.infra.postgres.host: 192.168.1.8 +# pcp.infra.postgres.host: localhost pcp: infra: postgres: - host: 192.168.1.8 + host: localhost port: 35400 kafka: - host: 192.168.1.8 + host: localhost port: 19092 network: - host: 192.168.1.8 + host: localhost ports: config: 8888 diff --git a/config-repo/application-docker-local.yaml b/config-repo/application-docker-local.yaml index 1e6e8f9..7b479db 100644 --- a/config-repo/application-docker-local.yaml +++ b/config-repo/application-docker-local.yaml @@ -56,7 +56,7 @@ spring: camunda: client: enabled: ${CAMUNDA_CLIENT_ENABLED:false} - grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500} + grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500} auth: method: none prefer-rest-over-grpc: false diff --git a/config-repo/application-local.yaml b/config-repo/application-local.yaml index 5fc6b17..ccd2cc3 100644 --- a/config-repo/application-local.yaml +++ b/config-repo/application-local.yaml @@ -3,10 +3,8 @@ pcp: infra: postgres: -# host: ${PCP_POSTGRES_HOST:localhost} -# port: ${PCP_POSTGRES_PORT:5432} - host: ${PCP_POSTGRES_HOST:192.168.100.160} - port: ${PCP_POSTGRES_PORT:35400} + host: ${PCP_POSTGRES_HOST:localhost} + port: ${PCP_POSTGRES_PORT:5432} kafka: host: ${PCP_KAFKA_HOST:localhost} port: ${PCP_KAFKA_PORT:29092} diff --git a/config-repo/pcp-ballistics-service.yaml b/config-repo/pcp-ballistics-service.yaml index 2078a67..3224306 100644 --- a/config-repo/pcp-ballistics-service.yaml +++ b/config-repo/pcp-ballistics-service.yaml @@ -78,6 +78,7 @@ app: settings: calculation-interval: 7 + stations-service: ${pcp.services.stations} server: port: 8080 @@ -127,6 +128,7 @@ app: settings: calculation-interval: 28 ic-service: http://${pcp.network.host}:9080 + stations-service: ${pcp.services.stations} server: port: 7003 diff --git a/config-repo/pcp-mission-planing-service.yaml b/config-repo/pcp-mission-planing-service.yaml index b4e3866..0daf469 100644 --- a/config-repo/pcp-mission-planing-service.yaml +++ b/config-repo/pcp-mission-planing-service.yaml @@ -24,6 +24,12 @@ logging: level: root: INFO +# Возвращаем текст причины ошибки клиенту (reason/exception message в поле `message` тела ответа), +# иначе UI видит только HTTP-фразу и не понимает, почему сохранение съёмки/сброса отклонено. +server: + error: + include-message: always + management: endpoints: web: @@ -75,7 +81,7 @@ spring: camunda: client: mode: self-managed - grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500} + grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500} auth: method: none prefer-rest-over-grpc: false diff --git a/config-repo/pcp-request-service.yaml b/config-repo/pcp-request-service.yaml index 6f2e83e..b69d13f 100644 --- a/config-repo/pcp-request-service.yaml +++ b/config-repo/pcp-request-service.yaml @@ -3,9 +3,18 @@ server: pcp: outbox: - request-completed-topic: pcp.request.completed.v1 + request-status-topic: pcp.request.status.v1 publish-batch-size: 50 publish-fixed-delay-ms: 5000 + max-attempts: 10 + initial-backoff-ms: 1000 + max-backoff-ms: 60000 + request: + expiry: + enabled: true + fixed-delay-ms: 60000 + batch-size: 200 + max-batches-per-run: 50 spring: boot: @@ -60,11 +69,13 @@ logging: app: kafka: topics: - request: pcp.request.in.v1 route: pcp.route.georeference.v1 - request: - cleanup-retention-days: 30 - cleanup-cron: "0 0 3 * * *" + consumer: + route: + dlq-topic: pcp.route.georeference-dlq.v1 + max-attempts: 5 + backoff-ms: 1000 + max-backoff-ms: 30000 grid: enabled: true diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index c462796..e458f74 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -25,25 +25,41 @@ management: exposure: include: health,info,metrics,prometheus +# Возвращаем текст причины ошибки клиенту (reason из ResponseStatusException попадает в поле +# `message` тела ответа). Без этого UI видит только HTTP-фразу «Unprocessable Content» и не +# понимает, почему действие отклонено (напр. «план заложен — используйте fork-forward»). +server: + error: + include-message: always + springdoc: swagger-ui: path: /swagger/ui planning: notification-before-start-minutes: 263 - target-plan-duration-minutes: 180 - target-plan-duration-delta-minutes: 60 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 external-update-enabled: true - external-points-lookahead-days: 3 + external-points-lookahead-days: 10 in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 + waiting-decision-grace-minutes: 30 + # Заморозка перед стартом: ручная replace-перевыдача плана в свой слот запрещена, + # если до старта осталось меньше этого числа минут (часть «точки невозврата»). + layin-freeze-minutes: 10 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + layin-failed: pcp.tgu.layin-failed.v1 + layin-confirmed: pcp.tgu.layin-confirmed.v1 + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 + station-params-changed: pcp.tgu.station-params-changed.v1 tgu: test-controller: @@ -89,12 +105,16 @@ spring: external-api: base-url: ${pcp.services.ballistics} + +mission-planing: + base-url: ${pcp.services.mission} + server: port: ${pcp.ports.tgu:7011} camunda: client: - grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://192.168.60.201:26500} + grpc-address: ${CAMUNDA_GRPC_ADDRESS:http://localhost:26500} --- spring: diff --git a/create_pcp_databases.sh b/create_pcp_databases.sh index 3e1314e..d257b3c 100755 --- a/create_pcp_databases.sh +++ b/create_pcp_databases.sh @@ -3,7 +3,7 @@ set -euo pipefail # PostgreSQL connection settings. Override via env if needed: # PGHOST=... PGPORT=... PGUSER=... PGPASSWORD=... ./create_pcp_databases.sh -PGHOST="${PGHOST:-192.168.1.8}" +PGHOST="${PGHOST:-localhost}" PGPORT="${PGPORT:-5432}" PGUSER="${PGUSER:-postgres}" PGPASSWORD="${PGPASSWORD:-password}" diff --git a/docs/architect/PCP_ARCHITECTURE.md b/docs/architect/PCP_ARCHITECTURE.md index 3f96ce4..51a7197 100644 --- a/docs/architect/PCP_ARCHITECTURE.md +++ b/docs/architect/PCP_ARCHITECTURE.md @@ -14,7 +14,7 @@ | Сервис | Зона ответственности | REST base | Хранит | Kafka (in / out) | Зрелость* | |---|---|---|---|---|---| -| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.completed.v1` (outbox) | 🟢 высокая | +| **pcp-request-service** | Приём и жизненный цикл заявок на съёмку; сетка Земли (earth-grid), геопривязка ячеек, сопоставление маршрутов с заявками | `/v1/requests`, `/v1/cells`, `/v1/grid` | заявки, ячейки сетки, outbox | in: `pcp.route.georeference.v1` · out: `pcp.request.status.v1` (outbox → geoportal) | 🟢 высокая | | **pcp-route-processing-service** | Приём «паспортов маршрутов», нормализация/геопривязка, ретрансляция дальше; собственный outbox + DLQ | `/api/route-processing` (test) | outbox маршрутов | in: `pcp.request.survey-georeference.v1` · out: `pcp.route.georeference.v1`, `pcp.route.in.v1`, DLQ | 🟢 высокая | | **pcp-satellite-catalog-service** | Каталог КА (источник истины): спутники, группы, observation/slot-профили | `/api/satellites`, `/api/satellite-groups` | каталог КА, профили | out: `pcp.satellites` (`SatelliteDeletedEvent`) | 🟡 средняя (мало тестов) | | **pcp-ballistics-service** | Баллистика: орбиты, ЗРВ/RVA, узлы, трассы, time-extract по TLE | `/api/satellites`, `/api/obj-view`, `/api/tle`, `/api/rva` | орбитальные данные | in: `pcp.tle` (IC/ICRV/TLE фильтры), `pcp.satellites` | 🟡 средняя | @@ -89,7 +89,7 @@ flowchart LR EXT[Внешний источник / UI ⚠] -->|pcp.request.survey-georeference.v1| RP[route-processing] RP -->|pcp.route.georeference.v1| REQ[request-service] RP -->|pcp.route.in.v1| MP[mission-planing] - REQ -->|pcp.request.completed.v1 ⚠ потребитель| Q1{{?}} + REQ -->|pcp.request.status.v1 · COMPLETED/EXPIRED| GEO[geoportal · внешний] CAT[satellite-catalog] -->|pcp.satellites / SatelliteDeletedEvent| BAL[ballistics] CAT --> TLEM[tle-monitoring] @@ -175,7 +175,7 @@ sequenceDiagram | `pcp.route.georeference.v1` | route-processing | request-service | `RoutePassportDto` (геопривязанный) | ⚠ | | `pcp.route.in.v1` | route-processing | mission-planing (`ModeStatusChangedEvent`) | processed route / mode-status | ⚠ | | `pcp.request.in.v1` | request-service / UI ⚠ | ⚠ требует уточнения | `RequestPlacedEvent` | ⚠ | -| `pcp.request.completed.v1` | request-service (outbox) | ⚠ требует уточнения | RequestCompleted | `aggregateId` (UUID заявки) | +| `pcp.request.status.v1` | request-service (outbox) | **geoportal** (внешний) | `REQUEST_TERMINAL_STATUS` (COMPLETED/EXPIRED) | `aggregateId` (UUID заявки) | | `pcp.slots.status.v1` | slots-service | mission-planing, slots-service (self) | `BookedSlotsStatusChangedEvent` | ⚠ | | `pcp.tgu.satellite-plan-decision.v1` | Mission Ops / КА ⚠ | tgu-service | `PlanDecisionMessage` (ACCEPTED/REJECTED, `attemptId`) | ⚠ | | `pcp.tgu.visibility-windows-changed.v1` | tgu-service | tgu-service | `VisibilityWindowsChangedMessage` | ⚠ | @@ -255,7 +255,7 @@ tgu : GET /api/plans/{spacecraftId} ; /api/platforms ; Эти пункты помечены `⚠` выше — их стоит зафиксировать до изменений: 1. Producer топиков `pcp.request.survey-georeference.v1` и `pcp.request.in.v1` (внешняя система или UI?). -2. Consumer `pcp.request.completed.v1` — кто слушает завершённые заявки (предположительно complex-mission/планирование). +2. ~~Consumer `pcp.request.completed.v1`~~ — уточнено: потребитель терминальных статусов заявки = внешний **geoportal**, топик переименован в `pcp.request.status.v1` (COMPLETED/EXPIRED, #35). 3. Producer `pcp.tgu.satellite-plan-decision.v1` — реальный Mission Ops/КА vs текущая имитация в `FlowService`. 4. Точные DTO payload для `pcp.tle`, `pcp.route.in.v1`, `pcp.slots.status.v1` — зафиксировать схемы. 5. Возможная циклическая зависимость `complex-mission ⇄ coverage-scheme` в рантайме. diff --git a/docs/architect/PCP_SERVICE_INTERACTIONS.md b/docs/architect/PCP_SERVICE_INTERACTIONS.md new file mode 100644 index 0000000..bd0c5ac --- /dev/null +++ b/docs/architect/PCP_SERVICE_INTERACTIONS.md @@ -0,0 +1,224 @@ +# Карта взаимодействий сервисов PCP + +> Составлено по исходникам (`services/*/src/main`) и конфигам (`config-repo/`). +> Три канала интеграции: **Kafka** (события), **HTTP/REST** (синхронные запросы), **Camunda 8 / Zeebe** (оркестрация процессов). +> Имена топиков и адреса сервисов берутся из Spring Cloud Config (`config-repo/*.yaml`, +> общая карта адресов — `application-dev.yaml` / `application-local.yaml`, ключи `pcp.services.*`). + +## Содержание +1. [Перечень сервисов и порты](#1-перечень-сервисов-и-порты) +2. [Kafka — карта топиков и событий](#2-kafka--карта-топиков-и-событий) +3. [HTTP/REST — карта вызовов между сервисами](#3-httprest--карта-вызовов-между-сервисами) +4. [Camunda — процессы и воркеры](#4-camunda--процессы-и-воркеры) + +--- + +## 1. Перечень сервисов и порты + +Локальные порты — из `config-repo/application-local.yaml` (`pcp.ports.*`). В dev/docker все слушают `8080`. + +| Сервис | Локальный порт | Роль | +|---|---|---| +| `spring-cloud-config-server` | 8888 | раздача конфигов | +| `pcp-srpring-boot-admin-server` | (admin) | мониторинг Spring Boot Admin | +| `tle-monitoring-service` | 7001 | загрузка/раздача TLE (CelesTrak) | +| `pcp-complex-mission-service` | 7002 | комплексные планы/миссии, режимы съёмки КА | +| `pcp-ballistics-service` | 7003 | баллистика, расчёт НУ/витков/трасс (он же `ic-service`) | +| `pcp-request-service` | 7005 | заявки, маршруты-привязки, outbox | +| `slots-service` | 7006 | слоты, бронирования, расчёт покрытия | +| `pcp-ui-service` | 7008 | BFF/агрегатор для старого UI | +| `pcp-stations-service` | 7009 | каталог наземных станций | +| `pcp-mission-planing-service` | 7010 | планирование миссий + **Camunda job-воркеры** | +| `pcp-tgu-service` | 7011 | ТГУ: выдача планов, **запуск Camunda-процессов** | +| `pcp-coverage-scheme-service` | 7012 | расчёт схем покрытия | +| `pcp-satellite-catalog-service` | 7013 | каталог КА | +| `pcp-dynamic-plan-service` | 7014 | динамические/комплексные планы (calcPlan) | +| `pcp-route-processing-service` | — | обработка маршрутных паспортов | + +`pcp-tgu-ops-ui` — фронтенд (Vite), ходит в микросервисы напрямую через прокси, в этой карте не участвует как backend. + +--- + +## 2. Kafka — карта топиков и событий + +Событие — JSON-конверт `KafkaMessage` с заголовком `type` (значения из `PcpKafkaEvent`). +Консьюмеры фильтруют по этому заголовку. + +### Топики + +| Топик | Продюсер(ы) | Консьюмер(ы) | Событие / назначение | +|---|---|---|---| +| `pcp.tle` *(default-topic)* | `tle-monitoring-service` (TLE); `slots-service` (`ICRVPlacedEvent`) | `pcp-ballistics-service` (фильтры `placedIcFilter`, `updatedIcFilter`, `placedIcRvFilter`) | общий «шина TLE / НУ-РВ»: новые TLE и заложенные начальные условия | +| `pcp.satellites` | `pcp-satellite-catalog-service` (`SatelliteDeletedEvent`) | `tle-monitoring`, `slots-service`, `pcp-complex-mission-service`, `pcp-dynamic-plan-service`, `pcp-ballistics-service`, `pcp-mission-planing-service` | удаление КА (fan-out для очистки производных данных) | +| `pcp.slots.status.v1` | `pcp-mission-planing-service` (`BookedSlotsStatusChangedEvent`) | `slots-service` (`BookedSlotsKafkaListener`) | смена статуса забронированных слотов (lifecycle Kafka-first) | +| `pcp.route.in.v1` | `pcp-route-processing-service` (`processed-route`) | `pcp-mission-planing-service` (`ModeStatusChangedKafkaListener`, `ModeStatusChangedEvent`) | обработанный маршрут → смена статуса режима съёмки | +| `pcp.route.georeference.v1` | `pcp-route-processing-service` (`route-georeference`) | `pcp-request-service` (`RequestKafkaListener.consumeRoute`) | геопривязка маршрута к заявке | +| `pcp.request.survey-georeference.v1` | (инжект `RouteProcessingTestController` / внешний источник) | `pcp-route-processing-service` (`RoutePassportMessageListener`, вход) | входной маршрутный паспорт на обработку | +| `pcp.request.survey-georeference-dlq.v1` | `pcp-route-processing-service` (DLQ) | — | dead-letter для входных паспортов | +| `pcp.request.status.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | **geoportal** (внешний, в репо нет) | терминальный статус заявки `COMPLETED`/`EXPIRED` (`REQUEST_TERMINAL_STATUS`) | +| `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) | + +### Поток данных (упрощённо) + +``` +CelesTrak ──HTTP──> tle-monitoring ──pcp.tle(TLE)──────────────> ballistics +slots-service ─────pcp.tle(ICRVPlacedEvent)───────────────────> ballistics + +satellite-catalog ──pcp.satellites(SatelliteDeletedEvent)──┬──> tle-monitoring + ├──> slots-service + ├──> complex-mission + ├──> dynamic-plan + ├──> ballistics + └──> mission-planing + +route-processing ──pcp.route.in.v1──────> mission-planing (ModeStatusChanged) +route-processing ──pcp.route.georeference.v1──> request-service + (вход) pcp.request.survey-georeference.v1 ──> route-processing + +mission-planing ──pcp.slots.status.v1(BookedSlots)──────> slots-service +request-service ──pcp.request.status.v1 (outbox)────────> geoportal (внешний) +``` + +### ТГУ-топики (`pcp.tgu.*`) + +`pcp-tgu-service` объявляет консьюмеры на свои топики (`config-repo/pcp-tgu-service.yaml`, блок `topics:`). +Продюсеры этих событий — внешняя система СПО УОГ / тестовые эндпоинты; внутри репозитория стабильных продюсеров нет. + +| Топик | Консьюмер в tgu | Событие | +|---|---|---| +| `pcp.tgu.visibility-windows-changed.v1` | `VisibilityWindowsChangedConsumer` | изменение окон видимости | +| `pcp.tgu.satellite-plan-decision.v1` | `PlanDecisionConsumer` | решение по плану (принят/отклонён) | +| `pcp.tgu.layin-confirmed.v1` | `LayinConfirmedConsumer` | подтверждение закладки | +| `pcp.tgu.layin-failed.v1` | `LayinFailedConsumer` | ошибка закладки | +| `pcp.tgu.plan-not-accepted.v1` | `PlanNotAcceptedConsumer` | план не принят | +| `pcp.tgu.station-params-changed.v1` | `StationParamsChangedConsumer` | смена параметров станции | + +--- + +## 3. HTTP/REST — карта вызовов между сервисами + +Адреса берутся из `settings.*` → `pcp.services.*` (Spring Cloud Config). Клиенты — `WebClient`. + +| Вызывающий сервис | Целевой сервис | Эндпоинты (примеры) | Назначение | +|---|---|---|---| +| `pcp-ui-service` (BFF) | `tle-monitoring` | `/v1/api/tle`, `/v1/api/tle/satellite/{id}` | TLE | +| | `pcp-satellite-catalog` | `/api/satellites`, `/api/satellites/{id}/observation-profile`, `/slot-profile`, `/api/satellite-groups` | каталог КА | +| | `pcp-complex-mission` | `/api/missions`, `/api/missions/{id}/drops/calculate`, `/api/com-plan/runs`, `/api/com-plan/process` | миссии и комплексные планы | +| | `pcp-dynamic-plan` | `/v1/main/calcPlan`, `/{runId}/result`, `/routes`, `/intervals` | динамические планы | +| | `pcp-request` (request / earth-grid) | `/v1/requests`, `/v1/requests/{id}/cells` | заявки и ячейки | +| | `pcp-stations` | `/api/stations`, `/api/stations/{id}` | станции | +| | `slots-service` | `/api/slots/...`, `/api/slots/booking[...]` | слоты и бронирования | +| | `pcp-ballistics` | `/api/satellites/{id}/asc-node`, `/flight-line`, `/orbit`, `/extract-time`, `/initial-conditions/current`, `/receive-rv`, `/api/tle/parse`, `/api/tle/point` | баллистика | +| | `pcp-coverage-scheme` | `/api/coverage-schemes/calculate` | схемы покрытия | +| | `pcp-tgu` | `/api/platforms`, `/api/plans[...]`, `/api/plans/{id}/dismiss`, `/restore`, `/test/tgu/plans/{id}/decision`, `/confirm-layin` | планы ТГУ | +| `pcp-tgu-service` | `pcp-mission-planing` (`mission-planing.base-url`) | `/api/missions/{id}/clone`, `/api/missions/blank`, `/api/missions/{id}` | управление миссиями плана | +| | `pcp-ballistics` (`ballistics.base-url`) | расчёт точек/баллистики (`ExternalPointsClient`) | внешние точки | +| | `ordinis.nstart.cloud` (внешний) | `/api/v1/spacecraft/records` (`platforms-url`) | классификатор платформ КА | +| `pcp-mission-planing` | `pcp-ballistics` | `/api/obj-view/mpl-square`, `/mpl-point`, `/cell-coverage/{cell}`, `/api/satellites/{id}/rva`, `/extract-time` | баллистика для планирования | +| | `pcp-complex-mission` | `/api/satellites/snapshots/{id}`, режимы КА | данные комплексной миссии | +| | `pcp-satellite-catalog` | `/api/satellites/{id}` | каталог КА | +| `pcp-complex-mission` | `pcp-ballistics` (`ic-service`) | расчёты НУ/баллистики | баллистика | +| | `pcp-satellite-catalog` | `/api/satellites`, `/api/satellites/batch` | каталог КА | +| | `slots-service` | покрытие/слоты (`CoverageBatchClient`, `SlotService`) | слоты | +| | `pcp-request` (`earth-grid`) | сетка Земли/ячейки (`EarthGridService`) | заявки/ячейки | +| | `pcp-coverage-scheme` | расчёт схемы покрытия | покрытие | +| `pcp-dynamic-plan` | `pcp-request` | `/v1/requests` (выборка заявок) | заявки | +| | `pcp-ballistics` | `/api/satellites/orbit/availability/{norad}`, `/extract-time`, `/api/obj-view/mpl-square` | баллистика | +| | `pcp-satellite-catalog` | `/api/satellites` | каталог КА | +| `pcp-coverage-scheme` | `pcp-ballistics` | `/api/obj-view/mpl-square`, `/mpl-point`, `/extract-time` | баллистика | +| | `pcp-complex-mission` | `/api/satellites/{id}` | данные КА/миссии | +| `pcp-ballistics` | `pcp-stations` | `/api/stations` | станции (для расчётов видимости) | +| `slots-service` | `pcp-satellite-catalog` | `/api/satellites`, `/batch`, `/slot-profile` | каталог КА | +| | `pcp-request` (`earth-grid`) | `/v1/requests`, `/cells` | заявки/ячейки | +| `tle-monitoring` | `pcp-satellite-catalog` | `/api/satellites` | каталог КА | + +Все сервисы дополнительно ходят в `spring-cloud-config-server` (8888) за конфигом и регистрируются в `spring-boot-admin-server`. + +### Граф зависимостей (кто кого зовёт по HTTP) + +``` +ui-service ──> почти все (BFF-агрегатор) +tgu ─────────> mission-planing, ballistics, (ordinis ext) +mission-planing ─> ballistics, complex-mission, satellite-catalog +complex-mission ─> ballistics, satellite-catalog, slots, request, coverage-scheme +dynamic-plan ───> request, ballistics, satellite-catalog +coverage-scheme ─> ballistics, complex-mission +ballistics ─────> stations +slots ──────────> satellite-catalog, request +tle-monitoring ─> satellite-catalog (+ CelesTrak внешний) +``` + +`pcp-ballistics` и `pcp-satellite-catalog` — наиболее востребованные «нижние» сервисы. + +--- + +## 4. Camunda — процессы и воркеры + +Используется **Camunda 8 (Zeebe)**, `io.camunda:camunda-spring-boot-4-starter:8.8.10`. +Брокер внешний: gRPC `camunda.k8s.265.nstart.cloud:30901` (dev) / `localhost:26500` (локально), +ключ `camunda.client.grpc-address`. + +- **`pcp-tgu-service`** — *оркестратор*: содержит BPMN-определения, разворачивает их и запускает экземпляры процессов (`CamundaClient.newCreateInstanceCommand()`). +- **`pcp-mission-planing-service`** — *исполнитель*: реализует все job-воркеры (`@JobWorker` в `FlowService`). + +### Процесс `createSatelliteMission` + +BPMN: [createSatelliteMission.bpmn](../../services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn). +Запуск: `BpmnPlanIssueProcessStarter` ← `IssueDuePlanWorker.issuePlanNow()` ← `PlanController` `POST /api/plans/{planId}/issue`. + +``` +startSatelliteMission + → [calculateSatelliteSurveyMissions] «Сформировать план съёмки» + → [calculateSatelliteDropMissions] «Сформировать план сбросов» + → [sendSatellitePlan] «Отправка плана в СПО УОГ» + → endSatelliteMission +``` + +### Процесс `updateAcceptedPlanSlots` + +BPMN: [updateAcceptedPlanSlots.bpmn](../../services/pcp-tgu-service/src/main/resources/BPMN/updateAcceptedPlanSlots.bpmn). +Запуск: `AcceptedPlanSlotsUpdateProcessStarter` ← `AcceptedPlanSlotsUpdateWorker`. + +``` +start → [updateSlotsStatus] «Обновление статуса слотов» → end +``` + +### Соответствие job-type → воркер + +Все воркеры — в `pcp-mission-planing-service`, [FlowService.kt](../../services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt), `autoComplete = false` (ручной `newCompleteCommand`). + +| Job type (BPMN) | Метод воркера | Процесс | +|---|---|---| +| `calculateSatelliteSurveyMissions` | `FlowService.surveyPlan` | createSatelliteMission | +| `calculateSatelliteDropMissions` | `FlowService.surveyDrop` | createSatelliteMission | +| `sendSatellitePlan` | `FlowService.sendPlan` | createSatelliteMission | +| `updateSlotsStatus` | `FlowService.confirmPlan` | updateAcceptedPlanSlots | + +### Схема + +``` + pcp-tgu-service pcp-mission-planing-service + (оркестратор) (job-воркеры, FlowService) + PlanController POST /api/plans/{id}/issue + │ + ▼ + BpmnPlanIssueProcessStarter + │ newCreateInstanceCommand("createSatelliteMission") + ▼ + ┌─────────────── Zeebe / Camunda 8 broker (gRPC) ───────────────┐ + │ calculateSatelliteSurveyMissions ─────────────────────────► surveyPlan + │ calculateSatelliteDropMissions ─────────────────────────► surveyDrop + │ sendSatellitePlan ─────────────────────────► sendPlan + └───────────────────────────────────────────────────────────────┘ + + AcceptedPlanSlotsUpdateProcessStarter + │ newCreateInstanceCommand("updateAcceptedPlanSlots") + ▼ + ┌─────────────── Zeebe broker ───────────────┐ + │ updateSlotsStatus ───────────────────────► confirmPlan + └─────────────────────────────────────────────┘ +``` + +> Связь между HTTP и Camunda: воркеры `pcp-mission-planing-service` в ходе выполнения задач +> сами ходят по HTTP в `pcp-ballistics`, `pcp-complex-mission`, `pcp-satellite-catalog` +> и публикуют Kafka-событие `pcp.slots.status.v1` (см. разделы 2–3). diff --git a/docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md b/docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md new file mode 100644 index 0000000..1eb97ce --- /dev/null +++ b/docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md @@ -0,0 +1,153 @@ +# Жизненный цикл статусов плана ТГУ + +Справочник по статусам business-plan в расписании ТГУ: когда наступает каждый статус, что он +означает и какой переход его порождает. Источник правды — enum +[`PlannedPlanStatus`](../../services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt); +переходы — в `service/IssueDuePlanWorker`, `service/HandlePlanDecisionUseCase`, +`service/CreateDraftUseCase`, `service/CreateChainPlanUseCase`, `dto/PlanResponse`. + +Эти же статусы (кроме «зарезервированных», см. ниже) показаны в легенде вкладки «Планы» +([`tguStatus.ts`](../../services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts)) и задают +цвет плашек на таймлайне. + +## Диаграмма переходов (упрощённо) + +``` + создать черновик + (нет плана) ───────────────────────────────► DRAFT + │ promote («Применить») / авто-выдача + ▼ + проекция будущего ····(виртуально)····► PLANNED ──(подошёл срок, авто-выдача)──► ISSUING + │ BPMN-процесс стартовал + ▼ + WAITING_DECISION + ACCEPTED-решение │ │ REJECTED-решение + ▼ ▼ + AWAITING_LAYIN REJECTED (цепочка на паузе) + │ + ┌────────────────────────────────┼────────────────────────────────┐ + событие «заложен» событие «не заложен» событие «не принят» + ▼ ▼ ▼ + LAID_IN LAYIN_FAILED NOT_ACCEPTED + (база цепочки; конкуренты (срочная перевыдача) (срочная перевыдача) + того же окна → SUPERSEDED) + + при сбое старта BPMN из ISSUING ─────────────────────────────────────────► START_AMBIGUOUS +``` + +## Статусы + +### DRAFT — черновик +- **Когда.** Оператор создаёт план через «Создать план» (`CreateChainPlanUseCase`) или «Добавить»/ + редактирование (`CreateDraftUseCase`). +- **Что значит.** План в стадии правки. Персистится, но **не** является головой цепочки, **не** + попадает в проекцию/read-путь как запланированный слот и **не** выдаётся авто-воркером. +- **Дальше.** Становится активным только после promote («Применить») — переходит в `ISSUING`. + Удаление черновика (abandon) убирает его и клон-миссию. +- В UI помечен как «ЧЕРНОВИК», виден только при включённом режиме «Черновики». + +### PLANNED — проекция будущего (виртуальный) +- **Когда.** Вычисляется на лету `PlanProjectionService`/`PlanResponse.projected()` при чтении + расписания. **Никогда не пишется в БД.** +- **Что значит.** Предполагаемый будущий слот цепочки (следующие ЗРВ) — «как пойдёт цепочка, если + ничего не менять». Не несёт `basePlanId`, стартом цепочки не считается. +- **Дальше.** Когда подходит срок выдачи, по этому слоту авто-воркер материализует реальный план + сразу в `ISSUING` (`materializeIssuingPlan` → `insertIssuingPlan`). +- В UI виден только при включённом тумблере «Возможные будущие планы». + +### ISSUING — идёт выдача +- **Когда.** `IssueDuePlanWorker` начинает выдачу: promote черновика (`prepareDraftPromotion`), + ручная (пере)выдача конкретного плана (`prepareSpecificPlan`) или авто-материализация головы + проекции (`materializeIssuingPlan`). +- **Что значит.** Запущен BPMN-процесс выдачи плана на борт; создан attempt со статусом `STARTING`. +- **Дальше.** Успешный старт процесса → `WAITING_DECISION` (`markStarted`). Сбой старта BPMN → + `START_AMBIGUOUS` (`markAmbiguous`). + +### WAITING_DECISION — ожидает решения +- **Когда.** BPMN-процесс выдачи успешно стартовал, attempt → `STARTED` (`markStarted`). +- **Что значит.** План выдан, ждём решения оператора/борта: ACCEPTED либо REJECTED. +- **Дальше.** + - ACCEPTED → `AWAITING_LAYIN` (`acceptPlan`). + - REJECTED → `REJECTED` (`rejectPlan`). + - **Grace-окно.** Если решения нет дольше `waitingDecisionGraceMinutes` после `startTime`, цепочка + «молчаливо» едет дальше: якорь сдвигается на этот план, блокировка снимается, но **статус + остаётся `WAITING_DECISION`** (`tryAdvancePastUnconfirmedPlan`). Запоздалое решение запишется на + attempt и ушедшую вперёд цепочку не тронет. Поэтому в UI кнопки решения гасятся после grace. + +### AWAITING_LAYIN — принят, ждёт подтверждения закладки +- **Когда.** Пришло решение ACCEPTED (`acceptPlan`). +- **Что значит.** План принят и ждёт закладки. **ACCEPT больше НЕ делает план базой и не двигает + `chainVersion`** — это разъединено намеренно: после приёмки можно отправить и принять коррекцию + того же окна, поэтому по одному окну может одновременно «лететь» несколько AWAITING_LAYIN/ + WAITING_DECISION. Базой и версией цепочки управляет только закладка (`LAID_IN`). На ACCEPT + запускается джоб обновления слотов принятого плана (`acceptedPlanSlotsUpdateJobService.enqueue`). +- **Дальше.** При подтверждении закладки → `LAID_IN` (становится базой). При негативе — + `LAYIN_FAILED`/`NOT_ACCEPTED`. Если базой стала **другая** коррекция того же окна — этот план + гасится в `SUPERSEDED`. +- В легенде это «принятый, ждёт закладки» статус (зелёный). Перевыдача/коррекция в свой слот ещё + возможна, пока до старта больше `layinFreezeMinutes` (после — «точка невозврата», только + fork-forward). + +### REJECTED — отклонён +- **Когда.** Пришло решение REJECTED (`rejectPlan`). +- **Что значит.** План признан неправильным. Авто-перевыдача **не** взводится (повторять тот же план + бессмысленно). Цепочка ставится **на паузу** (`pausedAfterReject = true`) — **только если по КА не + осталось других живых кандидатов** (в полёте/приняты, но не заложены): при нескольких выдачах + отказ одной не глушит цепочку, пока жив другой кандидат. На паузе проекция пуста, воркер ничего не + выдаёт. Базовый план не трогается. +- **Дальше.** Оператор вручную создаёт исправленный план; его promote/ручная выдача снимают паузу. +- В UI у КА с паузой рисуется пометка «⏸ на паузе». + +### NOT_ACCEPTED — не принят (негативный фидбек) +- **Когда.** Пришло внешнее событие «не принят» по текущему базовому плану в `AWAITING_LAYIN` + (`handleNotAccepted` → `applyNegativeFeedback`). +- **Что значит.** Терминальный негатив по базовому плану. В отличие от REJECTED — это **технический** + негатив, поэтому запускается **срочная перевыдача** на ближайшую ЗРВ (`reissueOnNearestWindow`). +- Событие, пришедшее не по текущему базовому плану (устаревшее/дубль), игнорируется. + +### LAYIN_FAILED — не заложен (негативный фидбек) +- **Когда.** Пришло внешнее событие «не заложен» по текущему базовому плану в `AWAITING_LAYIN` + (`handleLayinFailed` → `applyNegativeFeedback`). +- **Что значит.** Терминальный негатив: закладка не удалась. Поведение сейчас идентично + `NOT_ACCEPTED` — **срочная перевыдача** на ближайшую ЗРВ. + +### START_AMBIGUOUS — неопределённый старт +- **Когда.** Из `ISSUING` не удалось достоверно стартовать BPMN-процесс выдачи (исключение в + `startPreparedAttempt` → `markAmbiguous`); attempt → `AMBIGUOUS`. +- **Что значит.** Неизвестно, ушёл план на борт или нет — требуется ручной разбор оператором. + Опасный статус, в UI выделен пунктиром. + +### LAID_IN — заложен (терминальный успех, база цепочки) +- **Когда.** Пришло внешнее событие «план заложен» по плану в `AWAITING_LAYIN` + (`LayinConfirmedConsumer` → `handleLayinConfirmed`, топик `layin-confirmed`). +- **Что значит.** Подтверждённая закладка — **именно она делает план базой цепочки** + (`state.currentBasePlanId = plan.planId`, `chainVersion += 1`, снимается пауза/срочная + перевыдача). «Точка невозврата»: дальше план меняется только через fork-forward (гарды + `CreateDraftUseCase`, `assertReissuableInSlot`). +- **Supersede конкурентов.** Все ещё не заложенные планы КА (ISSUING/WAITING_DECISION/AWAITING_LAYIN) + с окном, пересекающим окно заложенного, переводятся в `SUPERSEDED` (`supersedeCompetingPlans`). +- Событие по плану не в `AWAITING_LAYIN` (устаревшее/дубль/уже вытеснён) — игнорируется. +- В легенде — зелёный, темнее `AWAITING_LAYIN`. + +### SUPERSEDED — вытеснён заложенной коррекцией (терминал) +- **Когда.** По тому же окну КА другой план ушёл в `LAID_IN`, а этот был конкурирующей выдачей/ + коррекцией (`supersedeCompetingPlans`). +- **Что значит.** Терминал, цепочку не двигает. Запоздавшее решение по его attempt записывается, но + игнорируется (план уже не in-flight). Базовый план не трогается. + +## Зарезервированные / не возникающие сейчас статусы + +- **ACCEPTED** — в enum присутствует, но приёмка (`acceptPlan`) переводит план **сразу** в + `AWAITING_LAYIN`, минуя `ACCEPTED`. Фактически это легаси-значение, нигде не присваивается; в + легенду вкладки «Планы» намеренно не вынесено. + +## Связанные конфиги (`PlanningProperties`) +- `waitingDecisionGraceMinutes` — сколько ждём решение после старта, прежде чем «молчаливо» ехать + дальше (см. `WAITING_DECISION`). +- `layinFreezeMinutes` — за сколько минут до старта окно выдачи замораживается («точка невозврата»): + перевыдача в свой слот запрещена, только fork-forward. +- `notificationBeforeStartMinutes` — за сколько до старта авто-воркер начинает выдачу головы цепочки. + +## См. также +- [PCP_ARCHITECTURE.md](PCP_ARCHITECTURE.md) +- [PCP_TGU_OPS_UI_CAPABILITY_MAP.md](PCP_TGU_OPS_UI_CAPABILITY_MAP.md) diff --git a/docs/standards/CHECKLIST.md b/docs/standards/CHECKLIST.md index 27445e9..c5461a4 100644 --- a/docs/standards/CHECKLIST.md +++ b/docs/standards/CHECKLIST.md @@ -21,6 +21,8 @@ - существующие публичные контракты не сломаны без явного запроса; - добавлены или обновлены релевантные тесты; - не внесено несвязанных правок; +- время на API — naive `LocalDateTime` без зоны; backend штампует `now` через `LocalDateTime.now()`, фронт парсит/форматирует через `utils/utcTime` без `new Date("<строка>")`; +- egress фронта к бэку — naive ISO (`YYYY-MM-DDTHH:mm:ss`, без Z); - validation выполнен по правилам `AGENTS.md`, либо пропуски явно объяснены. ### Дополнительно для BPM / Kafka / Zeebe / external integration изменений diff --git a/docs/tasks/TASK_routes-drops-carryover.md b/docs/tasks/TASK_routes-drops-carryover.md new file mode 100644 index 0000000..f2ff451 --- /dev/null +++ b/docs/tasks/TASK_routes-drops-carryover.md @@ -0,0 +1,30 @@ +# Следующая задача: маршруты, сбросы и перенос включений между планами + +> Статус: запланировано (дизайн ещё не начат). Это фиксация намерения, не финальный план. + +Следующий блок работ — три связанные темы вокруг содержимого плана (включений) и +их съёмочно-сбросовой обвязки: + +## 1. Построение маршрутов +Довести построение маршрутов наблюдения по плану. Сейчас маршруты порождаются в +потоке `createSatelliteMission` (BPMN), где `planId == missionId`; результат +ложится в БД mission-planing. Уточнить: когда и по какому триггеру строятся +маршруты для нового/перевыданного плана, и как они привязаны к включениям. + +## 2. Формирование сбросов +Формирование сбросов (`DropMode`) поверх съёмочных включений (`SurveyMode`): +расчёт окон сброса, привязка к ЗРВ/КПП, согласование времени сброса со временем +съёмки. Внутренние времена — naive `LocalDateTime` (см. сквозную модель без зон). + +## 3. Перенос включений из плана в план +При перевыдаче/версионировании плана переносить уже сформированные включения +(съёмки + сбросы) из предыдущего плана в новый — чтобы не терять набранное +содержимое цепочки. Определить, что переносится как есть, что пересчитывается, +и как это стыкуется с двумя осями версионирования (цепочка ТГУ + контент миссии). + +## Контекст / связанные места +- Маршруты и их сохранение: `createSatelliteMission`, `planId == missionId`. +- Сбросы/съёмки: `pcp-mission-planing-service` — `SurveyMode`/`DropMode`, + `MissionPlaningService`. +- Версионирование/перевыдача плана — две оси (цепочка ТГУ + контент миссии). +- Время — сквозной naive `LocalDateTime` без зоны (после отката UTC-слоя). diff --git a/docs/tasks/TASK_tgu-editor-integration.md b/docs/tasks/TASK_tgu-editor-integration.md deleted file mode 100644 index fe32add..0000000 --- a/docs/tasks/TASK_tgu-editor-integration.md +++ /dev/null @@ -1,219 +0,0 @@ -# Задача: встроить интерфейс «Редактор включений КА» в `pcp-tgu-ops-ui` - -> Документ-ТЗ для кодинг-агента. Раздел 0 — промпт для запуска (скопировать в агента). -> Разделы 1–11 — инструкция, которую агент читает в репозитории и которой следует. -> Стиль работы, границы слоёв и формат финального ответа — по корневому `AGENTS.md`. - ---- - -## 0. Промпт для запуска агента (copy-paste) - -``` -Ты работаешь в монорепозитории observatio-terrae (pcp). - -Задача: встроить прототип интерфейса «Редактор включений КА» в существующее -фронтенд-приложение services/pcp-tgu-ops-ui (Vite + React 19 + TypeScript). - -Перед началом обязательно прочитай: -1. /AGENTS.md — правила работы, стиль, формат финального ответа. -2. /docs/tasks/TASK_tgu-editor-integration.md — полное ТЗ этой задачи (этот файл). -3. Исходный прототип: /docs/ui-prototypes/pcp-tgu-editor/ (editor.jsx и соседние). -4. Целевую структуру: services/pcp-tgu-ops-ui/src (App.tsx, features/, api/, styles/). - -Это ПОРТ, а не копирование: прототип на Babel-standalone с глобалями и мок-данными; -цель — ESM/TS-модули, реальный API-клиент, существующая тема и паттерны проекта. - -Выполняй строго по этапам из раздела 5 ТЗ. Делай минимальные ревьюопригодные изменения, -не трогай чужие сервисы и backend (Kotlin). Карту, орбитальную математику и мок-данные -из прототипа НЕ переноси — переиспользуй features/tgu-map-2d и api/tguApi (раздел 3). -После каждого этапа прогоняй `npm run test` и `npm run build` в модуле. - -В конце дай отчёт в формате из раздела «Обязательный формат финального ответа» AGENTS.md. -``` - ---- - -## 1. Контекст и цель - -`pcp-tgu-ops-ui` — операторский UI ТГУ (Mission Ops): уже работающее SPA на **Vite 7 + React 19 + TypeScript**, feature-sliced структура (`features/tgu-planning`, `features/tgu-map-2d`), централизованный API-клиент `src/api/tguApi.ts` (все вызовы через `/api/...`, dev-proxy на backend `:7008`), токены темы в `src/styles/theme.css`, юнит-тесты на Vitest. - -Прототип «Редактор» — браузерный demo (React/Babel из CDN, JSX компилируется в браузере, общие глобали `D`/`SENSOR_MODES`/`window.WorldGeo`, мок-данные из `data.jsx`). Это **другой стек**, поэтому его нельзя «подключить» файлами — нужно портировать как новую фичу приложения. - -**Цель:** редактор включений КА доступен как третья вкладка приложения (`timeline | map | editor`), работает на реальных данных через `tguApi`, переиспользует существующую карту и тему, покрыт тестами и собирается без ошибок. - -Функциональность редактора (из прототипа `editor.jsx`): выбор активного КА/плана, черновик включений (`works`: `shoot` — съёмка, `downlink` — сброс), drag/resize по таймлайну, добавление съёмки по точке на карте и сброса по сеансу со станцией, контекст соседних КА, undo/redo, проверка конфликтов (пересечения по времени — живые; видимость цели/станции — по кнопке), применение правок. - ---- - -## 2. Границы задачи (scope) - -**В scope:** -- Новая фича `src/features/tgu-editor/` в `pcp-tgu-ops-ui`. -- Третья вкладка `"editor"` в существующем переключателе вкладок. -- Перенос UI-компонентов редактора на TSX + типизация. -- Чистые модули логики (undo/redo, конфликты) + их тесты. -- Подключение к реальным данным через `tguApi` (планы/платформы). -- Перемаппинг токенов темы. - -**Вне scope (НЕ делать без отдельного запроса):** -- Изменения в любых Kotlin-сервисах, включая `pcp-tgu-service` (backend-эндпоинт сохранения правок — отдельная задача, см. раздел 7). -- Перенос карты/орбитальной математики/мок-данных из прототипа (раздел 3). -- Добавление роутера, state-менеджеров, UI-библиотек, новых рантайм-зависимостей без необходимости. -- Рефакторинг существующих фич `tgu-planning` / `tgu-map-2d` сверх минимально необходимого для переиспользования. -- Изменения `helm/`, `settings.gradle.kts`, CI (модуль фронта в них и так не участвует). - ---- - -## 3. Целевая архитектура и правила переиспользования - -Следуй существующему feature-sliced паттерну (см. `features/tgu-planning`, `features/tgu-map-2d`): UI-компоненты в `*.tsx`, типы в `model/*.ts`, нетривиальная логика — в отдельных чистых `*.ts` модулях с тестами. - -**Обязательно переиспользовать (НЕ дублировать):** -- **Карта** — `features/tgu-map-2d` (`Tgu2DMapView`, `canvas/drawTracks|drawSwath|drawStations|drawPlanWorks|drawBaseMap`, `geometry/mapProjection|spatialIndex|bbox`). Редактору нужна карта в режиме «выбор точки съёмки» и подсветки трасс — расширяй существующий компонент пропсами (`pickMode`, `onPickPoint`, `targets`), а не переноси `mapview.jsx`/`orbit.jsx`/`world.js`/`basemap.js`. -- **API** — `src/api/tguApi.ts` (`fetchPlans`, `fetchPlatforms`, паттерн `fetchJson`/`TguApiError`). Все сетевые вызовы редактора добавляй сюда же или в `features/tgu-editor/editorApi.ts` в том же стиле. -- **Тема** — `src/styles/theme.css` (см. раздел 6). -- **Модели** — `src/model/tguTypes.ts`, `src/model/timelineTypes.ts`. Переиспользуй существующие типы планов/платформ, не вводи параллельные. - -**Не переносить из прототипа:** `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx` (дублируют то, что уже есть в TS), `data.jsx` (мок), `tabs.jsx` (заменяется вкладкой), `Editor.html`/`Map.html` (точка входа уже `index.html` + `main.tsx`). - -Орбитальные функции, которых нет в `tgu-map-2d` (`targetPasses`, `stationPasses`, `subPoint`, `nearestStation`), вынеси в чистый общий модуль `features/tgu-map-2d/geometry/passes.ts` (с тестами) и переиспользуй из карты и редактора. Не размещай бизнес/гео-логику внутри React-компонентов. - ---- - -## 4. Инвентаризация и маппинг файлов - -Источник: `docs/ui-prototypes/pcp-tgu-editor/` (положить туда содержимое архива до начала работ — по образцу уже существующих `docs/ui-prototypes/pcp-tgu-mission-ops/`). - -| Прототип | Действие | Целевой файл | -|---|---|---| -| `editor.jsx` (`EditorApp`) | Портировать, убрать `ReactDOM.createRoot`, сделать экспортируемым компонентом-вкладкой | `features/tgu-editor/TguEditorTab.tsx` | -| `editor.jsx` (`EditorToolbar`) | Портировать | `features/tgu-editor/EditorToolbar.tsx` | -| `editorrail.jsx` | Портировать | `features/tgu-editor/EditorRail.tsx` | -| `editortimeline.jsx` | Портировать | `features/tgu-editor/EditorTimeline.tsx` | -| `editorpanels.jsx` (`WorkInspector`/`ShootPanel`/`DownlinkPanel`) | Портировать | `features/tgu-editor/EditorPanels.tsx` | -| `visibility.jsx` | Портировать визуализацию; гео-расчёты вынести в `passes.ts` | `features/tgu-editor/VisibilityTracks.tsx` | -| `editor.jsx` (undo/redo, mutate, seedDraft) | Вынести в чистый модуль | `features/tgu-editor/editorDraft.ts` (+`editorDraft.test.ts`) | -| `editor.jsx` (overlaps + runCheck) | Вынести в чистый модуль | `features/tgu-editor/editorConflicts.ts` (+`editorConflicts.test.ts`) | -| типы `work`/`draft`/`target` | Создать | `features/tgu-editor/model/editorTypes.ts` | -| `mapview.jsx`, `orbit.jsx`, `world.js`, `basemap.js`, `util.jsx`, `data.jsx`, `tabs.jsx`, `*.html` | НЕ переносить | — | - ---- - -## 5. Пошаговый план (атомарные этапы) - -Каждый этап завершай прогоном `npm run test` и `npm run build` в `services/pcp-tgu-ops-ui`. - -**Этап 0 — подготовка.** Скопировать прототип в `docs/ui-prototypes/pcp-tgu-editor/`. Прочитать `App.tsx`, `TguPlanningPage.tsx`, `TguToolbar.tsx`, `tguApi.ts`, `theme.css`, `Tgu2DMapView.tsx`. Зафиксировать в отчёте точки расширения. - -**Этап 1 — типы и каркас фичи.** Создать `features/tgu-editor/model/editorTypes.ts` (`Work` с `kind: "shoot" | "downlink"`, `origin`, `modeId`, `stationId`, `targetLat/Lon`, `isNew`; `Draft`, `EditorTarget`). Создать пустой `TguEditorTab.tsx` с заголовком-заглушкой. Скомпилироваться. - -**Этап 2 — чистая логика + тесты.** Перенести в `editorDraft.ts`: `seedDraft`, `mutate`, `undo`, `redo`, `addShoot`, `addDownlink`, `updateWork`, `deleteWork`, `duplicateWork` (как чистые функции над `Draft`/`Work`, без React и без `Date.now()` внутри — id/время передавать параметром для детерминизма тестов). Перенести в `editorConflicts.ts`: `overlaps` и проверку видимости. Написать `editorDraft.test.ts` и `editorConflicts.test.ts` (undo/redo инварианты, обнаружение пересечений, отсутствие/наличие видимости). Это первоочередное — логика должна быть покрыта до UI. - -**Этап 3 — токены темы.** Реализовать перемаппинг (раздел 6). Убедиться, что портируемые стили резолвятся в палитру приложения. - -**Этап 4 — UI без карты.** Портировать `EditorToolbar`, `EditorRail`, `EditorTimeline`, `EditorPanels`, `VisibilityTracks` на TSX. Завязать их на состояние в `TguEditorTab` (`useState`/`useMemo`/`useCallback` напрямую из `react`). Drag/resize таймлайна сохранить. Вместо `alert()` использовать уведомление в стиле `decisionNotice`/`decisionError`. - -**Этап 5 — карта.** Расширить `Tgu2DMapView` пропсами `pickMode`/`onPickPoint`/`targets` (минимально, без поломки текущего использования в `Tgu2DMapTab`). Подключить в редакторе для выбора точки съёмки и подсветки трасс цели/станции через `passes.ts`. - -**Этап 6 — данные.** Заменить мок `D` на реальные данные: планы/платформы грузить через `tguApi` (как в `TguPlanningPage`). `seedDraft` строить из выбранного плана. Активный КА/план брать из пропсов (раздел 7), не из `location.search`. - -**Этап 7 — вкладка.** В `model/timelineTypes.ts`/`TguPlanningPage.tsx` расширить `activeTab` до `"timeline" | "map" | "editor"`, добавить кнопку вкладки в `TguToolbar` (`onTabChange`), отрендерить `TguEditorTab` при `activeTab === "editor"`, передав `selectedSpacecraftId`/`selectedPlan`/`appliedRange`. - -**Этап 8 — apply (заглушка).** Реализовать `applyDraft` через типизированную функцию `saveEditedPlan` в `editorApi.ts`, помеченную `// TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service` (раздел 7). Поведение: показать понятное уведомление о том, что сохранение во внешний сервис ещё не реализовано; не падать. - -**Этап 9 — финализация.** Прогнать `npm run test` и `npm run build`. Отчёт по формату AGENTS.md. - ---- - -## 6. Перемаппинг токенов темы - -Прототип использует oklch-токены (`--bg-0..3`, `--ink..--ink-3`, `--t-optical/radar/combo`, `--now`, `--warn`, `--line`, `--shadow`), которых нет в `theme.css` приложения (там hex: `--bg-base`, `--bg-panel`, `--text`, `--accent`, `--danger`, `--warning`, ...). - -Минимально-инвазивный подход: создать `src/styles/editor-tokens.css` (импортировать в `TguEditorTab` или в `main.tsx`) с алиасами, чтобы портированный код, ссылающийся на старые имена, резолвился в палитру приложения — без переписывания каждого inline-стиля: - -```css -:root { - --bg-0: var(--bg-base); - --bg-1: var(--bg-panel); - --bg-2: var(--bg-panel-2); - --bg-3: var(--bg-elevated); - --ink: var(--text); - --ink-1: var(--text); - --ink-2: var(--text-muted); - --ink-3: var(--text-dim); - --now: var(--warning); - --warn: var(--warning); - /* --line, --line-soft, --accent, --shadow совпадают по имени — проверить значения */ - /* --t-optical/radar/combo: задать на основе --accent/--warning/--danger или вынести в палитру типов КА */ -} -``` - -Уточнить фактические значения `--line/--line-soft/--accent/--shadow` (имена совпадают, значения могут отличаться) и зафиксировать решение в отчёте. Альтернатива (если команда предпочитает) — переписать стили редактора сразу на родные токены приложения; выбрать один путь и не смешивать. - ---- - -## 7. Данные и API - -- **Чтение:** планы и платформы — через существующие `fetchPlans`/`fetchPlatforms` (`tguApi.ts`). Активный КА/план редактор получает пропсами от `TguPlanningPage` (`selectedSpacecraftId`, `selectedPlan`), а не из URL. -- **Гео/орбиты:** через `tgu-map-2d` + `passes.ts`; мок `world.js`/`data.jsx` не использовать. -- **Сохранение правок (apply):** в текущем API `pcp-tgu-service` **нет** эндпоинта сохранения отредактированного набора включений (есть только выдача плана и `decision`/`issue`). Поэтому в этой задаче `applyDraft` реализуется как заглушка с явным `TODO(backend)` и не делает реальной записи. - -**Предложение контракта для backend-команды** (вне scope, зафиксировать в отчёте, не реализовывать): -- `POST /api/tgu-planning/plans/{planId}/edits` с телом `{ works: EditedWork[], baseRevision?: string }`, идемпотентно по `operationId`, возвращает новый план/attempt. Семантику (новый attempt vs патч существующего) согласовать отдельно. - ---- - -## 8. Конвенции - -Следуй `AGENTS.md` (раздел про стиль и scope) применительно к TS/React: -- Минимальные, локальные, ревьюопригодные изменения; не переименовывать и не форматировать несвязанные файлы. -- Предпочитать `const`, маленькие функции, явные имена; не вводить «умные» абстракции на будущее. -- Бизнес/гео-логику не держать в React-компонентах — выносить в чистые `*.ts` модули. -- Комментарии — там, где помогают сопровождению (undo/redo инварианты, правила конфликтов, edge cases), а не на каждую строку. Язык комментариев — как в проекте. -- Не глотать ошибки сети молча — использовать паттерн `TguApiError`/уведомлений, как в существующем коде. -- Не добавлять новые рантайм-зависимости без явной необходимости; React/ReactDOM/Babel из CDN прототипа не тащить — они уже есть в проекте как npm-пакеты. -- TypeScript: без `any` в публичных сигнатурах; типы — в `model/`. - ---- - -## 9. Критерии приёмки и команды валидации - -Рабочая директория: `services/pcp-tgu-ops-ui`. - -``` -npm install # при необходимости -npm run test # vitest run — все тесты зелёные, включая новые editorDraft/editorConflicts/passes -npm run build # tsc -b && vite build — без ошибок типов и сборки -npm run dev # ручная проверка: вкладка «Редактор» открывается, см. ниже -``` - -Функциональные критерии: -1. В тулбаре доступна третья вкладка; переключение `timeline | map | editor` работает, существующие вкладки не сломаны. -2. Редактор открывается на реальных данных (планы/платформы из `tguApi`), без мок-`D`. -3. Работают: drag/resize включений, добавление съёмки по точке на карте, добавление сброса по станции, undo/redo, проверка конфликтов (живые пересечения + видимость по кнопке). -4. Карта в редакторе — это `tgu-map-2d` (нет второго движка карты в бандле). -5. Тема консистентна с приложением (нет «бесцветных» из-за отсутствующих токенов). -6. `applyDraft` не падает и явно сообщает про отсутствующий backend-эндпоинт. - -Если какая-то команда не запускалась — указать какая и почему, и описать остаточный риск (по AGENTS.md). - ---- - -## 10. Риски, допущения, чего не делать - -- **Допущение:** структура планов/включений из `tguApi` достаточна, чтобы построить `seedDraft`. Если в реальном ответе нет полей под `works` редактора — зафиксировать gap, сделать адаптер-маппер и описать недостающие поля в отчёте, **не** менять backend. -- **Риск:** React 19 + StrictMode даёт двойной вызов эффектов — проверить `runCheck`/`setTimeout` на отсутствие двойных срабатываний. -- **Риск:** расширение `Tgu2DMapView` может задеть `Tgu2DMapTab` — новые пропсы делать опциональными с дефолтами, существующее поведение не менять. -- **Не делать:** правки в Kotlin-сервисах, в чужих фичах сверх необходимого, новый роутинг, новые тяжёлые зависимости, перенос мок-данных и второго движка карты. - ---- - -## 11. Обязательный формат финального ответа - -По `AGENTS.md` агент завершает задачу блоками: - -1. **Changed files** — список изменённых/созданных файлов. -2. **What changed** — краткое summary реализации по этапам. -3. **Architecture compliance** — почему соблюдены границы слоёв (логика вне компонентов, переиспользование `tgu-map-2d`/`tguApi`/темы, отсутствие дублей карты и мок-данных). -4. **Validation** — какие команды (`npm run test`, `npm run build`, ручная проверка) запускались и результаты. -5. **Risks / assumptions** — что не проверено, gap по данным/backend, edge cases. diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md new file mode 100644 index 0000000..dbd43cf --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/INTEGRATION.md @@ -0,0 +1,179 @@ +# INTEGRATION — встраивание вкладки «Состояние группировки» + +Цель: добавить дашборд новой вкладкой в существующий шелл (рядом с +`Планы / Карта / Комплексный план / Редактор / Заявки`), переключив встроенную +симуляцию на реальные данные. + +--- + +## 1. Зависимости + +```bash +npm i d3-geo topojson-client +# react / react-dom — уже есть в проекте +``` + +Версии, на которых собран прототип: `d3-geo@3`, `topojson-client@3`, `react@18`. + +--- + +## 2. Положить модуль + +Скопируйте папку `src/` в проект, например в `src/features/constellation/`. +Все импорты внутри — относительные, так что путь произвольный. + +`dashboard.css` импортируется автоматически из `ConstellationDashboard.jsx` +(`import "./dashboard.css"`). Если используете CSS-модули/Tailwind — см. п.6. + +--- + +## 3. Карта мира (TopoJSON) + +`MapView.jsx` сейчас тянет карту с CDN: + +```js +fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") +``` + +Для прода **положите файл локально** и раздавайте со своего origin: + +```js +// import landUrl from "../assets/land-110m.json?url"; // Vite +fetch(landUrl).then(r => r.json()).then(/* ... */) +``` + +Либо, если у вас уже есть карта (Leaflet/MapLibre/OpenLayers), рассмотрите +вариант рендерить слои группировки поверх неё — см. п.7. + +--- + +## 4. Монтирование как вкладки + +`src/index.js` отдаёт `default` = компонент `ConstellationDashboard` (без пропсов — +сейчас он сам строит данные из `data.js`/`sim.js`). + +Пример с массивом табов (адаптируйте под ваш роутер): + +```jsx +import ConstellationDashboard from "@/features/constellation"; + +const TABS = [ + { id: "plans", label: "Планы", element: }, + { id: "map", label: "Карта", element: }, + // ... + { id: "constel", label: "Группировка", element: }, // ← новая +]; +``` + +или с react-router: + +```jsx +} /> +``` + +### Важно про высоту +Корневой `.app` использует `height:100vh`, а `reference/styles.css` ставит +`body{overflow:hidden}`. Внутри вкладки с собственной шапкой это надо снять: + +- замените в `dashboard.css` `.app{height:100vh}` → `.app{height:100%}` и дайте + контейнеру вкладки реальную высоту (`height:100%` по цепочке родителей), **или** +- оставьте `100vh`, если вкладка занимает весь вьюпорт. +- правила для `body{…}` из `dashboard.css` лучше удалить/перенести, чтобы не влиять + на остальное приложение (или сразу оскопить — см. п.6). + +--- + +## 5. Контракты данных (главное) + +Сейчас данные синтетические. Замена — в двух файлах: `data.js` (что у нас есть) +и `sim.js` (где это находится во времени/пространстве). + +### 5.1 `data.js` — статический состав группировки + +```ts +type Payload = "optic" | "radar"; + +interface Satellite { + id: string; // уникальный ключ + name: string; // "Ресурс-П №3" + norad: number; + payload: Payload; // влияет на цвет и какие ROI снимает + domain: "ОПТИКА" | "ЛОКАЦИЯ"; // верхняя группировка в списке/таймлайне + group: string; // подгруппа ("Ресурс-П", "Кондор-ФКА", …) + color: string; // = COL[payload], проставляется автоматически + mem0: number; // стартовая загрузка памяти, % + orbit: { latAmp:number; period:number; phase:number; lon0:number }; // только для встроенной симуляции +} + +interface Station { id:string; name:string; lon:number; lat:number; } + +interface ROI { // заявка на съёмку (полигон) + id:string; name:string; payload:Payload; + poly:[number,number][]; // [[lon,lat], …] (без замыкающей точки) +} +``` + +Экспорты: `SATELLITES`, `STATIONS`, `ROIS`, `COL`. +Чтобы расширить до 20–30 КА и новых групп — просто добавляйте элементы; список, +сводка и таймлайн подхватывают автоматически (группировка идёт по `domain`→`group`). +Поле `orbit` нужно только встроенной симуляции (п.5.3). + +### 5.2 `sim.js` — геометрия и расписание + +Чистые функции, легко заменить на реальные источники: + +| Функция | Что возвращает | Чем заменить в проде | +|---|---|---| +| `subSat(sat, tMs, epochMs)` | `{lon,lat}` подспутниковой точки | реальная эфемерида/SGP4 по TLE на время `t` | +| `subSolar(tMs)` | `{lon,lat}` подсолнечной точки | можно оставить (астрономия точная) | +| `inShadow(lon,lat,tMs)` | в тени ли точка | по вашей модели освещённости | +| `statusAt(sat,tMs,env)` | `{state, p, roi?, station?, prohibit?, eclipse}` | **главный хук**: верните статус из вашего плана | +| `buildSchedule(...)` | интервалы `capture/downlink/prohibit/shadow` по КА на домене | отдайте готовые интервалы из API планирования | +| `buildProhibits(...)` | окна запрета по КА | из вашего сервиса ограничений (ВНЗ, питание и т.п.) | + +`state ∈ "operational" | "capture" | "downlink" | "prohibit"` (плюс флаг `eclipse`). +Приоритет в `statusAt`: запрет → съёмка → сброс → operational. + +### 5.3 Минимальная интеграция с API (рекомендуемый путь) +1. **Состав** (`SATELLITES/STATIONS/ROIS`) — из ваших справочников/`pcp-request-service`. +2. **Положение** — замените тело `subSat()` на расчёт по TLE (напр. `satellite.js`/SGP4), + `epochMs`/`period`/`latAmp`/`lon0` тогда не нужны. +3. **Расписание** — вместо `buildSchedule()` отдайте интервалы прямо из плана: + объект `{ [satId]: { capture:[], downlink:[], prohibit:[], shadow:[] } }`, + где интервал = `{ start:ms, end:ms, label?:string }`. Таймлайн и лента это рисуют как есть. +4. **Запреты** — `prohibit`-интервалы из сервиса ограничений (поле `reason` → подпись). +5. **Память борта** — если телеметрия известна, замените модельную динамику в RAF-цикле + `ConstellationDashboard.jsx` (блок `memory dynamics`) на реальные значения по `satId`. + +Домен времени задаётся в `buildEnv()` (`now-6h … now+18h`) — подгоните под горизонт плана. + +--- + +## 6. Изоляция стилей (если нужно) +`dashboard.css` — глобальные классы (`.app`, `.hdr`, `.side`, …) и `:root`-переменные. +Если боитесь конфликтов: +- оберните корень во `
` и добавьте префикс к селекторам + (или прогоните через CSS-модули / `@scope`), **или** +- оставьте как есть, если имена классов не пересекаются с вашими. +Переменные в `:root` безобидны (с префиксом `--bg0` и т.п.), но при желании +перенесите их на `.cdash`. + +--- + +## 7. (Опционально) рендер на вашей карте +Если в приложении уже есть карта (на скриншотах — тёмная equirectangular): +- слои **заявок (ROI)**, **станций**, **треков**, **полос обзора**, **линий сброса** + можно отрисовать вашими средствами (GeoJSON-слой / canvas-overlay), переиспользуя + геометрию из `sim.js` (`subSat`, footprint = окружность радиусом ~7–9° вокруг точки). +- тогда `MapView.jsx` нужен лишь как референс по слоям/цветам/порядку отрисовки. +- остальные части (`Header`, `SidePanel`, `Timeline`, RAF-часы/память) переиспользуются как есть. + +--- + +## 8. Проверка после встраивания +- [ ] Вкладка открывается, карта рисует сушу/сетку, КА движутся при Play. +- [ ] Скорости ×1…×600 меняют ход времени; «Сейчас» включает LIVE. +- [ ] Скраб по таймлайну двигает плейхед и пересчитывает статусы/карту. +- [ ] Клик по КА (список/карта/таймлайн) ставит фокус и затемняет остальных. +- [ ] Полосы памяти меняются на съёмке/сбросе. +- [ ] Высота/скролл вкладки корректны (см. п.4 «про высоту»). diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md new file mode 100644 index 0000000..6f8e9c1 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/README.md @@ -0,0 +1,156 @@ +# Handoff: Дашборд состояния группировки (Mission Ops «Состояние группировки») + +## Overview +Полноэкранная информационная панель (operations wall) для мониторинга орбитальной +группировки КА в реальном времени. Показывает на карте мира движущиеся аппараты, +их маршруты съёмки, сбросы данных на наземные станции, интервалы запрета работы и +тень Земли. Снизу — таймлайн событий по каждому КА. Воспроизведение управляется +кнопкой Play со скоростями ×1/×10/×60/×600, есть скраб по времени и кнопка «Сейчас». + +Назначение: дежурная смена видит «что сейчас делает группировка» одним взглядом — +кто снимает, кто сбрасывает, кто в запрете/в тени, загрузку памяти бортов и +ближайшие события. + +## About the Design Files +Файлы в этом пакете — **дизайн-референс, реализованный на HTML/React-прототипе**. +Это эталон внешнего вида и поведения, **не обязательно финальный продакшн-код**. +Задача — **воссоздать этот интерфейс в вашем кодовом окружении** (судя по скриншотам — +React + Vite, карта в стиле тёмной equirectangular-проекции), используя ваши паттерны, +ваш роутинг табов и, по возможности, **вашу существующую карту и ваши данные из API** +вместо встроенной симуляции. + +Два слоя в пакете: +- `reference/` — рабочий автономный прототип (открывается в браузере, грузит React/d3 с CDN). + Нужен только чтобы «пощупать» поведение и свериться по пикселям. +- `src/` — те же экраны, переписанные в **чистые ESM-модули** (`import/export`, без CDN + и без `window.*`). Это то, что встраивается в приложение. См. `INTEGRATION.md`. + +## Fidelity +**High-fidelity.** Цвета, типографика, отступы, состояния и анимации — финальные. +Воссоздавайте 1:1, подменяя только источник данных (см. ниже) и, при желании, +рендер карты на вашу картографическую библиотеку. + +## Screens / Views +Один экран, 4 функциональные зоны в CSS-grid/flex. + +### 1. Topbar (шапка) — `Header` +- **Layout:** горизонтальный флекс, высота `54px`, нижняя граница `1px var(--line)`, + фон `linear-gradient(var(--bg1),var(--bg0))`. Три блока: бренд (слева, `min-width:248px`), + транспорт (центр, `flex:1`, по центру), часы (справа, `min-width:248px`, выровнено вправо). +- **Бренд:** кружок-маркер `20px` с бирюзовым свечением + «СОСТОЯНИЕ ГРУППИРОВКИ» + (600, 13px, `nowrap`) и «MISSION OPS · ТГУ» (mono, 9px, `--dim`, letter-spacing .12em). +- **Транспорт:** кнопки `−1ч`, Play/Pause (бирюзовая, 42px), `+1ч`; разделитель; + сегмент скоростей `×1 ×10 ×60 ×600` (активная — бирюзовый фон); кнопка `СЕЙЧАС` + (в live-режиме — зелёная). +- **Часы:** бейдж `LIVE`/`СИМ` (LIVE — зелёная пульсирующая точка), крупное mono-время + `HH:MM:SS` (22px) + `UTC`, ниже `ДЕНЬ · ДД.ММ.ГГГГ` (mono, 10px). + +### 2. Left panel (левая панель) — `SidePanel` +Ширина `328px`, фон `--bg1`, правая граница, вертикальный флекс, скролл внутри. +- **СВОДКА ГРУППИРОВКИ:** 4 плитки (grid 4×1): СНИМАЮТ / СБРОС / ЗАПРЕТ / В ТЕНИ — + число (mono, 21px, цвет статуса) + подпись (8px, `--dim`). Под ними строка + «● ОПТИКА n ● ЛОКАЦИЯ n · N КА». +- **Поиск:** input (mono, 11.5px) — фильтрует список по name / NORAD / группе. +- **КОСМИЧЕСКИЕ АППАРАТЫ:** список, сгруппированный `ОПТИКА`→группы, `ЛОКАЦИЯ`→группы. + Каждая строка КА: цветная риска слева (cyan=оптика / violet=локация), имя (500, 12.5px), + статус справа (mono, 9px, цвет статуса, иконка ☾ если в тени), снизу `NORAD XXXXX`, + полоса памяти (4px) и `NN%`. Цвет памяти: бирюза → амбер >78% → красный >92%. + Клик по строке = фокус КА (подсветка только его событий). +- **АКТИВНЫЕ СОБЫТИЯ:** лента текущих событий (СЪЁМКА/СБРОС/ЗАПРЕТ) с целью/станцией + и обратным отсчётом «ещё M:SS». Клик = фокус соответствующего КА. + +### 3. Map (карта) — `MapView` +Занимает всё центральное пространство (`flex:1`). Canvas 2D, equirectangular-проекция +(d3-geo), повёрнута на `rotate([-10,0])`. Слои: +- База (кэш offscreen): океан `#0a0e0f`, сетка градусов (шаг 30°), суша `#171c1e` + (контур `#252d30`), прямоугольники заявок (ROI) пунктиром амбер, подписи станций (mono 9px). +- Тень Земли: ночное полушарие (d3.geoCircle от антисолнечной точки, r=90°) заливкой + `rgba(20,28,46,0.55)` с кромкой-терминатором. +- По каждому КА: синусоидальный трек (±52 мин, цвет по типу полезной нагрузки), + «полоса обзора» (footprint, geoCircle), глиф-ромб с гало и кольцом-статусом, подпись. +- Съёмка: амбер-утолщение трека + подсветка полигона заявки. +- Сброс: анимированная пунктирная синяя линия КА→станция + свечение станции. +- Станции: белые треугольники. +- Оверлеи (абсолютно): подсказка/карточка фокуса (сверху-слева), легенда (снизу-слева), + координаты курсора (снизу-справа, mono), тултип имени КА при наведении. + +### 4. Timeline (таймлайн) — `Timeline` +Высота `248px`, фон `--bg1`, верхняя граница. Шапка: «ТАЙМЛАЙН СОБЫТИЙ» + легенда +(СЪЁМКА/СБРОС/ЗАПРЕТ РАБОТЫ(штриховка)/ТЕНЬ ЗЕМЛИ) + статус режима. Тело — SVG: +- Левая колонка (`188px`): группы и имена КА с цветной точкой. +- Ось времени сверху (часовые тики, на полночь — дата). +- Дорожка каждого КА: полосы событий — тень (низ, слейт), сброс (синий), съёмка (амбер), + запрет (красная штриховка, во всю высоту). +- Вертикальный плейхед (бирюза в live, амбер в симуляции) + пунктирная линия «СЕЙЧАС» + (реальное время). Зажатие+перетаскивание = скраб времени. Клик по строке = фокус КА. + +## Interactions & Behavior +- **Play/Pause:** запуск/останов модельного времени. +- **Скорость ×1/×10/×60/×600:** множитель к ходу модельного времени (×1 = реальное). +- **СЕЙЧАС:** включает live-режим (время = системные часы, ×1, бейдж LIVE, зелёная пульсация). +- **−1ч / +1ч:** сдвиг модельного времени на час (выходит из live). +- **Скраб по таймлайну:** установка времени перетаскиванием (выходит из live). +- **Фокус КА:** клик в списке / на карте / в таймлайне / в ленте — подсвечивает один КА, + затемняет остальные; повторный клик снимает фокус. +- **Наведение на карту:** тултип имени ближайшего КА + координаты курсора (lat/lon). +- **Анимации:** бегущие пунктиры линий сброса, пульсация live-точки (1.4s), плавные + переходы полос памяти. Без бесконечных декоративных лупов на контенте. + +## State Management +Всё состояние — в корневом компоненте (`ConstellationDashboard.jsx`): +- `simRef` (модельное время, ms) — в `ref`, чтобы canvas рисовался на 60fps без ре-рендеров. +- `playingRef`, `speedRef`, `liveRef` — режим воспроизведения. +- `focusId` (state + ref) — выбранный КА. +- `memRef` — карта «загрузка памяти %» по КА (растёт при съёмке, падает при сбросе). +- `query` — строка поиска. `cursor`, `hover` — UI карты. +- RAF-цикл двигает время и память, рисует карту императивно (`mapApi.draw`), а панели/таймлайн + ре-рендерит троттлингом (~12 fps) через `setTick`. Производные (статусы, счётчики, + активные события) вычисляются в `render` из `simRef` + `memRef`. + +> Контракты данных и точки замены симуляции на реальное API — в `INTEGRATION.md`. + +## Design Tokens +Из `src/dashboard.css` (`:root`): +- **Фоны:** `--bg0 #0a0c0b` (канвас), `--bg1 #0e1211` (панели), `--panel #111514`, + `--ins #0a0e0d` (вставки/карта). +- **Линии:** `--line rgba(255,255,255,.06)`, `--line2 rgba(255,255,255,.11)`. +- **Текст:** `--hi #e7ece9`, `--mid #95a19d`, `--dim #5b6662`, `--faint #3c4644`. +- **Акценты:** `--teal #5fb3a3` (управление/активное), `--amber #d99a4e` (съёмка/заявки), + `--blue #5b9bd5` (сброс), `--red #d9685f` (запрет), `--green #5cae7e` (operational), + `--violet #a78bdb` (локация/РСА). +- **Тип нагрузки:** ОПТИКА `#56c2c8` (cyan), ЛОКАЦИЯ `#a78bdb` (violet) — `COL` в `data.js`. +- **Карта:** океан `#0a0e0f`, суша `#171c1e`/контур `#252d30`, ночь `rgba(20,28,46,.55)`. +- **Типографика:** IBM Plex Sans (UI), IBM Plex Mono (числа/координаты/ID/метки). + Заголовки секций — капс, letter-spacing .14–.16em. +- **Скругления:** 5–8px. **Высоты:** topbar 54, timeline 248, строка таймлайна 22. + +## Assets +- Карта мира: TopoJSON `world-atlas@2` → `land-110m.json` (грузится в `MapView`). + В проде положите файл локально или используйте свою карту (см. `INTEGRATION.md`). +- Иконок-картинок нет: станции/КА рисуются на canvas, бренд-маркер — CSS. +- Шрифты: IBM Plex Sans/Mono (Google Fonts; `@import` в `dashboard.css` можно убрать, + если шрифты уже есть в приложении). + +## Files +``` +design_handoff_constellation_dashboard/ +├── README.md ← этот файл (дизайн-референс) +├── INTEGRATION.md ← как встроить вкладку + контракты данных +├── reference/ ← автономный прототип «как должно выглядеть» +│ ├── preview.html ← открыть в браузере +│ ├── styles.css +│ └── js/ (data, sim, map, timeline, panels, app — версия с CDN/window.*) +└── src/ ← ESM-модули для встраивания + ├── index.js ← точка входа (default = ConstellationDashboard) + ├── ConstellationDashboard.jsx← корневой компонент вкладки + ├── MapView.jsx ← canvas-карта + ├── Timeline.jsx ← таймлайн событий + ├── Panels.jsx ← Header + SidePanel + ├── sim.js ← геометрия/расписание (заменяемо на API) + ├── data.js ← модель группировки (заменяемо на API) + ├── util.js ← хелперы + └── dashboard.css ← все стили +``` + +См. **`INTEGRATION.md`** — пошагово: зависимости, монтирование как вкладки, +контракты данных и где подключить реальные эфемериды/расписание. diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx new file mode 100644 index 0000000..8d3c4a9 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/app.jsx @@ -0,0 +1,203 @@ +/* ============================================================ + app.jsx — wiring, RAF clock, memory sim, derived state + ============================================================ */ +const { useState, useEffect, useRef, useMemo } = React; + +function buildEnv() { + const epochMs = Date.now(); + const { sats, stations, rois } = window.OPS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +function App() { + const cfg = useMemo(buildEnv, []); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: window.fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +ReactDOM.createRoot(document.getElementById("root")).render(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js new file mode 100644 index 0000000..7ff8b83 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (plain JS, attaches window.OPS) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + ============================================================ */ +(function () { + const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + + window.OPS = { sats, stations, rois, COL }; +})(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx new file mode 100644 index 0000000..3d9f07a --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/map.jsx @@ -0,0 +1,331 @@ +/* ============================================================ + map.jsx — canvas world map (exports window.MapView) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +const { useRef, useEffect } = React; + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs); + const pp = s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +function hexA(hex, a) { + if (hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} + +window.MapView = MapView; +window.hexA = hexA; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx new file mode 100644 index 0000000..555036d --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/panels.jsx @@ -0,0 +1,180 @@ +/* ============================================================ + panels.jsx — header + left panel (exports window.Header, + window.SidePanel). Status meta, summary, roster, activity feed. + ============================================================ */ +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`, + date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + UTC +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +window.Header = Header; +window.SidePanel = SidePanel; +window.STATUS_META = STATUS_META; +window.fmtDur = fmtDur; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js new file mode 100644 index 0000000..afb4ac8 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/sim.js @@ -0,0 +1,158 @@ +/* ============================================================ + sim.js — geometry + schedule (plain JS, attaches window.SIM) + ============================================================ */ +(function () { + const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSat(sat, tMs, env.epochMs); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSat(s, t, env.epochMs); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + + window.SIM = { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, + }; +})(); diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx new file mode 100644 index 0000000..af1f142 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/js/timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + timeline.jsx — bottom event timeline (exports window.Timeline) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +const { useRef: useRefTL, useEffect: useEffectTL, useState: useStateTL } = React; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const isDay = d.getUTCHours() === 0; + return ( + + + + {isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +window.Timeline = Timeline; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html new file mode 100644 index 0000000..af71843 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/preview.html @@ -0,0 +1,34 @@ + + + + + +Состояние группировки — Mission Ops + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css new file mode 100644 index 0000000..12d5124 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/reference/styles.css @@ -0,0 +1,180 @@ +:root { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; +} +*{box-sizing:border-box;} +html,body{height:100%;margin:0;} +body{ + background:var(--bg0); color:var(--hi); + font-family:'IBM Plex Sans',system-ui,sans-serif; + font-size:13px; -webkit-font-smoothing:antialiased; + overflow:hidden; +} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +button{font-family:inherit; cursor:pointer; color:inherit;} +::-webkit-scrollbar{width:8px;height:8px;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +::-webkit-scrollbar-track{background:transparent;} + +.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx new file mode 100644 index 0000000..1cabe32 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/ConstellationDashboard.jsx @@ -0,0 +1,210 @@ +/* ============================================================ + ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM) + Default export: — монтируется как вкладка. + ============================================================ */ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import MapView from "./MapView.jsx"; +import Timeline from "./Timeline.jsx"; +import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +import * as SIM from "./sim.js"; +import { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; +import "./dashboard.css"; + +function buildEnv() { + const epochMs = Date.now(); + const sats = SATELLITES, stations = STATIONS, rois = ROIS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +function App() { + const cfg = useMemo(buildEnv, []); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +export default App; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx new file mode 100644 index 0000000..1180930 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/MapView.jsx @@ -0,0 +1,331 @@ +/* ============================================================ + MapView.jsx — canvas world map (ESM, default export) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +import React, { useRef, useEffect } from "react"; +import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo"; +import { feature } from "topojson-client"; +import * as SIM from "./sim.js"; +import { hexA } from "./util.js"; + +// keep the original call-sites (d3.* / topojson.*) working unchanged +const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle }; +const topojson = { feature }; + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch("https://unpkg.com/world-atlas@2.0.2/land-110m.json") + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = SIM.subSat(sat, tMs + dm * 60000, env.epochMs); + seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink") { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = SIM.subSat(sat, apiRef.current.simTime(), env.epochMs); + const pp = s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +export default MapView; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx new file mode 100644 index 0000000..a934870 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Panels.jsx @@ -0,0 +1,180 @@ +/* ============================================================ + Panels.jsx — header + left panel (ESM) + Exports: Header, SidePanel, STATUS_META, fmtDur. + ============================================================ */ +import React from "react"; +import { COL } from "./data.js"; + +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`, + date: `${p(d.getUTCDate())}.${p(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getUTCDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + UTC +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +export { Header, SidePanel, STATUS_META, fmtDur }; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx new file mode 100644 index 0000000..9351480 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/Timeline.jsx @@ -0,0 +1,160 @@ +/* ============================================================ + Timeline.jsx — bottom event timeline (ESM, default export) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react"; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const isDay = d.getUTCHours() === 0; + return ( + + + + {isDay ? `${String(d.getUTCDate()).padStart(2, "0")}.${String(d.getUTCMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* downlink */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +export default Timeline; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css new file mode 100644 index 0000000..31f9ab6 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/dashboard.css @@ -0,0 +1,183 @@ +/* Fonts — remove this @import if your app already loads IBM Plex */ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +:root { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; +} +*{box-sizing:border-box;} +html,body{height:100%;margin:0;} +body{ + background:var(--bg0); color:var(--hi); + font-family:'IBM Plex Sans',system-ui,sans-serif; + font-size:13px; -webkit-font-smoothing:antialiased; + overflow:hidden; +} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +button{font-family:inherit; cursor:pointer; color:inherit;} +::-webkit-scrollbar{width:8px;height:8px;} +::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +::-webkit-scrollbar-track{background:transparent;} + +.app{height:100vh; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js new file mode 100644 index 0000000..d66b900 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (ESM) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + См. INTEGRATION.md — эти массивы предполагается заменить данными из API. + ============================================================ */ +export const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + +export { sats as SATELLITES, stations as STATIONS, rois as ROIS }; + diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js new file mode 100644 index 0000000..ab53214 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/index.js @@ -0,0 +1,10 @@ +/* index.js — public entry for the dashboard module */ +export { default } from "./ConstellationDashboard.jsx"; +export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx"; + +// granular exports (in case you want to recompose the layout) +export { default as MapView } from "./MapView.jsx"; +export { default as Timeline } from "./Timeline.jsx"; +export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +export * as SIM from "./sim.js"; +export { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js new file mode 100644 index 0000000..2494881 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/sim.js @@ -0,0 +1,158 @@ +/* ============================================================ + sim.js — geometry + schedule (ESM) + Чистая геометрия подспутниковой точки, тень, расписание событий. + См. INTEGRATION.md — subSat()/statusAt() можно заменить на реальную эфемериду/API. + ============================================================ */ +const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + function statusAt(sat, tMs, env) { + const p = subSat(sat, tMs, env.epochMs); + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSat(s, t, env.epochMs); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + +export { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, buildSchedule, STATION_RANGE, +}; diff --git a/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js new file mode 100644 index 0000000..0f91d08 --- /dev/null +++ b/docs/ui-prototypes/tgu-ops/design_handoff_constellation_dashboard/src/util.js @@ -0,0 +1,9 @@ +/* util.js — small shared helpers (ESM) */ + +// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged +export function hexA(hex, a) { + if (typeof hex !== "string" || hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} diff --git a/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md b/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md new file mode 100644 index 0000000..af9dd3f --- /dev/null +++ b/docs/ui-prototypes/бриф-дашборд-состояние-группировки.md @@ -0,0 +1,115 @@ +# Хэндофф для Claude Design — дашборд «Состояние группировки» + +Этот файл — то, что прикладываем в чат Claude Design вместе со скриншотами текущего UI. +Цель: спроектировать **новый экран «Состояние группировки»** в едином стиле с уже сделанными +прототипами (`pcp-tgu-mission-ops`, `pcp-service-map`, `pcp-tgu-editor`), но на **реальных +токенах продукта**. + +Эталон стиля — **реальный код** pcp-tgu-ops-ui (`src/styles/theme.css` + `editor-tokens.css`), +а не oklch/IBM Plex из прежних прототипов. Ниже — те же имена токенов, что в прототипах, +но с боевыми значениями. Используй их as-is. + +--- + +## 1. Токены (вставить в `:root` нового прототипа) + +```css +:root { + /* Фоны (тёмная пультовая тема) */ + --bg-0: #111315; /* база */ + --bg-1: #171a1c; /* панель */ + --bg-2: #1f2325; /* панель 2 */ + --bg-3: #262b2e; /* приподнятый элемент */ + --line: #30373a; + --line-soft: #252b2d; + --grid: #252b2d; + + /* Текст */ + --ink: #e7ecec; + --ink-1: #e7ecec; + --ink-2: #a4b0b1; + --ink-3: #748183; + + /* Акцент / статусы */ + --accent: #68c3bd; /* бирюза — основной акцент */ + --accent-strong: #82ded8; + --danger: #d64545; + --warning: #f0ad2e; + --success: #2f9e44; + --now: #f0ad2e; /* линия «сейчас» */ + --warn: #f0ad2e; + + /* Типы аппаратуры (ДЗЗ): оптика / радар / комбо */ + --t-optical: #68c3bd; + --t-optical-dim: rgba(104, 195, 189, 0.18); + --t-radar: #f0ad2e; + --t-radar-dim: rgba(240, 173, 46, 0.16); + --t-combo: #d64545; + --t-combo-dim: rgba(214, 69, 69, 0.16); + + /* Шрифты */ + --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + + --radius: 7px; + --shadow: 0 16px 42px rgba(0, 0, 0, 0.28); +} +``` + +**Правила стиля, обязательные к соблюдению:** +- Все **числа, идентификаторы, времена** — моноширинным (`--mono`). Это узнаваемая черта UI. +- Подписи/заголовки — `--sans` (Inter). +- Никаких oklch и IBM Plex — только значения выше. + +--- + +## 2. Что изменилось в продукте с момента прежних прототипов + +Если будешь заодно подравнивать существующие морды — учти эти расхождения с тем, что было +сгенерировано раньше: + +- **Редактор:** левая панель информации шире (≈17rem вместо 15.5rem) — числа больше не липнут к краю. +- **Слоты:** статусы слотов проставляются и красятся **только после закладки плана на КА** + (статус `LAID_IN`), а не по «принятию». До закладки — нейтральны. +- **Закладка:** кнопка «Подтвердить закладку» заблокирована, пока начало закладки в будущем. +- **Комплексный план (вкладка):** появились история прогонов (выпадающий список прошлых расчётов), + тумблер «Показать заявки» (оверлей заявок на карте) и ограничение интервала расчёта — **не более 10 суток**. +- **Редактор / правка включения:** инспектор включения read-only; правка идёт через + «Редактировать» → предзаполненное окно построения, которое заменяет маршрут. +- **Таймлайн:** стрелки цепочки версий плана между выдачами. +- **Статусы плана:** `LAID_IN` и `SUPERSEDED` теперь реальны; `ACCEPTED` не возникает. + +--- + +## 3. ТЗ на новый экран «Состояние группировки» + +Оперативный дашборд состояния орбитальной группировки ДЗЗ. Главное — «живая» карта с +проигрыванием и подсветкой того, что снимается прямо сейчас. + +**Раскладка (предложение, можно улучшить):** +- Крупная карта мира (2D, как в `pcp-service-map`) — основная область. +- Снизу — компактная **полоса проигрывания**: кнопка ▶/⏸, скорость (1×/10×/60×/300×), + ползунок времени, текущее модельное время моноширинным. +- Слева/справа — узкая панель: список КА группировки со статусом (на связи / съёмка / сброс / + вне зоны), фильтр по типу аппаратуры (оптика/радар/комбо), счётчики. +- Верхняя строка — сводка: всего КА, сейчас снимают N, активных маршрутов M, ближайший сеанс. + +**Поведение «плей»:** +- По ▶ модельное время идёт вперёд; **метки КА двигаются по трассам** в реальном (масштабируемом) времени. +- Маршруты/полосы, которые **снимаются в текущий момент времени**, подсвечиваются (акцентом/типом + аппаратуры), у КА в режиме съёмки — выделенное состояние. +- При паузе — всё замирает, можно кликнуть КА/маршрут и увидеть детали (как инспектор в редакторе). +- Линия «сейчас» (`--now`) отделяет факт от прогноза, если будет мини-таймлайн снизу. + +**Данные (моки в `data.jsx`, сид-генерация как в прежних прототипах):** +- `spacecraft[]`: { id, name, group, type: optical|radar|combo, ... } + упрощённая трасса + (последовательность {t, lat, lon}) для анимации позиции по времени. +- `captures[]` (включения): { id, scId, tStart, tEnd, footprint (полигон/полоса), requestId? } — + по ним подсвечиваем «снимается сейчас», когда `tStart ≤ now ≤ tEnd`. +- `NOW` зафиксировать под дату проекта, диапазон — несколько суток. + +**Формат сдачи:** новая папка `docs/ui-prototypes/pcp-grouping-dashboard/` в том же устройстве, +что прежние прототипы: `app.jsx` (корень) + `data.jsx` (моки) + модули (`mapview.jsx`, +`timeline.jsx`/`playbar.jsx`, `sidebar.jsx`, `details.jsx`, `util.jsx`) + HTML-обёртка +(React/Babel из CDN, блок `:root` из раздела 1). Переиспользуй `basemap.js`/`world.js`/`orbit.jsx` +из `pcp-service-map`, если подходят. diff --git a/docs/аудит-сервис-заданий.md b/docs/аудит-сервис-заданий.md new file mode 100644 index 0000000..dc353b0 --- /dev/null +++ b/docs/аудит-сервис-заданий.md @@ -0,0 +1,310 @@ +# Аудит сервиса заданий — pcp-request-service + +Дата: 2026-06-06. Автор: Claude (Opus 4.8). +Охват: сервис `pcp-request-service` («сервис заданий») — задача [#26](бэклог-pcp-tgu-ops-ui.md). +Метод: статический разбор кода (контроллеры, Kafka-listener, сервисы сопоставления и жизненного +цикла, outbox, earth-grid), схема БД и Flyway-миграция, конфиг в `config-repo`, обзор тестов. + +Парные документы: общий [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md), +карты [PCP_ARCHITECTURE.md](architect/PCP_ARCHITECTURE.md) и +[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md). +Стык с сервисом паспорта маршрута (`pcp-route-processing-service`) — отдельная задача +[#28](бэклог-pcp-tgu-ops-ui.md), здесь только обозначен. + +## Сводка по состоянию + +Базовое здоровье — хорошее. Сервис держит жизненный цикл заявок на съёмку, ведёт учёт отснятой +территории геометрическим вычитанием и сопоставляет приходящие паспорта маршрутов с заявками. +Конкурентность и работа в нескольких инстансах продуманы: per-row пессимистичные блокировки, +`SKIP LOCKED` в outbox, advisory-lock на пересборку сетки, дедуп по уникальным ключам, +transactional outbox. 19 тестовых файлов с покрытием ключевых сервисов и репозиториев. + +Главные риски — не в инфраструктуре, а в **бизнес-логике закрытия и сопоставления**: +закрытие «по времени» само по себе не срабатывает (нет планировщика истечения), а матчинг +засчитывает покрытие без проверки совместимости аппарата. Приоритеты: **RS1**, **RS2** (высокий), +далее RS3/RS4 (средний), затем долг RS5–RS7. + +Порт 7005. БД `pcp_requests` (PostgreSQL + PostGIS), Flyway `V1`, `ddl-auto: validate`. +Пакет `org.nstart.dep265.requestservice` (несогласован с остальными — см. B3 общего аудита). + +## Назначение и контур + +Заявки создаются **только по HTTP**; паспорта маршрутов приходят **из Kafka**; завершение +заявки уходит наружу через **transactional outbox**. + +``` + HTTP (оператор / pcp-tgu-ops-ui через Vite-прокси) + POST/GET/DELETE /v1/requests · GET /v1/cells · /v1/grid + │ + ▼ + ┌───────────────────────────────────┐ + Kafka │ pcp-request-service │ Kafka + IN ───▶│ заявки · покрытие · lifecycle · │── OUT ──▶ pcp.request.status.v1 → geoportal + │ earth-grid · transactional outbox │ (outbox; COMPLETED/EXPIRED, #35) + └───────────────────────────────────┘ + ▲ │ + │ ▼ +pcp.route.georeference.v1 DB: pcp_requests (PostgreSQL + PostGIS) + │ + pcp-route-processing-service ◀── связь для задачи #28 + («сервис паспорта маршрута») +``` + +### Карта взаимодействий + +| Направление | Канал | Контрагент | Примечание | +|---|---|---|---| +| IN (HTTP) | `/v1/requests` (POST/GET/`/map`/`/{id}`/`/{id}/cells`/DELETE) | UI / оператор | заявки заводятся только здесь | +| IN (HTTP) | `/v1/cells` (+`/priority-map`), `/v1/grid` (settings, `/rebuild`) | UI / оператор | сетка и карта важности | +| IN (Kafka) | `pcp.route.georeference.v1` | **pcp-route-processing-service** | `KafkaMessage`, контракт из `pcp-types-lib` | +| OUT (Kafka) | `pcp.request.status.v1` | **geoportal** (внешний) | outbox `REQUEST_TERMINAL_STATUS`; терминальный статус `COMPLETED`/`EXPIRED` в payload (#35) | +| — | `pcp.request.in.v1` (`app.kafka.topics.request`) | — | в конфиге есть, **слушателя нет** — мёртвый конфиг (RS4) | +| DB | `pcp_requests` (Postgres + PostGIS) | — | Flyway `V1`, `ddl-auto: validate` | + +HTTP-поверхность: [RequestController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt), +[CellsController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/CellsController.kt), +[GridManagementController](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/GridManagementController.kt). + +## Доменная модель и БД + +Источник: [V1__create_schema.sql](../services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql). + +- **`requests`** — заявка/задание: `geometry` (исходная) + `remaining_geometry` (что осталось снять), + `geometry_area` / `remaining_area`, `coverage_required_percent`, окно `begin/end_date_time`, + `status`, `match_count`, `last_matched_at`, `completed_at`, мягкое удаление `deleted_at`, + `@Version version` (оптимистичная блокировка). Статусы CHECK-ограничением: + `ACCEPTED · ACTIVE · COMPLETED · EXPIRED · DELETED`. +- **`request_optics_params` / `request_rsa_params`** — параметры съёмки (разрешение, угол Солнца, + облачность / поляризация, интерферометрия). Хранятся, но в матчинге **не используются** (RS2). +- **`request_route_matches`** — аудит и идемпотентность сопоставления: `UNIQUE(request_id, route_id)`, + `original_intersection_*`, `applied_intersection_*`, `coverage_delta_percent`, `contributes_to_coverage`. +- **`outbox_events`** — transactional outbox: `UNIQUE(event_type, aggregate_id)`, статусы `NEW/PUBLISHED/FAILED`. +- **`earth_cells` / `request_cells` / `earth_grid_settings`** — сетка Земли и проекция заявок на неё; + PostGIS-триггеры заполняют `contour_geom` и пересчитывают `earth_cells.importance` + (= Σ `request_cell.importance × coverage_percent/100`). Это **карта важности**, не прогресс съёмки (RS6). + +Геометрия хранится как WKT (`TEXT`); вычисления — JTS (`jts-core`), без hibernate-spatial. + +## Учёт отснятой территории + +Ядро — [RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt), +геометрия — [GeometryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt). + +На каждый паспорт маршрута: +1. Кандидаты выбираются по **окну времени + статусу** (`ACCEPTED`/`ACTIVE`, не удалённые) — + [findRequestIdsForRoute](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt). +2. Идемпотентность: `existsByRequestIdAndRouteId` + `request_route_matches UNIQUE` + обработка гонки вставки. +3. На каждую заявку — своя транзакция с `findByIdForUpdate` (row-lock): маршрут пересекается с + **`remaining_geometry`** (не с исходной), остаток уменьшается `difference`, пересчитывается `remaining_area`. +4. `coveredPercent = (geometry_area − remaining_area) / geometry_area × 100`. + +Поскольку учёт идёт по остатку, **перекрывающиеся маршруты не задваиваются** — второй маршрут +засчитывает только новую площадь. Это корректная кумулятивная модель покрытия. + +Ограничения учёта вынесены в находки **RS2** (игнор совместимости аппарата), **RS5** (планарная +площадь в градусах), **RS6** (earth-grid — это важность, а не покрытие). + +## Жизненный цикл и закрытие заданий + +Вся логика — пересчёт статуса «с нуля» в +[RequestLifecycleService.refreshStatus](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt), +по приоритету сверху вниз: + +| Условие | Статус | +|---|---| +| `coveredPercent ≥ coverage_required_percent` | **COMPLETED** (ставится `completed_at`) — приоритет над сроком | +| иначе `now < begin_date_time` | ACCEPTED | +| иначе `now > end_date_time` | **EXPIRED** | +| иначе | ACTIVE | + +При переходе в терминальный статус (`COMPLETED` или `EXPIRED`) — транзакционный outbox-эвент +`REQUEST_TERMINAL_STATUS` (идемпотентно по `(event_type, aggregate_id)`, +[RequestStatusOutboxService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt)), +который затем публикуется +[OutboxPublisherService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt) +в `pcp.request.status.v1` (geoportal). См. #35. + +Ключевая особенность (бывший источник RS1): `refreshStatus` исторически вызывался **только из матчинга +маршрута**. После #29 его дополнительно дёргают планировщик истечения (`RequestExpiryService`) и +`createRequest`, поэтому закрытие «по времени» теперь срабатывает (см. RS1). + +## Горизонтальное масштабирование (несколько инстансов) + +Сервис **спроектирован под несколько одинаковых инстансов** — это сильная сторона. + +Безопасно при N инстансов: +- **Матчинг** — `findByIdForUpdate` (`SELECT … FOR UPDATE`, пессимистичная блокировка строки заявки) + в отдельной транзакции на заявку (`PROPAGATION_REQUIRED` из не-транзакционного listener → + одна независимая транзакция на заявку), плюс `@Version` и дедуп `(request_id, route_id)`. + Инстансы делят партиции топика (consumer-group `pcp-request-service-route`). +- **Outbox** — `SELECT … FOR UPDATE SKIP LOCKED` + `UNIQUE(event_type, aggregate_id)` → + параллельные инстансы не публикуют эвент дважды + ([OutboxEventRepository](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt)). +- **Пересборка сетки** — `pg_try_advisory_xact_lock` + ([GridRebuildLockService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildLockService.kt)) → + ребилдит только один инстанс. +- **Создание заявки** — `existsById` + PK + обработка `23505` → дубликат не пройдёт при гонке. + +Семантика доставки — **at-least-once**; повторная обработка маршрута безопасна за счёт дедупа. +После #31 «ядовитое» сообщение уходит в DLQ (`DefaultErrorHandler` + bounded retry), а не блокирует +партицию. Outbox FAILED после #35 ретраится с backoff и пределом попыток. Параллелизм потребления +маршрутов ограничен числом партиций топика. + +--- + +## Находки + +### RS1 (высокий). Нет планировщика истечения — закрытие «по времени» не срабатывает — ✅ устранено (#29) +Файлы: [RequestLifecycleService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestLifecycleService.kt), +[RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt) + +`refreshStatus` — единственный путь смены статуса — вызывается **только при сопоставлении маршрута**. +Единственный `@Scheduled` в сервисе — публикация outbox. Поэтому если по заявке после `end_date_time` +**не пришло ни одного маршрута**, она навсегда останется `ACTIVE`/`ACCEPTED` и **никогда не станет +`EXPIRED`**. В `EXPIRED` заявка попадает лишь побочно — если какой-то маршрут случайно пересечётся +с ней уже после истечения срока. Дополнительно: `createRequest` всегда ставит `ACCEPTED`, даже если +окно уже в прошлом (`refreshStatus` при создании не вызывается). + +Рекомендация: периодический job (`@Scheduled` + advisory-lock или `SKIP LOCKED`-выборка), который +переводит просроченные заявки в `EXPIRED` без участия маршрутов; вызывать `refreshStatus` и при создании. + +**Готово (#29):** добавлен [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt) — +`@Scheduled`-воркер переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`, забирая заявки батчами через +`findExpirableForUpdateSkipLocked` (`FOR UPDATE SKIP LOCKED` + верхний лимит батчей — безопасно при нескольких +инстансах). `refreshStatus` теперь вызывается и в `createRequest`, поэтому статус приёма отражает окно +(`ACCEPTED`/`ACTIVE`/`EXPIRED`). Конфиг — `pcp.request.expiry.*`. Тесты: `RequestExpiryServiceTest`, +`RequestRepositoryJpaTest`. + +### RS2 (высокий). Матчинг игнорирует совместимость аппарата +Файлы: [RouteMatchingService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt), +[RequestValidationService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestValidationService.kt) + +Кандидаты подбираются только по окну времени и статусу, а покрытие засчитывается по любому +геометрическому пересечению. `survey_type` (оптика/РСА), разрешение, угол Солнца, облачность и +**крен** в фильтрации **не участвуют**: крен парсится и валидируется (0–90°), но как фильтр не +используется; параметры съёмки лежат в БД мёртвым грузом. Следствие: РСА-маршрут «закроет» +оптическую заявку, маршрут с негодным разрешением/освещённостью засчитается как покрытие. +Перекликается с UI-задачей #4. + +Рекомендация: добавить проверку совместимости маршрут↔заявка перед зачётом покрытия (как минимум +survey_type и крен; в идеале — разрешение и угол Солнца). Требует, чтобы паспорт маршрута нёс тип +сенсора — согласовать в рамках #28. + +**Решение (2026-06-06): отложено до #28.** `RoutePassportDto` сейчас не несёт явный `surveyType` +(есть `polarisation`, `resolutionRange`/`Azimuth`, `rollAngle`, `visirAngle`). Фиксируем контракт +паспорта в рамках аудита связи сервисов #28 и тогда же добавляем фильтр совместимости. До этого +matching не трогаем. + +### RS3 (средний). Нет DLQ / обработчика ошибок Kafka — poison-сообщение блокирует партицию — ✅ устранено (#31) +Файл: [RequestKafkaListener.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/kafka/RequestKafkaListener.kt) + +Listener ловит исключение, логирует и **перебрасывает** его. DLQ и кастомный `CommonErrorHandler` +не настроены — работают дефолты Spring Kafka. Невалидное («ядовитое») сообщение маршрута будет +ретраиться бесконечно и **заблокирует партицию**. У парного `pcp-route-processing-service` DLQ есть, +здесь — нет. + +Рекомендация: `DefaultErrorHandler` с ограниченными ретраями и `DeadLetterPublishingRecoverer` +(топик вида `pcp.route.georeference-dlq.v1`); невалидные по контракту сообщения отправлять в DLQ, +а не зацикливать. + +**Готово (#31):** [KafkaConsumerConfig](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt) — +`DefaultErrorHandler` + `DeadLetterPublishingRecoverer` (DLQ `pcp.route.georeference-dlq.v1`), +экспоненциальный bounded retry; контрактные ошибки (`JsonProcessingException`/`ConstraintViolationException`) +не ретраятся, а сразу уходят в DLQ с диагностическими заголовками (`pcp-exception-class`, +`pcp-original-topic`/`-partition`/`-offset`). Настройки — `app.kafka.consumer.route.*`. По образцу +`pcp-route-processing-service`. Тест — `KafkaConsumerConfigTest`. + +### RS4 (средний). Мёртвый конфиг: cleanup и входной топик заявок — ✅ устранено (#32) +Файл: [config-repo/pcp-request-service.yaml](../config-repo/pcp-request-service.yaml) + +`app.request.cleanup-cron` / `cleanup-retention-days` были заданы, но **кода под них нет** — архивации +или удаления старых заявок не происходило. Аналогично `app.kafka.topics.request: pcp.request.in.v1` +был сконфигурирован, но **без слушателя** (заявки заводятся только по HTTP). Конфиг вводил в заблуждение. + +Решение (#32): ключи удалены из `config-repo` (привязок `@Value` не было). Если cleanup/приём заявок +из Kafka понадобятся — заводить конфиг вместе с реализацией. + +### RS5 (низкий). Планарная площадь в градусах вместо геодезической +Файл: [GeometryService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GeometryService.kt) + +Площади считаются JTS `geometry.area` в координатах EPSG:4326 (градусы). Для компактных заявок +процент покрытия как отношение почти корректен, но для крупных и высокоширотных областей даёт +систематическое искажение (градус долготы у полюса короче). + +Рекомендация: при росте требований к точности — считать площадь геодезически (например, PostGIS +`ST_Area(geography)` или проекция в равновеликую СК), а не планарно в градусах. + +**Решение (2026-06-06): отложено.** Покрытие считается отношением площадей, поэтому для обычных AOI +планарная оценка близка к геодезической. Переключение `area()` на геодезию ripple-эффектом затронет +учёт `coverageDeltaPercent` и точечные ассерты площадей в `RouteMatchingServiceTest`; делаем при +реальном росте требований к точности (крупные/высокоширотные заявки). + +### RS6 (низкий). `coverage_required_percent` захардкожен в 100%; earth-grid ≠ покрытие — ✅ устранено (#34) +Файлы: [RequestService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt), +[RequestGridProjectionService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestGridProjectionService.kt) + +`createRequest` всегда ставит `DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0`, игнорируя значение из API, +хотя колонка и поле его поддерживают. Отдельно стоит зафиксировать: `request_cells.coverage_percent` +и `/priority-map` — это **доля футпринта заявки в ячейке × важность** (карта приоритетов), считается +один раз при создании и **маршрутами не обновляется**. Реальный прогресс съёмки живёт только в +`remaining_geometry` / `remaining_area`. Это легко перепутать по названию поля. + +Рекомендация: пробрасывать требуемый процент из API; в документации/именовании развести «важность +ячейки» и «прогресс покрытия». + +**Готово (#34):** в `CreateRequestDto` добавлено опциональное поле `coverageRequiredPercent` +(0 < x ≤ 100, по умолчанию 100); оно пробрасывается в сущность и служит порогом `COMPLETED`, +константа `DEFAULT_COVERAGE_REQUIRED_PERCENT` убрана. В KDoc разведены «прогресс покрытия» +(`coverageRequiredPercent`/`remaining_area`) и «важность заявки» (`importance` → карта приоритетов +ячеек). Тесты — `RequestServiceTest`, `RequestApiDtoContractTest`. + +### RS7 (средний). Уведомление geoportal о финале заявки; outbox FAILED без backoff — ✅ устранено (#35) +Файлы: [OutboxPublisherService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt), +[OutboxEventRepository.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt), +[RequestStatusOutboxService.kt](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt) + +Потребитель завершения заявок — внешний сервис **geoportal** (в этом монорепо его нет, поэтому в +[PCP_SERVICE_INTERACTIONS.md](architect/PCP_SERVICE_INTERACTIONS.md) консьюмер не виден). Ему нужно +сообщать про **оба** терминальных исхода: «выполнено» (`COMPLETED`) и «срок вышел» (`EXPIRED`). Сейчас +эмитится только `COMPLETED` (в `pcp.request.completed.v1`), а `EXPIRED`-эвента нет вовсе. Плюс +публикуемая выборка берёт `NEW` + `FAILED`, поэтому FAILED **ретраится**, но без backoff и без cap: +при устойчивом сбое Kafka каждые 5 с все инстансы перевыбирают FAILED (нагрузка, но не потеря). + +Решение: свести уведомления в единый топик **`pcp.request.status.v1`** (по аналогии с +`pcp.slots.status.v1`) с терминальным статусом в payload, заменив orphan `pcp.request.completed.v1`; +эмитить `EXPIRED`-эвент через тот же transactional outbox (зависит от планировщика истечения **RS1**, +который и создаёт переход в `EXPIRED`). Outbox дополнить `next_attempt_at`/экспоненциальным backoff и +пределом попыток — по образцу `RoutePassportOutboxWorker` из `pcp-route-processing-service`. + +**Готово (#35):** единый топик `pcp.request.status.v1` с терминальным статусом в payload +(`COMPLETED`/`EXPIRED`, плюс `occurredAt`/`coveragePercent`) — заменил orphan `pcp.request.completed.v1`. +Единое событие `REQUEST_TERMINAL_STATUS` пишется в той же транзакции из двух мест: +[RouteMatchingService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt) +(по достижению покрытия) и [RequestExpiryService](../services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt) +(по истечению срока). Outbox получил `attempts`/`next_attempt_at`: при сбое — удвоение backoff от +`initial-backoff-ms` до `max-backoff-ms`, после `max-attempts` событие становится терминальным `FAILED` +и из выборки выпадает. Конфиг — `pcp.outbox.*`. Мелкий зазор: заявка, **созданная** уже просроченной, +терминальное событие не порождает (вырожденный кейс; обычные пути ACTIVE→COMPLETED/EXPIRED покрыты). + +## Что в порядке (намеренно не отмечаем как проблемы) + +- Конкурентность и мультиинстанс: row-lock на заявку, `SKIP LOCKED` в outbox, advisory-lock на сетку, + `@Version`, дедуп по уникальным ключам — сделано грамотно. +- Transactional outbox: терминальный эвент `REQUEST_TERMINAL_STATUS` пишется в той же транзакции, что и завершение/истечение заявки. +- Идемпотентность сопоставления: повтор маршрута безопасен (`request_route_matches UNIQUE` + проверки). +- Кумулятивный учёт покрытия по остатку (без задвоения перекрытий). +- Тесты: `RouteMatchingServiceTest`, `OutboxPublisherServiceTest`, + `RequestStatusOutboxServiceTest`, `RequestExpiryServiceTest`, `RequestRepositoryJpaTest`, `MigrationSchemaTest`. + +## Статус работ + +Сделано: +- ✅ **RS1** — устранено (#29): планировщик истечения `RequestExpiryService` + `refreshStatus` при создании. +- ✅ **RS3** — устранено (#31): `DefaultErrorHandler` + DLQ `pcp.route.georeference-dlq.v1`. +- ✅ **RS4** — устранено (#32): мёртвый конфиг удалён из `config-repo`. +- ✅ **RS6** — устранено (#34): проброс `coverageRequiredPercent` из API + разведение терминов. +- ✅ **RS7** — устранено (#35): единый топик `pcp.request.status.v1` (COMPLETED/EXPIRED) + backoff/cap outbox. + +Отложено (осознанно): +- ⏸ **RS2** (#30) — фильтр совместимости аппарата. Отложено **до #28**: паспорт маршрута не несёт явный `surveyType`, контракт фиксируется в аудите связи сервисов. +- ⏸ **RS5** (#33) — геодезическая площадь. Отложено до роста требований к точности (переключение `area()` затрагивает matching и тесты; покрытие — отношение площадей). diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md new file mode 100644 index 0000000..4bef3cf --- /dev/null +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -0,0 +1,97 @@ +# Бэклог pcp-tgu-ops-ui + +Список задач по сервису **pcp-tgu-ops-ui** (и связанным бэкендам). +Ведётся вручную и с помощью Claude: по просьбе — отмечаем выполненное и добавляем новые задачи. + +**Обозначения статуса:** +- `[ ]` — не начато +- `[~]` — в работе +- `[x]` — выполнено (переносится в раздел «Выполнено» с пометкой) + +У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер. + +> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации +> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). + +_Обновлено: 2026-06-08_ + +--- + +## Активные задачи + +### Карта и отображение +- [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца. +- [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. +- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться. + +### Группировка +- [ ] **#36** — Заполнение бортового накопителя на вкладке «Группировка». Сейчас (MVP) считается на фронте из объёмов маршрутов плана (`volumeMb`): старт 0 на начало интервала плана, `+vol` на конце съёмки, `−vol` на конце сброса, ёмкость борта — модельные 500 ГБ (`ONBOARD_CAPACITY_MB` в `features/constellation/realData.ts`). Довести до реального источника: брать ёмкость борта из НСИ платформы (не хардкод), а заполнение — из телеметрии/бортового учёта, когда такой канал появится; уточнить модель «съёмка копит / сброс опустошает» (частичные сбросы, очерёдность). + +### Редактор +- [ ] **#4** — Показывать на вкладке «Редактор» только те заявки, которые может удовлетворить аппарат (например, для оптики не показывать заявку, для которой доступна только локация). +- [ ] **#5** — Дать возможность превратить план в статусе `PLANNED` (построенный цепочкой от последнего `LAID_IN`) в черновик и вручную наполнить его включениями — до того, как он будет автоматически выдан по таймеру. +- [ ] **#38** — Правка SLOTS-маршрута «телепортирует» его (исчезает с карты). При «Редактировать» редактор пересобирает контур через `survey-route/build`, передавая сохранённый `timeStart`. Для маршрутов `source=MANUAL` это корректно (их `timeStart` = время построения, контур воспроизводится). Для `source=SLOTS` `timeStart`/виток/контур посчитаны другим конвейером и не согласованы: напр. режим 174 хранит `timeStart=2026-06-07T01:51:38`, виток 152, контур над Чёрным морем (центр 45.42, 39.40), но в 01:51:38 спутник по flight-line над Индией (12–16° с.ш., виток 150) — пересборка даёт контур над Индией (10.73, 77.06), исходный прячется → маршрут «пропадает» из вида. Корень — та же баллистическая рассинхронизация, что и [#37]. _Решение (2026-06-08): чиним только на бэкенде — согласовать `timeStart`/виток/контур у SLOTS-режимов, чтобы `survey-route/build` воспроизводил их. UI-страховку (не прятать исходный контур / блокировать правку SLOTS) сознательно НЕ делаем — симптом остаётся до бэкенд-фикса._ + +### Комплексный план +- [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки. + +### Бэкенд / инфраструктура +- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). +- [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:50–12:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene` → `visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. + +### Проверка сервисов +- [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности. +- [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой. + +### Баги +- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». +- [ ] **#39** — Авторасчёт плана выдаёт нереализуемые маршруты съёмки с креном > 45° (напр. `survey-211`, Emissio 1, крен 46.8°). Такой маршрут нельзя построить вручную: фронт зажимает `MAX_ROUTE_ROLL_DEG = 45` (`TguEditorTab.tsx`), а баллистика/геометрия маршрута и фильтр видимости (МПЛ, `abs(kren) <= 45°`) определены только до 45°. _Диагностика (2026-06-08): корень — данные каталога, не логика. slots-service строит крен слота как центр угловой полосы из каталога (`SlotService.kt:903-905`, `gamma=(begin+end)/2`) и предел 45° не учитывает; полосы берутся напрямую из `satellite_slot_angle`. В сиде каталога (`pcp-satellite-catalog-service/.../V1__initial.sql`) у **оптических** Emissio (id 1-6, 22) собственный профиль полос уходит до 50.42°, и полоса №17 [46.08°, 47.51°] даёт центр ровно 46.8°. Версия «посчитали по логике локации» проверена и НЕ подтвердилась: в slots-service нет ветвления по типу КА (оптика/локация) — все КА строятся одним `TestSatelliteImpl`, солвер выбирается стратегией запроса (GREEDY/CONTINUOUS), `type_code` не используется. Reflexio (локация) наоборот имеет узкие полосы ~17-25°. Решение (вернуться позже): либо обрезать/убрать в каталоге оптические полосы за 45° (поз. №16-20 у Emissio), либо добавить в slots-service зажим/отсев слотов по рабочему пределу крена КА. Истинный предел Emissio подтвердить у владельца каталога/баллистиков перед правкой._ + +### Аудит — надёжность и бэкенд +_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ +- [ ] **#12** — (B1, высокий) Camunda job-воркеры в `FlowService` молча глотают исключения (`catch { logger.warn(ex.message) }`) при `autoComplete=false`: задача не завершается и не падает, BPMN-процесс зависает без инцидента. Завершать через `newFailCommand`/проброс наверх, логировать `error(msg, ex)`. +- [ ] **#13** — (B2, средний) Снять симуляционную заглушку `Thread.sleep(5000)` («имитация подтверждения плана») в `FlowService.sendPlan` при появлении реального канала подтверждений. +- [ ] **#14** — (B3, средний) Привести в порядок несогласованные пакеты-копипасты (ballistics→`pcp_request_service`, complex-mission→`pcp_satellites_service`, ui-service и slots-service делят `slots_service`, request→`org.nstart.dep265`, tgu→без `.pcp.`). Отдельной задачей из-за широкого охвата. +- [ ] **#15** — (B4, низкий) Опечатка «srpring» в `pcp-srpring-boot-admin-server` растиражирована по каталогу, пакету, CI, docker-compose, gradle. Чинить вместе с #14. +- [ ] **#16** — (B5, низкий) ~9 мест `logger.warn/error(ex.message)` теряют stack trace — заменить на `logger.error("...", ex)`. + +### Аудит — фронтенд +_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ +- [ ] **#17** — (F1, средний) Вынести общий `src/api/http.ts` (`fetchJson` + `extractErrorText` + маппинг ошибок): `extractErrorText` продублирован в 5 клиентах, fetch-обвязка повторяется ~22 раза. Заодно устранить расхождение приоритета полей ошибки (F5). +- [ ] **#18** — (F2, низкий) Перевести нетипизированный островок `features/constellation/*` (`.jsx`/`.js`, `allowJs`) на TS или добавить декларации типов на границе. +- [ ] **#19** — (F3, низкий) Удалить мёртвый barrel `features/constellation/index.js` (нигде не импортируется). +- [ ] **#20** — (F4, низкий) Декомпозировать God-компонент `TguEditorTab.tsx` (1727 строк): вынести хуки (`useDraftHistory`, `useRouteBuilder`), разнести панели. + +### Аудит — гигиена репозитория и конфиги +_Источник и рекомендации: [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md)._ +- [ ] **#21** — (R1, средний) Убрать из git крупные дампы в корне (`pcp_requests_converted_create_requests.json` ~228 КБ, `response_заданияКубернетс.json` ~157 КБ, `response_заданияЕмельянов.json` ~66 КБ) — перенести в `src/test/resources`/`docs` или удалить с добавлением в `.gitignore`. +- [ ] **#22** — (R2, низкий) Удалить посторонние артефакты в корне: `node_modules/` + пустой `package-lock.json`, `obj_terra.zip`; при желании добавить `/package-lock.json` в `.gitignore`. +- [ ] **#23** — (R3, низкий) Прогнать `./gradlew clean` / удалить 26 устаревших каталогов `bin/` (задваивают исходники, шумят при grep). +- [ ] **#24** — (C1, средний) Унифицировать креды БД в config-repo: привести все сервисы к переопределяемому `password: ${DB_PASSWORD:...}` (как в tgu-service), убрать зашитые `password: password`. +- [ ] **#25** — (C2, низкий) Сузить CORS для прода в `pcp-ui-service` (`allowedOriginPatterns("*")` + `allowCredentials`) до конкретных origin; держать в курсе отсутствие CORS у напрямую вызываемых сервисов (работает только через прокси/nginx). + +### Аудит — сервис заданий (pcp-request-service) +_Источник и рекомендации: [аудит-сервис-заданий.md](аудит-сервис-заданий.md) (коды находок RS1–RS7)._ +- [ ] **#30** — (RS2, высокий) Матчинг засчитывает покрытие по любому геометрическому пересечению, игнорируя совместимость аппарата (`survey_type`, крен, разрешение, угол Солнца). Добавить фильтр совместимости маршрут↔заявка перед зачётом покрытия; тип сенсора в паспорте маршрута согласовать в рамках #28. _Отложено до #28 (решение, 2026-06-06): `RoutePassportDto` не несёт явный `surveyType` — контракт фиксируется в рамках аудита связи сервисов #28, тогда же делаем фильтр._ +- [ ] **#33** — (RS5, низкий) Площади считаются планарно в градусах (JTS `area` на EPSG:4326) — искажение для крупных/высокоширотных заявок. Перейти на геодезию (`ST_Area(geography)` или равновеликая проекция), когда вырастут требования к точности. _Отложено по решению аудита: покрытие считается отношением площадей (планар ≈ геодезия для обычных AOI), а переключение `area()` затрагивает matching и точечные ассерты тестов — вводим при росте требований к точности._ +--- + +## Выполнено +- [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] **#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] **#29** — (RS1) Планировщик истечения заявок. _Готово: `RequestExpiryService` (`@Scheduled`, `FOR UPDATE SKIP LOCKED`, батчи с верхним лимитом) переводит просроченные `ACCEPTED`/`ACTIVE` в `EXPIRED`; несколько инстансов безопасны. `refreshStatus` теперь вызывается и при создании заявки — статус приёма отражает окно (`ACCEPTED`/`ACTIVE`/`EXPIRED`), а не всегда `ACCEPTED`. Конфиг `pcp.request.expiry.*` в config-repo. Тесты: `RequestExpiryServiceTest` (юнит) + `RequestRepositoryJpaTest` (H2). Разблокирует EXPIRED-эмиссию в #35._ +- [x] **#32** — (RS4) Удалить мёртвый конфиг сервиса заданий. _Готово: из `config-repo/pcp-request-service.yaml` убраны `app.request.cleanup-cron`/`cleanup-retention-days` и неиспользуемый топик `app.kafka.topics.request` (`pcp.request.in.v1`); кода под ними не было, `@Value`-привязок нет._ +- [x] **#26** — Проверить сервис заданий (`pcp-request-service`) по отдельности. _Готово: аудит в [аудит-сервис-заданий.md](аудит-сервис-заданий.md) — карта взаимодействий, учёт покрытия, жизненный цикл, мультиинстанс; находки RS1–RS7 (RS1/RS2 — высокий приоритет)._ +- [x] **#9** — Расширить вправо левую панель информации на вкладке «Редактор» (цифры у границы смотрелись плохо). _Готово: commit 69dfd80, рейл 15.5rem → 17rem._ +- [x] **#10** — Проставлять статусы слотов только после того, как план заложен на КА. _Готово: commit 69dfd80, слоты занимаются по `LAID_IN`, а не по `ACCEPT`._ +- [x] **#11** — Запретить нажимать «Подтвердить закладку», пока начало закладки в будущем. _Готово: commit 69dfd80._ + + + + + + + +Добавил вручную: +- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index f6a971f..3859eb1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # VERSION: 0.0.3 ### Versions ### -versions.java=17 +versions.java=21 # plugins versions_kotlin=2.2.20 versions_spring_boot=4.0.2 @@ -15,7 +15,6 @@ versions.testcontainers=1.18.1 versions.otel=1.12.1 versions.lombok=1.18.36 versions.hibernate.spatial=6.5.1.Final -versions.camunda=8.4.15 versions.open-api=3.0.1 versions.spring.boot.admin=4.0.1 diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/DropRouteDTOs.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/DropRouteDTOs.kt new file mode 100644 index 0000000..ff21636 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/DropRouteDTOs.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +/** + * Запрос на ручное добавление сброса в план по выбранной зоне радиовидимости (ЗРВ). + * + * Оператор выбирает ЗРВ станции и набор уже запланированных маршрутов съёмки, время конца + * которых строго меньше начала зоны (т.е. съёмка успевает завершиться до сеанса сброса). + * Сброс сохраняется как [space.nstart.pcp.pcp_mission_planing_service]-режим DROP под основным + * планом миссии (planNumber = 1) с теми же связями съёмка↔сброс, что и при штатном расчёте. + */ +@Schema(description = "Запрос на ручное добавление сброса по выбранной зоне радиовидимости") +data class DropRouteSaveRequestDTO( + @field:Schema(description = "Идентификатор КА (NORAD)") + val satelliteId: Long = 0, + + @field:Schema(description = "Идентификатор/номер станции приёма (из ЗРВ)") + val station: String = "", + + @field:Schema(description = "Номер витка зоны радиовидимости") + val revolution: Long = 0, + + @field:Schema(description = "Начало зоны радиовидимости (локальное время без зоны)") + val zoneStart: LocalDateTime = LocalDateTime.now(), + + @field:Schema(description = "Конец зоны радиовидимости (локальное время без зоны)") + val zoneStop: LocalDateTime = LocalDateTime.now(), + + @field:Schema(description = "Идентификаторы режимов съёмки (survey_modes), которые сбрасываются в этой зоне") + val surveyModeIds: List = emptyList(), + + @field:Schema( + description = "Начало интервала плана (локальное время без зоны). Используется только при " + + "сохранении, если миссии/плана с таким id ещё нет — чтобы создать их с корректным окном.", + nullable = true + ) + val planStart: LocalDateTime? = null, + + @field:Schema( + description = "Конец интервала плана (локальное время без зоны). См. planStart.", + nullable = true + ) + val planEnd: LocalDateTime? = null +) diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt new file mode 100644 index 0000000..784ece3 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeDataRates.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +/** + * Скорости накопления/сброса данных для оценки объёма режима. + * + * Объём (`volumeMb`) считается единообразно во всех сервисах в **мегабайтах (Мбайт)** как + * `скорость × длительность`, поэтому константы живут в общей библиотеке типов — один источник + * правды для текущего плана (pcp-ui-service), редактора и комплексного плана (pcp-tgu-ops-ui). + * + * Внимание на единицы: съёмка задана в Мбайт/с, а скорость канала сброса — в Мбит/с, + * поэтому при расчёте объёма сброса скорость приводится к Мбайт делением на 8. + */ +object ModeDataRates { + /** + * Накопление при съёмке — единое для всех КА, **Мбайт/с**. + * Из расчёта 100 с маршрута = 12 ГБ (1 ГБ = 1000 Мбайт): 12000 Мбайт / 100 с = 120 Мбайт/с. + */ + const val SURVEY_MBYTE_PER_SEC: Double = 120.0 + + /** Сброс — постоянная скорость канала, **Мбит/с**. */ + const val DROP_MBIT_PER_SEC: Double = 1200.0 + + /** Бит в байте — приведение скорости канала (Мбит/с) к объёму памяти (Мбайт). */ + private const val BITS_PER_BYTE: Double = 8.0 + + /** Объём маршрута съёмки, Мбайт, по его длительности (секунды). */ + fun surveyVolumeMb(durationSeconds: Double): Double = durationSeconds * SURVEY_MBYTE_PER_SEC + + /** Объём сброса, Мбайт, по длительности сеанса (секунды): скорость канала Мбит/с → Мбайт. */ + fun dropVolumeMb(durationSeconds: Double): Double = durationSeconds * DROP_MBIT_PER_SEC / BITS_PER_BYTE +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt index a14befc..87f749c 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTO.kt @@ -1,9 +1,10 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo import io.swagger.v3.oas.annotations.media.Schema -import java.time.OffsetDateTime +import java.time.LocalDateTime @Schema( description = "Базовая модель режима", @@ -28,20 +29,24 @@ sealed interface ModeResponseDTO { val planId: Long @get:Schema(description = "Время начала режима") - val timeStart: OffsetDateTime? + val timeStart: LocalDateTime? @get:Schema(description = "Номер витка") val revolution: Long @get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"]) val type: String + + @get:Schema(description = "Объём режима, Мбайт (накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с)") + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + val volumeMb: Double } @Schema(description = "Режим съемки") data class SurveyModeResponseDTO( override val id: Long = 0, override val planId: Long = 0, - override val timeStart: OffsetDateTime? = null, + override val timeStart: LocalDateTime? = null, override val revolution: Long = 0, override val type: String = "SURVEY", val status: String = "", @@ -54,16 +59,22 @@ data class SurveyModeResponseDTO( val roll: Double = 0.0, @get:Schema(description = "Идентификаторы забронированных слотов") val bookedSlotIds: List = emptyList() -) : ModeResponseDTO +) : ModeResponseDTO { + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + override val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration) +} @Schema(description = "Режим сброса") data class DropModeResponseDTO( override val id: Long = 0, override val planId: Long = 0, - override val timeStart: OffsetDateTime? = null, + override val timeStart: LocalDateTime? = null, override val revolution: Long = 0, override val type: String = "DROP", val station: String? = "", val duration: Double = 0.0, val surveys: List = emptyList() -) : ModeResponseDTO +) : ModeResponseDTO { + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + override val volumeMb: Double get() = ModeDataRates.dropVolumeMb(duration) +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt index ef23741..d79d04d 100644 --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SatelliteModeResponseDTO.kt @@ -1,5 +1,6 @@ package space.nstart.pcp.pcp_types_lib.dto.satellite.mission +import com.fasterxml.jackson.annotation.JsonProperty import java.time.LocalDateTime data class SatelliteModeResponseDTO( @@ -19,4 +20,9 @@ data class SatelliteModeResponseDTO( val cellNums: List = emptyList(), val bookedSlotIds: List = emptyList(), val snapshotId: Long? = null -) +) { + /** Объём режима, Мбайт: сброс — канал 1200 Мбит/с (÷8), иначе — накопление съёмки 120 Мбайт/с. */ + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + val volumeMb: Double + get() = if (type == "DROP") ModeDataRates.dropVolumeMb(duration) else ModeDataRates.surveyVolumeMb(duration) +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt new file mode 100644 index 0000000..cbde248 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/SurveyRouteBuildDTOs.kt @@ -0,0 +1,82 @@ +package space.nstart.pcp.pcp_types_lib.dto.satellite.mission + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.DecimalMin +import java.time.LocalDateTime + +/** + * Способ построения временного окна маршрута относительно момента видимости. + */ +enum class SurveyRouteAnchor { + /** Окно центрируется на моменте видимости: [time - duration/2, time + duration/2]. */ + CENTERED, + + /** Момент видимости — начало съёмки: [time, time + duration]. */ + FORWARD +} + +@Schema(description = "Запрос на построение одного маршрута съёмки по параметрам видимости точки") +data class SurveyRouteBuildRequestDTO( + @field:Schema(description = "Идентификатор КА (тот же, что используется в obj-view/mpl-point — NORAD)") + val satelliteId: Long = 0, + + @field:Schema(description = "Момент видимости (локальное время без зоны)") + val time: LocalDateTime = LocalDateTime.now(), + + @field:DecimalMin(value = "1", message = "Длительность съёмки должна быть не меньше 1 секунды") + @field:Schema(description = "Длительность съёмки, секунды") + val durationSeconds: Double = 60.0, + + @field:Schema(description = "Крен (gamma), градусы") + val roll: Double = 0.0, + + @field:Schema(description = "Угол захвата (полуширина полосы), градусы") + val captureAngle: Double = 1.5, + + @field:Schema(description = "Номер витка (0 — определить по орбитальным точкам)") + val revolution: Long = 0, + + @field:Schema(description = "Способ построения окна относительно момента видимости") + val anchor: SurveyRouteAnchor = SurveyRouteAnchor.CENTERED, + + @field:Schema( + description = "Начало интервала плана (локальное время без зоны). Используется только при " + + "сохранении маршрута, если миссии/плана с таким id ещё нет — чтобы создать их с " + + "корректным окном (для расчёта пересечений и наложений). Игнорируется в /build.", + nullable = true + ) + val planStart: LocalDateTime? = null, + + @field:Schema( + description = "Конец интервала плана (локальное время без зоны). См. planStart.", + nullable = true + ) + val planEnd: LocalDateTime? = null +) + +@Schema(description = "Построенный маршрут съёмки") +data class SurveyRouteBuildResponseDTO( + val satelliteId: Long, + @field:Schema(description = "Начало съёмки (локальное время без зоны)") + val timeStart: LocalDateTime, + @field:Schema(description = "Длительность съёмки, секунды") + val duration: Double, + val revolution: Long, + @field:Schema(description = "Крен (gamma), градусы") + val roll: Double, + @field:Schema(description = "Угол захвата, градусы") + val captureAngle: Double, + @field:Schema(description = "Широта центра контура, градусы") + val lat: Double, + @field:Schema(description = "Долгота центра контура, градусы") + val longitude: Double, + @field:Schema(description = "Контур полосы съёмки, WKT POLYGON (lon lat, ...)") + val contourWkt: String, + @field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)") + val surveyModeId: Long? = null +) { + @get:Schema(description = "Объём маршрута, Мбайт (накопление съёмки 120 Мбайт/с × длительность)") + @get:JsonProperty(access = JsonProperty.Access.READ_ONLY) + val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration) +} diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt old mode 100755 new mode 100644 index 42b54ad..f8a236e --- a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/utils/json/LocalDateTimeTimeZoneSerializer.kt @@ -1,22 +1,22 @@ -package space.nstart.pcp.pcp_types_lib.utils.json - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.databind.JsonSerializer -import com.fasterxml.jackson.databind.SerializerProvider -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter - -class LocalDateTimeTimeZoneSerializer: JsonSerializer() { - - companion object { - val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx") - } - - override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { - - //todo: проверить корректность зоны по нормальным правилам -// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt)) - p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt)) - } -} \ No newline at end of file +package space.nstart.pcp.pcp_types_lib.utils.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonSerializer +import com.fasterxml.jackson.databind.SerializerProvider +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class LocalDateTimeTimeZoneSerializer: JsonSerializer() { + + companion object { + val fmt: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSxxx") + } + + override fun serialize(p0: LocalDateTime?, p1: JsonGenerator, p2: SerializerProvider) { + + //todo: проверить корректность зоны по нормальным правилам +// p1.writeString(p0?.atZone(ZoneId.of("Europe/Moscow"))?.format(fmt)) + p1.writeString(p0?.atZone(ZoneId.systemDefault())?.format(fmt)) + } +} diff --git a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt index 7fd1aad..7a0f836 100644 --- a/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt +++ b/libs/pcp-types-lib/src/test/kotlin/space/nstart/pcp/pcp_types_lib/dto/satellite/mission/ModeResponseDTOJacksonTest.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.module.kotlin.readValue import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test +import kotlin.test.assertTrue class ModeResponseDTOJacksonTest { @@ -50,4 +51,26 @@ class ModeResponseDTOJacksonTest { assertEquals("SURVEY", result[0].type) assertEquals("DROP", result[1].type) } + + @Test + fun `serializes computed volume and tolerates it on read-back`() { + // Сервис-источник теперь сериализует volumeMb; потребитель (pcp-ui-service) + // десериализует тот же JSON обратно — read-only поле не должно ломать чтение. + val modes: List = listOf( + SurveyModeResponseDTO(id = 1, planId = 10, duration = 100.0), + DropModeResponseDTO(id = 2, planId = 10, duration = 5.0) + ) + + val json = mapper.writeValueAsString(modes) + val tree = mapper.readTree(json) + + // Съёмка: 100 с × 120 Мбайт/с = 12000 Мбайт. Сброс: 5 с × 1200 Мбит/с ÷ 8 = 750 Мбайт. + assertEquals(12000.0, tree[0]["volumeMb"].asDouble()) + assertEquals(750.0, tree[1]["volumeMb"].asDouble()) + + val roundTrip: List = mapper.readValue(json) + assertEquals(12000.0, roundTrip[0].volumeMb) + assertEquals(750.0, roundTrip[1].volumeMb) + assertTrue(roundTrip[0] is SurveyModeResponseDTO) + } } diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt index be2be88..cf99c62 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/controller/SatelliteController.kt @@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.pcp_request_service.configuration.CustomValidationException import space.nstart.pcp.pcp_request_service.service.SatelliteIcEventService import space.nstart.pcp.pcp_request_service.service.SatelliteService -import space.nstart.pcp.pcp_types_lib.configuration.CustomValidationException import space.nstart.pcp.pcp_types_lib.dto.ballistics.ExactTimePositionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.ballistics.SatelliteICDTO import java.time.LocalDateTime diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt index 996f783..74d027b 100644 --- a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/SatelliteService.kt @@ -102,6 +102,9 @@ class SatelliteService { @Autowired private lateinit var earthCellViewRepository: EarthCellViewRepository + @Autowired + private lateinit var stationCatalogClient: StationCatalogClient + val astro = AstronomerJ2000(EarthType.PZ90d02) @@ -800,17 +803,32 @@ class SatelliteService { bal.rollMin = 0.0 bal.rollMax = 45.0 / 180 * PI + // ЗРВ считаем по реальным станциям приёма — каталог берём у pcp-stations-service. + // Идентификатор PPI = station.number, поэтому RadioVisibilityAreaDTO.stationId + // совпадает с номером станции (клиент сопоставляет название по своему списку станций). + val stations = stationCatalogClient.allStations() + if (stations.isEmpty()) { + logger.warn("Нет зарегистрированных станций — ЗРВ для КА {} не рассчитываются", id) + return emptyList() + } - val r = bal.calculateZRV( - listOf(PPI(1,1,0.35, 0.45, 100.0, 0.0, PI/2)), - tstart, - tstop - ) + val ppis = stations.map { station -> + PPI( + station.number, 1, + station.position.lat * PI / 180, + station.position.long * PI / 180, + station.position.height, + station.elevationMin * PI / 180, + station.elevationMax * PI / 180 + ) + } + + val r = bal.calculateZRV(ppis, tstart, tstop) if (r != BallisticsError.OK) { logger.error("Ошибка при расчете параметров ЗРВ : $r") return listOf() } else - return bal.zrv.map { mkRVA(it, id)} + return bal.zrv.map { mkRVA(it, id) }.sortedBy { it.onStart.time } } @@ -820,7 +838,8 @@ class SatelliteService { revolution = p.vit.toLong(), onStart = mkTP(p.zoneIn), onMaximum = mkTP(p.zoneMax), - onStop = mkTP(p.zoneOut) + onStop = mkTP(p.zoneOut), + duration = (p.zoneOut.t - p.zoneIn.t) ) fun mkTP(p : VisibilityParametersZRV) = TargetPositionDTO( toDateTime(p.t), diff --git a/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/StationCatalogClient.kt b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/StationCatalogClient.kt new file mode 100644 index 0000000..da8964c --- /dev/null +++ b/services/pcp-ballistics-service/src/main/kotlin/space/nstart/pcp/pcp_request_service/service/StationCatalogClient.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.pcp_request_service.service + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.WebClient +import space.nstart.pcp.pcp_types_lib.dto.ballistics.StationDTO + +/** + * Клиент к pcp-stations-service: получение списка наземных станций приёма. + * + * Расчёт зон радиовидимости (ЗРВ) ведётся по реальным станциям, поэтому баллистика + * сама забирает их каталог у сервиса станций (а не получает с фронта). + */ +@Service +class StationCatalogClient(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + private val logger: Logger = LoggerFactory.getLogger(this::class.java) + + @Value("\${settings.stations-service:stations-service}") + val url = "" + + /** Возвращает все зарегистрированные станции; при ошибке — пустой список (ЗРВ тогда не строится). */ + fun allStations(): List = + runCatching { + webClientBuilder.build() + .get() + .uri("$url/api/stations") + .retrieve() + .bodyToFlux(StationDTO::class.java) + .collectList() + .block() + ?: emptyList() + }.getOrElse { e -> + logger.error("Не удалось получить список станций из $url/api/stations: ${e.message}") + emptyList() + } +} diff --git a/services/pcp-ballistics-service/src/main/resources/application.yaml b/services/pcp-ballistics-service/src/main/resources/application.yaml index ae41013..973f1d2 100644 --- a/services/pcp-ballistics-service/src/main/resources/application.yaml +++ b/services/pcp-ballistics-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt index 56c5887..42ed9c9 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunResponseDTO.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import java.time.LocalDateTime -import java.time.OffsetDateTime data class ComplexPlanRunResponseDTO( val runId: Long, @@ -11,9 +10,9 @@ data class ComplexPlanRunResponseDTO( val intervalEnd: LocalDateTime, val satelliteIds: List, val requestedBy: String?, - val createdAt: OffsetDateTime, - val startedAt: OffsetDateTime?, - val finishedAt: OffsetDateTime?, + val createdAt: LocalDateTime, + val startedAt: LocalDateTime?, + val finishedAt: LocalDateTime?, val durationMs: Long?, val errorMessage: String?, val calculatedModesCount: Int, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt index d401132..aa5d252 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/dto/ComplexPlanRunSummaryDTO.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.pcp_satellites_service.dto import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunStatus import java.time.LocalDateTime -import java.time.OffsetDateTime data class ComplexPlanRunSummaryDTO( val runId: Long, @@ -11,9 +10,9 @@ data class ComplexPlanRunSummaryDTO( val intervalEnd: LocalDateTime, val satelliteIds: List, val requestedBy: String?, - val createdAt: OffsetDateTime, - val startedAt: OffsetDateTime?, - val finishedAt: OffsetDateTime?, + val createdAt: LocalDateTime, + val startedAt: LocalDateTime?, + val finishedAt: LocalDateTime?, val durationMs: Long?, val errorMessage: String?, val calculatedModesCount: Int, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt index 1c519d5..a37b4d4 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/entity/SatelliteModeEntity.kt @@ -12,8 +12,11 @@ import jakarta.persistence.Id import jakarta.persistence.OneToMany import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate import jakarta.persistence.Table import jakarta.persistence.Index +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates import java.time.LocalDateTime @Entity @@ -92,4 +95,14 @@ class SatelliteModeEntity( fetch = FetchType.LAZY ) var cells: MutableList = mutableListOf() -) +) { + /** Объём маршрута, Мбайт. Денормализация: считается из duration при построении (satellite_modes — только SURVEY). */ + @Column(name = "volume_mb", nullable = false) + var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration) + + @PrePersist + @PreUpdate + fun computeVolumeMb() { + volumeMb = ModeDataRates.surveyVolumeMb(duration) + } +} diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt index 7d6bfd1..37d2c55 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunMapper.kt @@ -4,9 +4,6 @@ import org.springframework.stereotype.Component import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunResponseDTO import space.nstart.pcp.pcp_satellites_service.dto.ComplexPlanRunSummaryDTO import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity -import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset @Component class ComplexPlanRunMapper { @@ -19,9 +16,9 @@ class ComplexPlanRunMapper { intervalEnd = entity.intervalEnd, satelliteIds = satelliteIds(entity), requestedBy = entity.requestedBy, - createdAt = entity.createdAt.toUtcOffset(), - startedAt = entity.startedAt?.toUtcOffset(), - finishedAt = entity.finishedAt?.toUtcOffset(), + createdAt = entity.createdAt, + startedAt = entity.startedAt, + finishedAt = entity.finishedAt, durationMs = entity.durationMs, errorMessage = entity.errorMessage, calculatedModesCount = entity.calculatedModesCount, @@ -46,9 +43,9 @@ class ComplexPlanRunMapper { intervalEnd = entity.intervalEnd, satelliteIds = satelliteIds(entity), requestedBy = entity.requestedBy, - createdAt = entity.createdAt.toUtcOffset(), - startedAt = entity.startedAt?.toUtcOffset(), - finishedAt = entity.finishedAt?.toUtcOffset(), + createdAt = entity.createdAt, + startedAt = entity.startedAt, + finishedAt = entity.finishedAt, durationMs = entity.durationMs, errorMessage = entity.errorMessage, calculatedModesCount = entity.calculatedModesCount, @@ -75,8 +72,4 @@ class ComplexPlanRunMapper { .mapNotNull { it.id } .distinct() .sorted() - - private fun LocalDateTime.toUtcOffset(): OffsetDateTime = - atOffset(ZoneOffset.UTC) - } diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt index 634b419..dbc5f0f 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt @@ -10,6 +10,7 @@ import space.nstart.pcp.pcp_satellites_service.entity.ComplexPlanRunEntity import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource import tools.jackson.databind.json.JsonMapper +import java.time.Duration import kotlin.system.measureTimeMillis @Service @@ -25,6 +26,8 @@ class ComplexPlanRunOrchestrationService( private val objectMapper = JsonMapper.builder().findAndAddModules().build() fun process(request: ComplexPlanProcessRequestDTO, requestedBy: String?): ComplexPlanRunResponseDTO { + validateInterval(request) + lateinit var selectedSatelliteIds: List val resolveSatelliteIdsMs = measureTimeMillis { selectedSatelliteIds = resolveSatelliteIds(request) @@ -161,6 +164,25 @@ class ComplexPlanRunOrchestrationService( request.satelliteId?.let { add(it) } addAll(request.satelliteIds) }.distinct().ifEmpty { satelliteService.satellites.map { it.satelliteId }.distinct() } + + /** + * Интервал расчёта обязан быть корректным и не длиннее [MAX_INTERVAL_DAYS] суток: считать + * комплексный план по всем КА на больший горизонт слишком тяжело и бессмысленно для оперативного + * плана. Проверка до создания run, чтобы не плодить заведомо отклонённые записи. + */ + private fun validateInterval(request: ComplexPlanProcessRequestDTO) { + if (!request.intervalEnd.isAfter(request.intervalStart)) { + throw CustomValidationException("Interval end must be after interval start") + } + if (Duration.between(request.intervalStart, request.intervalEnd) > Duration.ofDays(MAX_INTERVAL_DAYS)) { + throw CustomValidationException("Complex plan interval cannot exceed $MAX_INTERVAL_DAYS days") + } + } + + companion object { + /** Максимальная длина интервала расчёта комплексного плана, суток. */ + const val MAX_INTERVAL_DAYS = 10L + } } data class ComplexPlanCalculationResult( diff --git a/services/pcp-complex-mission-service/src/main/resources/application.yaml b/services/pcp-complex-mission-service/src/main/resources/application.yaml index 7043a07..20b63ff 100644 --- a/services/pcp-complex-mission-service/src/main/resources/application.yaml +++ b/services/pcp-complex-mission-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql b/services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql new file mode 100644 index 0000000..54516ec --- /dev/null +++ b/services/pcp-complex-mission-service/src/main/resources/db/migration/V9__satellite_mode_volume.sql @@ -0,0 +1,6 @@ +-- Объём маршрута съёмки как атрибут, Мбайт. Считается при построении из длительности +-- (см. ModeDataRates в pcp-types-lib): накопление 120 Мбайт/с. satellite_modes — только SURVEY. +ALTER TABLE satellite_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- Бэкфилл существующих маршрутов из длительности. +UPDATE satellite_modes SET volume_mb = duration * 120; diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt index 669a3db..af48f91 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanControllerTest.kt @@ -17,7 +17,6 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexMissionService import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunOrchestrationService import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -50,9 +49,9 @@ class ComplexPlanControllerTest { intervalEnd = LocalDateTime.of(2026, 3, 26, 12, 0), satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = LocalDateTime.of(2026, 3, 26, 9, 59).atOffset(ZoneOffset.UTC), - startedAt = LocalDateTime.of(2026, 3, 26, 10, 0).atOffset(ZoneOffset.UTC), - finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1).atOffset(ZoneOffset.UTC), + createdAt = LocalDateTime.of(2026, 3, 26, 9, 59), + startedAt = LocalDateTime.of(2026, 3, 26, 10, 0), + finishedAt = LocalDateTime.of(2026, 3, 26, 10, 1), durationMs = 60000, errorMessage = null, calculatedModesCount = 1, diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt index 83a23c6..9fdbebc 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/controller/ComplexPlanRunApiTest.kt @@ -22,9 +22,9 @@ import space.nstart.pcp.pcp_satellites_service.service.ComplexPlanRunPersistence import space.nstart.pcp.pcp_satellites_service.service.SatelliteModePersistenceService import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class ComplexPlanRunApiTest { @@ -81,7 +81,7 @@ class ComplexPlanRunApiTest { assertEquals(10.0, response.totalRequestsArea) assertEquals(4.0, response.coveredArea) assertEquals(40.0, response.coveredAreaPercent) - assertEquals(ZoneOffset.UTC, response.createdAt.offset) + assertNotNull(response.createdAt) } @Test diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt index 7ad27df..0956d92 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationServiceTest.kt @@ -16,7 +16,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.CoverageCalculationSource import space.nstart.pcp.pcp_types_lib.dto.requests.ComplexPlanProcessRequestDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import java.time.LocalDateTime -import java.time.ZoneOffset import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -191,9 +190,9 @@ class ComplexPlanRunOrchestrationServiceTest { intervalEnd = request.intervalEnd, satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), - startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), - finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + createdAt = request.intervalStart.minusMinutes(1), + startedAt = request.intervalStart, + finishedAt = request.intervalStart.plusMinutes(1), durationMs = 60_000, errorMessage = null, calculatedModesCount = 1, @@ -301,9 +300,9 @@ class ComplexPlanRunOrchestrationServiceTest { intervalEnd = request.intervalEnd, satelliteIds = listOf(22L), requestedBy = "tester", - createdAt = request.intervalStart.minusMinutes(1).atOffset(ZoneOffset.UTC), - startedAt = request.intervalStart.atOffset(ZoneOffset.UTC), - finishedAt = request.intervalStart.plusMinutes(1).atOffset(ZoneOffset.UTC), + createdAt = request.intervalStart.minusMinutes(1), + startedAt = request.intervalStart, + finishedAt = request.intervalStart.plusMinutes(1), durationMs = 60_000, errorMessage = null, calculatedModesCount = 0, diff --git a/services/pcp-coverage-scheme-service/src/main/resources/application.yaml b/services/pcp-coverage-scheme-service/src/main/resources/application.yaml index 67f8611..e07ab9f 100644 --- a/services/pcp-coverage-scheme-service/src/main/resources/application.yaml +++ b/services/pcp-coverage-scheme-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt index eb0667d..6c33e87 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/JacksonConfig.kt @@ -6,30 +6,15 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.registerKotlinModule import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration - - @Configuration class JacksonConfig { @Bean fun objectMapper(): ObjectMapper { val mapper = ObjectMapper() - - // Настраиваем формат для LocalDateTime -// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.zzz") - -// module.addSerializer( -// LocalDateTime::class.java, -// LocalDateTimeSerializer(formatter) -// ) -// module.addDeserializer( -// LocalDateTime::class.java, -// LocalDateTimeDeserializer(formatter) -// ) mapper.registerModule(JavaTimeModule()) .registerKotlinModule() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - return mapper } } diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt index 2bdcfff..be20de9 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/apl/BasicPlanCalculator.kt @@ -841,7 +841,7 @@ class BasicPlanCalculator( val resultFilePath = if (request.saveResultToFile && status == ComplexPlanCalculationStatus.COMPLETED) { observationParametersDebugFileWriter.write( ObservationParametersDebugFileDTO( - createdAt = Instant.now(), + createdAt = LocalDateTime.now(), requestId = request.requestId, satelliteIds = selectedSatelliteIds, missingSatelliteIds = missingSatelliteIds, diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt index 020de0c..98cccf2 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/service/RequestServiceClient.kt @@ -7,7 +7,7 @@ import org.springframework.web.client.HttpClientErrorException import org.springframework.web.client.RestClient import org.springframework.web.util.UriComponentsBuilder import space.nstart.pcp.complan.types.RequestItemDTO -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID /** Получение заявок из pcp-request-service /v1 API. */ @@ -43,8 +43,9 @@ class RequestServiceClient { id = id, name = name, geometry = geometry, - intervalBegin = beginDateTime.toLocalDateTime(), - intervalEnd = endDateTime.toLocalDateTime(), + // pcp-request-service шлёт naive LocalDateTime без зоны — берём как есть (настенные часы UTC). + intervalBegin = beginDateTime, + intervalEnd = endDateTime, resolution = resolution(), importance = importance, ) @@ -63,16 +64,16 @@ data class RequestResponseDto( val surveyType: String = "", val geometry: String, val importance: Double, - val beginDateTime: OffsetDateTime, - val endDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, + val endDateTime: LocalDateTime, val kpp: List = emptyList(), val highPriorityTransmit: Boolean = false, val optics: SurveyParamsDto? = null, val rsa: SurveyParamsDto? = null, val coverage: CoverageStateDto = CoverageStateDto(), - val createdAt: OffsetDateTime? = null, - val updatedAt: OffsetDateTime? = null, - val deletedAt: OffsetDateTime? = null, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, + val deletedAt: LocalDateTime? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt index 6db84b0..76620ae 100644 --- a/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt +++ b/services/pcp-dynamic-plan-service/src/main/kotlin/space/nstart/pcp/complan/types/ObservationParametersDebugDTO.kt @@ -3,7 +3,6 @@ package space.nstart.pcp.complan.types import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty import io.swagger.v3.oas.annotations.media.Schema -import java.time.Instant import java.time.LocalDateTime import java.util.UUID @@ -116,7 +115,7 @@ data class ObservationParametersDebugFileDTO( @get:JsonProperty("createdAt") @param:JsonProperty("createdAt") - val createdAt: Instant, + val createdAt: LocalDateTime, @get:JsonProperty("requestId") @param:JsonProperty("requestId") diff --git a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml index a4613fe..3130095 100644 --- a/services/pcp-dynamic-plan-service/src/main/resources/application.yaml +++ b/services/pcp-dynamic-plan-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} @@ -15,7 +15,7 @@ spring: # max-in-memory-size: 16MB datasource: driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://192.168.60.201:5432/complan + url: jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/complan username: postgres password: password jpa: diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt index 2f59ed7..b2217ee 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/apl/ObservationParametersDebugFileWriterTest.kt @@ -9,7 +9,6 @@ import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.SquareViewParamDTO import java.nio.file.Files import java.nio.file.Path -import java.time.Instant import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test @@ -29,7 +28,7 @@ class ObservationParametersDebugFileWriterTest { outputDir = tempDir.resolve("observation-parameters").toString() ) val artifact = ObservationParametersDebugFileDTO( - createdAt = Instant.parse("2026-05-05T10:15:30Z"), + createdAt = LocalDateTime.parse("2026-05-05T10:15:30"), requestId = requestId, satelliteIds = listOf(1001L), missingSatelliteIds = emptyList(), diff --git a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt index 03c19b7..193a85c 100644 --- a/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt +++ b/services/pcp-dynamic-plan-service/src/test/kotlin/space/nstart/pcp/complan/service/RequestServiceClientTest.kt @@ -65,6 +65,8 @@ class RequestServiceClientTest { private fun serverWithRequestResponse( requestedUris: MutableList, requestId: UUID, + beginDateTime: String = "2026-01-02T03:04:05", + endDateTime: String = "2026-01-03T04:05:06", ): HttpServer = HttpServer.create(InetSocketAddress(0), 0).apply { createContext("/v1/requests") { exchange -> @@ -77,8 +79,8 @@ class RequestServiceClientTest { "surveyType": "OPTICS", "geometry": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "importance": 7.5, - "beginDateTime": "2026-01-02T03:04:05Z", - "endDateTime": "2026-01-03T04:05:06Z", + "beginDateTime": "$beginDateTime", + "endDateTime": "$endDateTime", "kpp": [1], "highPriorityTransmit": false, "optics": { @@ -90,8 +92,8 @@ class RequestServiceClientTest { }, "rsa": null, "coverage": {"currentPercent": 0.0}, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z", + "createdAt": "2026-01-01T00:00:00", + "updatedAt": "2026-01-01T00:00:00", "deletedAt": null } """.trimIndent() diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt index 22aaa4e..c439a39 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/PcpMissionPlaningServiceApplication.kt @@ -2,10 +2,16 @@ package space.nstart.pcp.pcp_mission_planing_service import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication +import org.springframework.context.annotation.Import import org.springframework.kafka.annotation.EnableKafka +import space.nstart.pcp.pcp_types_lib.configuration.GlobalExceptionHandler @EnableKafka @SpringBootApplication +// GlobalExceptionHandler лежит в пакете pcp-types-lib вне базового component-scan этого сервиса, +// поэтому регистрируем @ControllerAdvice явно — иначе CustomErrorException/CustomValidationException +// отдаются дефолтным WebFlux-хендлером как голый 500 без тела с сообщением. +@Import(GlobalExceptionHandler::class) class PcpMissionPlaningServiceApplication fun main(args: Array) { diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt new file mode 100644 index 0000000..ae22aaa --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionCloneDTOs.kt @@ -0,0 +1,31 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import java.time.LocalDateTime +import java.util.UUID + +/** Допустимые значения [MissionCloneRequest.mode]. */ +object CloneMode { + const val REPLACE = "replace" + const val FORK_FORWARD = "fork-forward" +} + +data class MissionCloneRequest( + /** [CloneMode.REPLACE] — полный клон; [CloneMode.FORK_FORWARD] — только включения, перекрывающиеся с окном. */ + val mode: String, + /** Начало окна нового плана (naive, локальное время). Обязательно для fork-forward. */ + val windowStart: LocalDateTime? = null, + /** Конец окна нового плана (naive, локальное время). Обязательно для fork-forward. */ + val windowStop: LocalDateTime? = null +) + +data class MissionCloneResponse(val newMissionId: UUID) + +/** Запрос создания пустой миссии (план новой цепочки «без истории»). */ +data class MissionBlankRequest( + /** КА (= noradId). */ + val satelliteId: Long, + /** Начало окна миссии (naive, локальное время). */ + val missionStart: LocalDateTime, + /** Конец окна миссии (naive, локальное время). */ + val missionStop: LocalDateTime +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt index 419f7cb..ab9d685 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt @@ -12,6 +12,7 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PutMapping @@ -20,8 +21,10 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO import space.nstart.pcp.pcp_mission_planing_service.service.MissionPlaningService import java.time.LocalDateTime import java.util.UUID @@ -34,6 +37,54 @@ class MissionController( private val missionPlaningService: MissionPlaningService ) { + @Operation( + summary = "Построить один маршрут съёмки", + description = "Строит контур полосы съёмки по параметрам видимости точки (момент, крен, угол захвата). " + + "Орбитальные точки берутся из pcp-ballistics-service, контур — тем же алгоритмом, что и при расчёте плана съёмки. " + + "Маршрут не сохраняется в БД, только возвращается клиенту (live-превью)." + ) + @PostMapping("/survey-route/build") + fun buildSurveyRoute(@Valid @RequestBody body: SurveyRouteBuildRequestDTO) = + missionPlaningService.buildSurveyRoute(body) + + @Operation( + summary = "Построить и сохранить маршрут съёмки по выбранному плану", + description = "Строит контур полосы съёмки тем же алгоритмом, что и /survey-route/build, " + + "и сохраняет его в БД как режим съёмки (source = MANUAL) под основным планом миссии. " + + "planId — идентификатор выбранного плана в pcp-tgu-service, совпадает с mission_id этого сервиса. " + + "Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Маршрут построен и сохранён") + ] + ) + @PostMapping("/{plan_id}/survey-route") + fun saveSurveyRoute( + @Parameter(description = "Идентификатор выбранного плана (= mission_id)") + @PathVariable("plan_id") planId: UUID, + @Valid @RequestBody body: SurveyRouteBuildRequestDTO + ) = missionPlaningService.saveSurveyRoute(planId, body) + + @Operation( + summary = "Добавить сброс вручную по выбранной зоне радиовидимости", + description = "Сохраняет режим сброса (DROP) под основным планом миссии по выбранной оператором ЗРВ " + + "и набору маршрутов съёмки, заканчивающихся строго до начала зоны. " + + "planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). " + + "Адресуется существующая миссия-черновик (DRAFT-клон); если миссии с таким id нет — 404." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Сброс добавлен") + ] + ) + @PostMapping("/{plan_id}/drop-route") + fun saveDropRoute( + @Parameter(description = "Идентификатор выбранного плана (= mission_id)") + @PathVariable("plan_id") planId: UUID, + @Valid @RequestBody body: DropRouteSaveRequestDTO + ) = missionPlaningService.saveDropRoute(planId, body) + @Operation(summary = "Получить список миссий") @GetMapping fun all() = missionPlaningService.all() @@ -161,6 +212,90 @@ class MissionController( return ResponseEntity.noContent().build() } + @Operation( + summary = "Перестроить маршрут съёмки", + description = "Перестраивает контур существующего режима съёмки по новым параметрам (окно/крен/" + + "угол захвата) тем же алгоритмом, что и /survey-route/build, и сохраняет результат на месте. " + + "Используется для правки включения в плане-черновике." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Маршрут перестроен"), + ApiResponse(responseCode = "404", description = "Режим съёмки не найден") + ] + ) + @PatchMapping("/survey-modes/{mode_id}") + fun updateSurveyRoute( + @Parameter(description = "Идентификатор режима съёмки") + @PathVariable("mode_id") modeId: Long, + @Valid @RequestBody body: SurveyRouteBuildRequestDTO + ) = missionPlaningService.updateSurveyRoute(modeId, body) + + @Operation( + summary = "Удалить включение (съёмку или сброс)", + description = "Удаляет режим (SURVEY или DROP) из плана-черновика по его id. Для съёмки снимаются " + + "брони слотов и связи со сбросами, осиротевший сброс (без единой съёмки) тоже удаляется. " + + "Для сброса удаляются только его связи; съёмки сохраняются." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "Включение удалено"), + ApiResponse(responseCode = "404", description = "Режим не найден") + ] + ) + @DeleteMapping("/modes/{mode_id}") + fun deleteMode( + @Parameter(description = "Идентификатор режима (survey или drop)") + @PathVariable("mode_id") modeId: Long + ): ResponseEntity { + missionPlaningService.deleteMode(modeId) + return ResponseEntity.noContent().build() + } + + @Operation( + summary = "Очистить включения плана", + description = "Удаляет включения плана миссии вместе с бронями слотов и связями съёмка↔сброс. " + + "Сбросы удаляются всегда. Для съёмок поведение выбирает оператор флагом keepManual: " + + "keepManual=true — ручные маршруты съёмки (source=MANUAL) сохраняются, удаляются только " + + "рассчитанные (как при пересчёте плана); keepManual=false (по умолчанию) — удаляются ВСЕ " + + "съёмки, включая ручные. Используется кнопкой «Очистить план» в редакторе." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "План очищен"), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @DeleteMapping("/{mission_id}/modes") + fun clearPlan( + @Parameter(description = "Идентификатор миссии") + @PathVariable("mission_id") id: UUID, + @Parameter(description = "Сохранить ручные маршруты съёмки (source=MANUAL)") + @RequestParam(name = "keepManual", required = false, defaultValue = "false") keepManual: Boolean + ): ResponseEntity { + missionPlaningService.clearPlan(id, keepManual) + return ResponseEntity.noContent().build() + } + + @Operation( + summary = "Проверить миссию-черновик на конфликты", + description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " + + "режимам. MVP: валидатор-заглушка — контракт корректен, список конфликтов пуст; " + + "реальные правила (пересечения по времени, видимость цели, ЗРВ, порядок сброса, борт) — " + + "отдельной задачей." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "Валидация выполнена, конфликты в теле ответа"), + ApiResponse(responseCode = "404", description = "Миссия не найдена") + ] + ) + @PostMapping("/{mission_id}/validate") + fun validateMission( + @Parameter(description = "Идентификатор миссии-черновика") + @PathVariable("mission_id") id: UUID + ): MissionValidationResponse = missionPlaningService.validateMission(id) + @Operation(summary = "Создать миссию") @@ -178,4 +313,25 @@ class MissionController( missionPlaningService.delete(id) return ResponseEntity.noContent().build() } + + @Operation( + summary = "Клонировать миссию", + description = "Создаёт полный (replace) или частичный (fork-forward) клон миссии с новым missionId. " + + "Для fork-forward клонируются только включения, перекрывающиеся с окном [windowStart, windowStop). " + + "Возвращает newMissionId, который tgu-service использует как planId черновика." + ) + @PostMapping("/{mission_id}/clone") + fun cloneMission( + @PathVariable("mission_id") id: UUID, + @RequestBody body: MissionCloneRequest + ): MissionCloneResponse = missionPlaningService.cloneMission(id, body) + + @Operation( + summary = "Создать пустую миссию", + description = "Создаёт миссию без режимов на заданное окно — для плана новой цепочки «без истории». " + + "Возвращает newMissionId, который tgu-service использует как planId черновика." + ) + @PostMapping("/blank") + fun createBlankMission(@RequestBody body: MissionBlankRequest): MissionCloneResponse = + missionPlaningService.createBlankMission(body) } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt new file mode 100644 index 0000000..9ed7b6a --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt @@ -0,0 +1,55 @@ +package space.nstart.pcp.pcp_mission_planing_service.controller + +import java.util.UUID + +/** + * Коды правил валидации. MVP: валидатор-заглушка их **не вычисляет** — список существует как + * стабильный контракт для фронта, чтобы появление реальных правил (раздел 6 дизайн-дока) не ломало схему. + */ +object ConflictRule { + /** Пересечение по времени между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс). */ + const val TIME_OVERLAP = "TIME_OVERLAP" + /** Окно съёмки вне окна видимости цели (баллистика). */ + const val TARGET_VISIBILITY = "TARGET_VISIBILITY" + /** Окно сброса вне зоны радиовидимости станции. */ + const val COMMS_SESSION = "COMMS_SESSION" + /** Сбрасываемая съёмка не завершается строго до начала зоны сброса. */ + const val DROP_ORDER = "DROP_ORDER" + /** Бортовые ограничения: длительность/скважность, энергетика, память. */ + const val ONBOARD_LIMIT = "ONBOARD_LIMIT" +} + +/** Уровень конфликта: [ERROR] блокирует «Применить», [WARNING] — информативный. */ +object ConflictSeverity { + const val ERROR = "error" + const val WARNING = "warning" +} + +/** + * Один конфликт на затронутом включении. Возвращается диффом по режимам (а не как весь план), + * чтобы фронт мог точечно подсвечивать проблемные включения. + * + * @param modeId id режима (survey или drop), к которому относится конфликт. + * @param rule код правила, см. [ConflictRule]. + * @param severity уровень, см. [ConflictSeverity]. + * @param message человекочитаемое описание для оператора. + * @param relatedModeId id второго участника парного правила (например, пересечения по времени), если есть. + */ +data class ModeConflict( + val modeId: Long, + val rule: String, + val severity: String, + val message: String, + val relatedModeId: Long? = null +) + +/** + * Результат валидации миссии-черновика. + * + * MVP: [conflicts] всегда пуст — реальные правила ещё не считаются (TODO раздела 6 дизайн-дока). + * Контракт стабилен: фронт уже завязывается на него и не сломается, когда правила появятся. + */ +data class MissionValidationResponse( + val missionId: UUID, + val conflicts: List +) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt index 8a614ea..0cc04b3 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/DropModeEntity.kt @@ -3,8 +3,11 @@ package space.nstart.pcp.pcp_mission_planing_service.entity import jakarta.persistence.Column import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate import jakarta.persistence.Table -import java.time.OffsetDateTime +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates +import java.time.LocalDateTime @Entity @Table(name = "drop_modes") @@ -12,7 +15,7 @@ import java.time.OffsetDateTime class DropModeEntity( id: Long? = null, plan: PlanEntity? = null, - timeStart: OffsetDateTime? = null, + timeStart: LocalDateTime? = null, revolution: Long = 0, @Column(name = "station") @@ -20,4 +23,14 @@ class DropModeEntity( @Column(name = "duration", nullable = false) var duration: Double = 0.0 -) : ModeEntity(id, plan, timeStart, revolution) +) : ModeEntity(id, plan, timeStart, revolution) { + /** Объём сброса, Мбайт. Денормализация: считается из duration (канал 1200 Мбит/с ÷ 8) при сохранении. */ + @Column(name = "volume_mb", nullable = false) + var volumeMb: Double = ModeDataRates.dropVolumeMb(duration) + + @PrePersist + @PreUpdate + fun computeVolumeMb() { + volumeMb = ModeDataRates.dropVolumeMb(duration) + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt index 75daeec..ab7e87d 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/ModeEntity.kt @@ -13,7 +13,7 @@ import jakarta.persistence.InheritanceType import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.Table -import java.time.OffsetDateTime +import java.time.LocalDateTime @Entity @Table(name = "modes") @@ -29,7 +29,7 @@ abstract class ModeEntity( open var plan: PlanEntity? = null, @Column(name = "time_start") - open var timeStart: OffsetDateTime? = null, + open var timeStart: LocalDateTime? = null, @Column(name = "revolution", nullable = false) open var revolution: Long = 0 diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt index ee053fc..ce6d8f6 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/entity/SurveyModeEntity.kt @@ -5,9 +5,12 @@ import jakarta.persistence.DiscriminatorValue import jakarta.persistence.Entity import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.PrePersist +import jakarta.persistence.PreUpdate import jakarta.persistence.Table import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus -import java.time.OffsetDateTime +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates +import java.time.LocalDateTime @Entity @Table(name = "survey_modes") @@ -15,7 +18,7 @@ import java.time.OffsetDateTime class SurveyModeEntity( id: Long? = null, plan: PlanEntity? = null, - timeStart: OffsetDateTime? = null, + timeStart: LocalDateTime? = null, revolution: Long = 0, @Column(name = "lat", nullable = false) @@ -46,4 +49,14 @@ class SurveyModeEntity( @Enumerated(EnumType.STRING) @Column(name = "source", nullable = false, length = 20) var source: SurveyModeSource = SurveyModeSource.SLOT -) : ModeEntity(id, plan, timeStart, revolution) +) : ModeEntity(id, plan, timeStart, revolution) { + /** Объём маршрута съёмки, Мбайт. Денормализация: считается из duration при построении/сохранении. */ + @Column(name = "volume_mb", nullable = false) + var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration) + + @PrePersist + @PreUpdate + fun computeVolumeMb() { + volumeMb = ModeDataRates.surveyVolumeMb(duration) + } +} diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt index 45b3850..a14622f 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/DropModeSurveyRepository.kt @@ -7,4 +7,7 @@ import java.util.UUID interface DropModeSurveyRepository : JpaRepository { fun findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropModeId: Long): List fun findAllByDropMode_Plan_Mission_MissionId(missionId: UUID): List + + /** Связи сброс↔съёмка по конкретной съёмке (для удаления съёмки и поиска осиротевших сбросов). */ + fun findAllBySurveyMode_Id(surveyModeId: Long): List } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt index a5bd595..f199ba3 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/ModeRepository.kt @@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID interface ModeRepository : JpaRepository { @@ -14,7 +14,7 @@ interface ModeRepository : JpaRepository { fun findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId: UUID): List fun findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( satelliteId: Long, - timeStop: OffsetDateTime + timeStop: LocalDateTime ): List fun countByPlan_Mission_SatelliteId(satelliteId: Long): Long @@ -24,6 +24,20 @@ interface ModeRepository : JpaRepository { @Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY'", nativeQuery = true) fun deleteSurveyModesByPlanId(@Param("planId") planId: Long): Int + /** + * Удаляет только пересчитываемые маршруты съёмки плана, сохраняя ручные + * (source = 'MANUAL'). Используется при пересчёте плана (этап 5, loadSlots), + * чтобы не затирать добавленные оператором съёмки. + */ + @Modifying + @Transactional + @Query( + "DELETE FROM modes WHERE plan_id = :planId AND type = 'SURVEY' " + + "AND id NOT IN (SELECT id FROM survey_modes WHERE source = 'MANUAL')", + nativeQuery = true + ) + fun deleteRecalculableSurveyModesByPlanId(@Param("planId") planId: Long): Int + @Modifying @Transactional @Query("DELETE FROM modes WHERE plan_id = :planId AND type = 'DROP'", nativeQuery = true) diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt index b264ef1..064a71b 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/repository/SurveyModeRepository.kt @@ -4,7 +4,7 @@ import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.query.Param import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID interface SurveyModeRepository : JpaRepository { @@ -26,7 +26,7 @@ interface SurveyModeRepository : JpaRepository { fun findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( @Param("satelliteNumber") satelliteNumber: String, @Param("revolution") revolution: Long, - @Param("timeStartFrom") timeStartFrom: OffsetDateTime, - @Param("timeStartTo") timeStartTo: OffsetDateTime + @Param("timeStartFrom") timeStartFrom: LocalDateTime, + @Param("timeStartTo") timeStartTo: LocalDateTime ): List } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt index c27c6a2..12df33a 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt @@ -20,11 +20,20 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionRequestDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.MissionSurveyCalculationResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeResponseDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyModeResponseDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteAnchor +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResponseDTO +import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionValidationResponse import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity @@ -53,7 +62,6 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO import java.time.Duration import java.time.LocalDateTime -import java.time.ZoneOffset import java.util.UUID import kotlin.math.PI import kotlin.math.abs @@ -120,6 +128,157 @@ class MissionPlaningService( missionRepository.deleteById(id) } + /** + * Клонирует миссию и все её включения в новый `missionId`. + * + * Режим `replace` — полный глубокий клон: Mission + Plan + все Mode (SurveyMode/DropMode). + * Режим `fork-forward` — клонирует только включения, чей интервал `[timeStart, timeStart+duration)` + * пересекается с окном `[windowStart, windowStop)`. DropMode клонируется только если хотя бы одна + * из его связанных съёмок тоже попала в клон (иначе сброс становится пустым). + * + * Намеренные особенности fork-forward: + * - Времена включений переносятся как есть. Включение, начавшееся до `windowStart` и закончившееся + * внутри окна, клонируется со своим исходным `timeStart` — он может быть раньше `missionStart` + * клона. Границы миссии описывают слот целевой ЗРВ, а не оболочку времён её включений. + * - Съёмка переносится, даже если её сброс не попал в окно: сброс будет перевыдан в новом плане. + * Обратное неверно — сброс без единой перенесённой съёмки отбрасывается. + * + * Новый `missionId` передаётся в ответе; он используется как `planId` черновика в pcp-tgu-service + * (инвариант planId == missionId сохраняется). + */ + @Transactional + fun cloneMission(sourceMissionId: UUID, req: MissionCloneRequest): MissionCloneResponse { + if (req.mode != CloneMode.REPLACE && req.mode != CloneMode.FORK_FORWARD) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown clone mode: ${req.mode}") + } + val isForkForward = req.mode == CloneMode.FORK_FORWARD + if (isForkForward && (req.windowStart == null || req.windowStop == null)) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "windowStart and windowStop are required for fork-forward clone" + ) + } + + val source = missionRepository.findById(sourceMissionId).orElseThrow { notFound(sourceMissionId) } + val allModes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(sourceMissionId) + + val modesToClone: List = if (isForkForward) { + allModes.filter { mode -> mode.intersects(req.windowStart!!, req.windowStop!!) } + } else { + allModes + } + + val (newMissionStart, newMissionStop) = if (isForkForward) { + req.windowStart!! to req.windowStop!! + } else { + source.missionStart to source.missionStop + } + + val newMission = missionRepository.save( + MissionEntity( + satelliteId = source.satelliteId, + station = source.station, + missionStart = newMissionStart, + missionStop = newMissionStop, + status = "NEW" + ) + ) + + val newPlan = planRepository.save( + PlanEntity( + mission = newMission, + planStart = newMissionStart, + planStop = newMissionStop, + planNumber = 1 + ) + ) + + // Клонируем съёмки и строим маппинг старый id → новая сущность для перевязки сбросов. + val surveyIdMapping = mutableMapOf() + modesToClone.filterIsInstance().forEach { src -> + val cloned = surveyModeRepository.save( + SurveyModeEntity( + plan = newPlan, + timeStart = src.timeStart, + revolution = src.revolution, + lat = src.lat, + long = src.long, + duration = src.duration, + contourWkt = src.contourWkt, + roll = src.roll, + status = SurveyModeStatus.PLANNED, + source = src.source + ) + ) + src.id?.let { surveyIdMapping[it] = cloned } + } + + // Клонируем сбросы; пропускаем если все их связанные съёмки вышли за окно (пустой сброс). + modesToClone.filterIsInstance().forEach { src -> + val srcId = src.id ?: return@forEach + val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(srcId) + val remappedSurveys = links.mapNotNull { link -> link.surveyMode?.id?.let { surveyIdMapping[it] } } + if (remappedSurveys.isEmpty()) { + return@forEach + } + val newDrop = dropModeRepository.save( + DropModeEntity( + plan = newPlan, + timeStart = src.timeStart, + revolution = src.revolution, + station = src.station, + duration = src.duration + ) + ) + remappedSurveys.forEach { survey -> + dropModeSurveyRepository.save(DropModeSurveyEntity(dropMode = newDrop, surveyMode = survey)) + } + } + + val clonedDropCount = modesToClone.count { it is DropModeEntity } + logger.info( + "Клонирована миссия {}: mode={}, новый id={}, съёмок={}, сбросов={}", + sourceMissionId, req.mode, newMission.missionId, surveyIdMapping.size, clonedDropCount + ) + + return MissionCloneResponse(newMissionId = newMission.missionId) + } + + /** + * Создаёт пустую миссию (без режимов) на заданное окно — для плана новой цепочки «без истории». + * Возвращает новый missionId, который tgu использует как planId черновика; включения оператор + * добавляет сам в редакторе. Создаётся миссия (status=NEW) и её основной план (planNumber=1). + */ + fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse { + if (!req.missionStop.isAfter(req.missionStart)) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "missionStop must be after missionStart") + } + + val mission = missionRepository.save( + MissionEntity( + satelliteId = req.satelliteId, + station = null, + missionStart = req.missionStart, + missionStop = req.missionStop, + status = "NEW" + ) + ) + planRepository.save( + PlanEntity( + mission = mission, + planStart = req.missionStart, + planStop = req.missionStop, + planNumber = 1 + ) + ) + + logger.info( + "Создана пустая миссия {}: satelliteId={}, окно [{}–{}]", + mission.missionId, req.satelliteId, req.missionStart, req.missionStop + ) + return MissionCloneResponse(newMissionId = mission.missionId) + } + fun modesByMission(id: UUID): List { if (!missionRepository.existsById(id)) { throw notFound(id) @@ -144,7 +303,7 @@ class MissionPlaningService( val modes = modeRepository .findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( satelliteId, - timeStop.atOffset(ZoneOffset.UTC) + timeStop ) .filter { it.intersects(timeStart, timeStop) } @@ -185,14 +344,18 @@ class MissionPlaningService( .mapValues { (_, slotIds) -> slotIds.filter { it > 0 }.distinct() } } + /** + * Пересечение интервала включения с окном `[intervalStart, intervalStop)` (правый конец открыт). + * Применяется при fork-forward-клоне: включения строго вне окна не клонируются. + */ private fun ModeEntity.intersects(intervalStart: LocalDateTime, intervalStop: LocalDateTime): Boolean { - val modeStart = timeStart?.toLocalDateTime() ?: return false + val modeStart = timeStart ?: return false val modeStop = modeStop() ?: return false - return !modeStop.isBefore(intervalStart) && !modeStart.isAfter(intervalStop) + return modeStop.isAfter(intervalStart) && modeStart.isBefore(intervalStop) } private fun ModeEntity.modeStop(): LocalDateTime? { - val modeStart = timeStart?.toLocalDateTime() ?: return null + val modeStart = timeStart ?: return null val durationSeconds = when (this) { is SurveyModeEntity -> duration is DropModeEntity -> duration @@ -349,7 +512,12 @@ class MissionPlaningService( survs.count { it.bookedSlotIds.isNotEmpty() } ) - modeRepository.deleteSurveyModesByPlanId(planId) + val removedRecalculable = modeRepository.deleteRecalculableSurveyModesByPlanId(planId) + logger.info( + "Пересчёт плана {}: удалено {} пересчитываемых маршрутов съёмки, ручные (source=MANUAL) сохранены", + planId, + removedRecalculable + ) if (survs.isEmpty()) { val finishedAt = LocalDateTime.now() @@ -499,6 +667,400 @@ class MissionPlaningService( ) + /** + * Построение одного маршрута съёмки по параметрам видимости точки + * (используется вкладкой «Редактор» в pcp-tgu-ops-ui для построения включений съёмок). + * + * Stateless: орбитальные точки берутся из pcp-ballistics-service (extract-time), контур + * полосы строится тем же алгоритмом [contour], что и при штатном расчёте маршрутов + * (этап 5, [loadSlots] → snapshot этапа 4). Маршрут только возвращается клиенту для + * отображения на картооснове (live-превью при выборе момента видимости), без персистенции. + * Сохранение построенного маршрута выполняет [saveSurveyRoute]. + */ + fun buildSurveyRoute(req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO = + buildContour(req).toResponse(surveyModeId = null) + + /** + * Построение и сохранение одного маршрута съёмки в БД по идентификатору выбранного плана + * в сервисе pcp-tgu-service. + * + * Идентификатор плана (`planId` из PlannedPlanEntity в pcp-tgu-service) совпадает с + * `MissionEntity.missionId` в этом сервисе (см. BPMN createSatelliteMission: переменная + * `satellitePlanId` → `MissionRequestDTO.missionId`). Маршрут сохраняется как + * [SurveyModeEntity] с источником [SurveyModeSource.MANUAL] под основным планом миссии + * (planNumber = 1) — той же таблицей `survey_modes`, что и при штатном расчёте плана. + * + * Адресуется только **существующая** миссия-черновик: правки редактора идут в DRAFT-клон, + * созданный заранее (`POST /api/missions/{id}/clone`). Авто-создание живой миссии на лету + * убрано — нельзя молча писать правки в несуществующий/живой план. Если миссии с таким id + * нет — 404. + * + * Ручные маршруты (source = MANUAL) переживают пересчёт плана: [loadSlots] удаляет только + * пересчитываемые маршруты ([ModeRepository.deleteRecalculableSurveyModesByPlanId]). + */ + @Transactional + fun saveSurveyRoute(planId: UUID, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { + // Проверяем существование миссии-черновика до дорогого расчёта контура (fail-fast на 404). + val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) } + + val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) + .orElseGet { + planRepository.save( + PlanEntity( + mission = mission, + planStart = mission.missionStart, + planStop = mission.missionStop, + planNumber = 1 + ) + ) + } + + val built = buildContour(req) + + val saved = surveyModeRepository.save( + SurveyModeEntity( + plan = plan, + timeStart = built.timeStart, + revolution = built.revolution, + lat = built.lat, + long = built.longitude, + duration = built.durationSeconds, + contourWkt = built.contourWkt, + roll = built.roll, + status = SurveyModeStatus.PLANNED, + source = SurveyModeSource.MANUAL + ) + ) + + logger.info( + "Сохранён ручной маршрут съёмки (survey_mode id={}) для плана/миссии {}: revolution={}, timeStart={}, duration={}", + saved.id, + planId, + built.revolution, + built.timeStart, + built.durationSeconds + ) + + return built.toResponse(surveyModeId = saved.id) + } + + /** + * Ручное добавление сброса в план по выбранной оператором зоне радиовидимости (ЗРВ). + * + * Оператор на вкладке «Редактор» выбирает ЗРВ станции (из pcp-ballistics) и набор уже + * запланированных маршрутов съёмки, время конца которых строго меньше начала зоны. Сброс + * сохраняется как [DropModeEntity] под основным планом миссии (planNumber = 1) со связями + * съёмка↔сброс ([DropModeSurveyEntity]) — той же моделью, что и при штатном расчёте + * ([calculateDrops]). + * + * Идентификатор плана (`planId`) совпадает с `MissionEntity.missionId` (см. saveSurveyRoute). + * Адресуется только **существующая** миссия-черновик (DRAFT-клон, созданный заранее). Авто-создание + * живой миссии на лету убрано — если миссии с таким id нет, возвращается 404. + * + * Внимание: ручные сбросы НЕ защищены от пересчёта — штатный [calculateDrops] удаляет все + * сбросы плана ([ModeRepository.deleteDropModesByPlanId]). + */ + @Transactional + fun saveDropRoute(planId: UUID, req: DropRouteSaveRequestDTO): DropModeResponseDTO { + if (req.surveyModeIds.isEmpty()) { + throw CustomErrorException("Не выбрано ни одного маршрута съёмки для сброса") + } + + val mission = missionRepository.findById(planId).orElseThrow { notFound(planId) } + + val plan = planRepository.findFirstByMissionMissionIdAndPlanNumber(mission.missionId, 1) + .orElseGet { + planRepository.save( + PlanEntity( + mission = mission, + planStart = mission.missionStart, + planStop = mission.missionStop, + planNumber = 1 + ) + ) + } + + // Грузим выбранные съёмки и валидируем: принадлежат этому плану и заканчиваются строго + // до начала зоны (съёмка успевает завершиться к сеансу сброса). + val surveys = surveyModeRepository.findAllById(req.surveyModeIds) + val foundIds = surveys.mapNotNull { it.id }.toSet() + val missing = req.surveyModeIds.filterNot { it in foundIds } + if (missing.isNotEmpty()) { + throw CustomErrorException("Маршруты съёмки не найдены: $missing") + } + surveys.forEach { survey -> + if (survey.plan?.id != plan.id) { + throw CustomErrorException("Маршрут съёмки ${survey.id} не относится к плану $planId") + } + val start = survey.timeStart + ?: throw CustomErrorException("У маршрута съёмки ${survey.id} не задано время начала") + val surveyEnd = start + .plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) + if (!surveyEnd.isBefore(req.zoneStart)) { + throw CustomErrorException( + "Маршрут съёмки ${survey.id} заканчивается ($surveyEnd) не раньше начала зоны (${req.zoneStart}) — сброс невозможен" + ) + } + } + + // Длительность сброса — сумма длительностей сброса по каждой съёмке (как в calculateDrops). + val usedDuration = surveys.sumOf { calculateDropDuration(it).coerceAtLeast(0.0) } + val zoneDuration = Duration.between(req.zoneStart, req.zoneStop).toMillis().toDouble() / 1000.0 + if (zoneDuration > 0.0 && usedDuration > zoneDuration) { + throw CustomErrorException( + "Суммарная длительность сброса ($usedDuration с) превышает длительность зоны ($zoneDuration с)" + ) + } + + val dropMode = dropModeRepository.save( + DropModeEntity( + plan = plan, + timeStart = req.zoneStart, + revolution = req.revolution, + station = req.station, + duration = usedDuration + ) + ) + val links = surveys.map { survey -> DropModeSurveyEntity(dropMode = dropMode, surveyMode = survey) } + dropModeSurveyRepository.saveAll(links) + + logger.info( + "Сохранён ручной сброс (drop_mode id={}) для плана/миссии {}: станция={}, виток={}, зона={}, съёмок={}, длительность={}", + dropMode.id, planId, req.station, req.revolution, req.zoneStart, surveys.size, usedDuration + ) + + return DropModeResponseDTO( + id = dropMode.id ?: 0, + planId = plan.id ?: 0, + timeStart = dropMode.timeStart, + revolution = dropMode.revolution, + station = dropMode.station, + duration = dropMode.duration, + surveys = surveys.mapNotNull { it.id } + ) + } + + /** + * Перестраивает контур существующего маршрута съёмки по новым параметрам (окно/крен/угол захвата) + * тем же алгоритмом [buildContour], что и [buildSurveyRoute]/[saveSurveyRoute], и сохраняет + * результат на месте (правка включения в черновике). + * + * Меняются: время начала, виток, центр (lat/long), длительность, контур (WKT) и крен. Статус + * сохраняется. Источник переводится в [SurveyModeSource.MANUAL] — отредактированный оператором + * маршрут переживает пересчёт плана ([loadSlots] удаляет только пересчитываемые, не MANUAL). + */ + @Transactional + fun updateSurveyRoute(modeId: Long, req: SurveyRouteBuildRequestDTO): SurveyRouteBuildResponseDTO { + val survey = surveyModeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Survey mode with id $modeId not found") + } + + val built = buildContour(req) + survey.timeStart = built.timeStart + survey.revolution = built.revolution + survey.lat = built.lat + survey.long = built.longitude + survey.duration = built.durationSeconds + survey.contourWkt = built.contourWkt + survey.roll = built.roll + survey.source = SurveyModeSource.MANUAL + val saved = surveyModeRepository.save(survey) + + logger.info( + "Перестроен маршрут съёмки (survey_mode id={}): revolution={}, timeStart={}, duration={}, roll={}", + saved.id, built.revolution, built.timeStart, built.durationSeconds, built.roll + ) + + return built.toResponse(surveyModeId = saved.id) + } + + /** + * Удаляет включение (SURVEY или DROP) из плана-черновика по его id. + * + * Съёмка: снимаются её брони слотов ([ModeBookingEntity]) и связи съёмка↔сброс + * ([DropModeSurveyEntity]); сброс, оставшийся без единой съёмки, тоже удаляется (осиротевший + * сеанс не имеет смысла). Другие съёмки того же сброса не трогаются. + * + * Сброс: удаляются только его связи и сам сброс; съёмки независимы и сохраняются. + */ + @Transactional + fun deleteMode(modeId: Long) { + val mode = modeRepository.findById(modeId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Mode with id $modeId not found") + } + when (mode) { + is SurveyModeEntity -> deleteSurveyMode(mode) + is DropModeEntity -> deleteDropMode(mode) + else -> throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported mode type for id $modeId: ${mode::class.qualifiedName}" + ) + } + } + + private fun deleteSurveyMode(survey: SurveyModeEntity) { + val surveyId = survey.id ?: throw IllegalStateException("Survey mode id is null") + + val bookings = modeBookingRepository.findAllByMode_IdIn(listOf(surveyId)) + if (bookings.isNotEmpty()) { + modeBookingRepository.deleteAll(bookings) + } + + val links = dropModeSurveyRepository.findAllBySurveyMode_Id(surveyId) + val affectedDropIds = links.mapNotNull { it.dropMode?.id }.distinct() + if (links.isNotEmpty()) { + dropModeSurveyRepository.deleteAll(links) + } + // Сброс, у которого после удаления этой съёмки не осталось ни одной другой, — осиротел. + affectedDropIds.forEach { dropId -> + val hasOtherSurvey = dropModeSurveyRepository + .findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId) + .any { it.surveyMode?.id != null && it.surveyMode?.id != surveyId } + if (!hasOtherSurvey) { + dropModeRepository.deleteById(dropId) + } + } + + surveyModeRepository.delete(survey) + logger.info("Удалена съёмка (survey_mode id={}), снято броней={}, осиротевших сбросов проверено={}", + surveyId, bookings.size, affectedDropIds.size) + } + + private fun deleteDropMode(drop: DropModeEntity) { + val dropId = drop.id ?: throw IllegalStateException("Drop mode id is null") + val links = dropModeSurveyRepository.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(dropId) + if (links.isNotEmpty()) { + dropModeSurveyRepository.deleteAll(links) + } + dropModeRepository.delete(drop) + logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size) + } + + /** + * Очистка плана. Всегда удаляет все сбросы и брони слотов; для съёмок поведение выбирает + * оператор флагом [keepManual]: + * - `keepManual=true` — сохраняются ручные маршруты съёмки (source=MANUAL), удаляются только + * рассчитанные (как и при пересчёте плана [loadSlots]/[calculateDrops]); + * - `keepManual=false` — удаляются ВСЕ съёмки, включая ручные (полная очистка). + * Сбросы удаляются первыми, чтобы снять связи до удаления съёмок и не плодить проверку + * осиротевших сбросов. + */ + @Transactional + fun clearPlan(missionId: UUID, keepManual: Boolean) { + missionRepository.findById(missionId).orElseThrow { notFound(missionId) } + val modes = modeRepository.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(missionId) + val drops = modes.filterIsInstance() + val surveys = modes.filterIsInstance() + val (keptSurveys, deletedSurveys) = + if (keepManual) surveys.partition { it.source == SurveyModeSource.MANUAL } + else emptyList() to surveys + drops.forEach { deleteDropMode(it) } + deletedSurveys.forEach { deleteSurveyMode(it) } + logger.info( + "План миссии {} очищен (keepManual={}): удалено сбросов={}, съёмок={}, сохранено ручных съёмок (MANUAL)={}", + missionId, keepManual, drops.size, deletedSurveys.size, keptSurveys.size + ) + } + + /** + * Валидация миссии-черновика на конфликты/наложения включений. + * + * MVP-заглушка: проверяет существование миссии (404, если нет) и возвращает корректный контракт + * с **пустым** списком конфликтов. Реальные правила (пересечения по времени, видимость цели, ЗРВ + * станции, порядок сброса, бортовые ограничения) — TODO раздела 6 дизайн-дока. Контракт + * ([MissionValidationResponse]/[ModeConflict]) стабилен, чтобы фронт не ломался при их появлении. + */ + fun validateMission(missionId: UUID): MissionValidationResponse { + if (!missionRepository.existsById(missionId)) { + throw notFound(missionId) + } + return MissionValidationResponse(missionId = missionId, conflicts = emptyList()) + } + + /** + * Строит контур полосы съёмки по параметрам видимости точки. + * Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute]. + */ + private fun buildContour(req: SurveyRouteBuildRequestDTO): BuiltSurveyRoute { + if (req.durationSeconds <= 0.0) { + throw CustomErrorException("Длительность съёмки должна быть положительной") + } + + val halfNanos = (req.durationSeconds / 2.0 * 1_000_000_000L).toLong() + val fullNanos = (req.durationSeconds * 1_000_000_000L).toLong() + val (tn, tk) = when (req.anchor) { + SurveyRouteAnchor.CENTERED -> req.time.minusNanos(halfNanos) to req.time.plusNanos(halfNanos) + SurveyRouteAnchor.FORWARD -> req.time to req.time.plusNanos(fullNanos) + } + + val orbPoints = ballisticsService.exactTime( + req.satelliteId, + ExactTimePositionRequestDTO(time = tn, timeStop = tk, stepMs = 5000) + ).toList() + + if (orbPoints.isEmpty()) { + throw CustomErrorException( + "Нет орбитальных точек для КА ${req.satelliteId} на интервале [$tn - $tk]" + ) + } + + val stepper = RungeStepper( + orbPoints.map { p -> + OrbitalPoint( + t = fromDateTime(p.time), + vit = p.revolution.toInt(), + r = Vector3D(p.x, p.y, p.z), + v = Vector3D(p.vx, p.vy, p.vz) + ) + }.toMutableList(), + EarthType.PZ90d02 + ) + + val wkt = contour(stepper, tn, tk, req.roll, req.captureAngle) + val (lat, lon) = extractCenterLatLon(wkt) + val revolution = if (req.revolution != 0L) req.revolution else orbPoints.first().revolution + + return BuiltSurveyRoute( + satelliteId = req.satelliteId, + timeStart = tn, + durationSeconds = req.durationSeconds, + revolution = revolution, + roll = req.roll, + captureAngle = req.captureAngle, + lat = lat, + longitude = lon, + contourWkt = wkt + ) + } + + /** Результат расчёта контура одного маршрута съёмки (до сохранения в БД). */ + private data class BuiltSurveyRoute( + val satelliteId: Long, + val timeStart: LocalDateTime, + val durationSeconds: Double, + val revolution: Long, + val roll: Double, + val captureAngle: Double, + val lat: Double, + val longitude: Double, + val contourWkt: String + ) + + private fun BuiltSurveyRoute.toResponse(surveyModeId: Long?): SurveyRouteBuildResponseDTO = + SurveyRouteBuildResponseDTO( + satelliteId = satelliteId, + timeStart = timeStart, + duration = durationSeconds, + revolution = revolution, + roll = roll, + captureAngle = captureAngle, + lat = lat, + longitude = longitude, + contourWkt = contourWkt, + surveyModeId = surveyModeId + ) + + private fun contour( stepper: AbstractStepper, tn: LocalDateTime, @@ -617,12 +1179,11 @@ class MissionPlaningService( val iterator = unassigned.iterator() while (iterator.hasNext()) { val survey = iterator.next() - val surveyStartOffset = survey.timeStart - if (surveyStartOffset == null) { + val surveyStart = survey.timeStart + if (surveyStart == null) { iterator.remove() continue } - val surveyStart = surveyStartOffset.toLocalDateTime() val surveyEnd = surveyStart.plusNanos((survey.duration.coerceAtLeast(0.0) * 1_000_000_000L).toLong()) if (!surveyEnd.isBefore(zoneStart)) { @@ -646,7 +1207,7 @@ class MissionPlaningService( val dropMode = dropModeRepository.save( DropModeEntity( plan = plan, - timeStart = zoneStart.atOffset(ZoneOffset.UTC), + timeStart = zoneStart, revolution = zone.revolution, station = zone.stationId.toString(), duration = usedDuration @@ -747,8 +1308,8 @@ class MissionPlaningService( @Transactional(readOnly = true) fun findMode(routePassport: RoutePassportDto): SurveyModeEntity? { - val searchStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).minusSeconds(1) - val searchEnd = routePassport.intervalBegin.atOffset(ZoneOffset.UTC).plusSeconds(1) + val searchStart = routePassport.intervalBegin.minusSeconds(1) + val searchEnd = routePassport.intervalBegin.plusSeconds(1) val candidates = surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( satelliteNumber = routePassport.kaShort.trim(), revolution = routePassport.orbitNumber, @@ -758,7 +1319,7 @@ class MissionPlaningService( return candidates.minByOrNull { candidate -> val candidateStart = candidate.timeStart ?: return@minByOrNull Long.MAX_VALUE - abs(Duration.between(routePassport.intervalBegin.atOffset(ZoneOffset.UTC), candidateStart).toMillis()) + abs(Duration.between(routePassport.intervalBegin, candidateStart).toMillis()) } } diff --git a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt index 4b02ce8..9f1868c 100644 --- a/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt +++ b/services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/satellite_type/LegacySatelliteMissionTypeSupport.kt @@ -6,7 +6,6 @@ import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SatelliteModeResponseDTO -import java.time.ZoneOffset abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { override fun prepareSurvey( @@ -15,7 +14,7 @@ abstract class LegacySatelliteMissionTypeSupport : SatelliteMissionType { plan: PlanEntity ): SurveyModeEntity = SurveyModeEntity( plan = plan, - timeStart = surv.startTime.atOffset(ZoneOffset.UTC), + timeStart = surv.startTime, revolution = surv.revolution, lat = surv.lat, long = surv.longitude, diff --git a/services/pcp-mission-planing-service/src/main/resources/application.yaml b/services/pcp-mission-planing-service/src/main/resources/application.yaml index 71fadd4..33c1a3b 100644 --- a/services/pcp-mission-planing-service/src/main/resources/application.yaml +++ b/services/pcp-mission-planing-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql b/services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql new file mode 100644 index 0000000..a78fede --- /dev/null +++ b/services/pcp-mission-planing-service/src/main/resources/db/migration/V10__mode_volume.sql @@ -0,0 +1,8 @@ +-- Объём данных как атрибут маршрута, Мбайт. Считается при построении из длительности +-- (см. ModeDataRates в pcp-types-lib): съёмка 120 Мбайт/с; сброс — канал 1200 Мбит/с ÷ 8 = 150 Мбайт/с. +ALTER TABLE survey_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0; +ALTER TABLE drop_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0; + +-- Бэкфилл существующих маршрутов из длительности. +UPDATE survey_modes SET volume_mb = duration * 120; +UPDATE drop_modes SET volume_mb = duration * 1200.0 / 8; diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt new file mode 100644 index 0000000..3500d55 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionCloneTest.kt @@ -0,0 +1,297 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class MissionCloneTest { + + private val missionStore = linkedMapOf() + private val planStore = linkedMapOf() + private val surveyStore = linkedMapOf() + private val dropStore = linkedMapOf() + private val dropSurveyStore = mutableListOf() + + private var missionAutoId = 1L + private var planAutoId = 100L + private var modeAutoId = 10L + + private val missionRepository = mock(MissionRepository::class.java).also { repo -> + `when`(repo.findById(anyUUID())).thenAnswer { inv -> + Optional.ofNullable(missionStore[inv.arguments[0]]) + } + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val m = inv.arguments[0] as MissionEntity + missionStore[m.missionId] = m + m + } + } + + private val planRepository = mock(PlanRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val p = inv.arguments[0] as PlanEntity + val id = ++planAutoId + val saved = PlanEntity(id = id, mission = p.mission, planNumber = p.planNumber, planStart = p.planStart, planStop = p.planStop) + planStore[id] = saved + saved + } + } + + private val modeRepository = mock(ModeRepository::class.java).also { repo -> + `when`(repo.findAllByPlan_Mission_MissionIdOrderByTimeStartAsc(anyUUID())).thenAnswer { inv -> + val missionId = inv.arguments[0] as UUID + (surveyStore.values.filter { it.plan?.mission?.missionId == missionId } + + dropStore.values.filter { it.plan?.mission?.missionId == missionId }) + } + } + + private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val s = inv.arguments[0] as SurveyModeEntity + val id = ++modeAutoId + val saved = SurveyModeEntity(id = id, plan = s.plan, timeStart = s.timeStart, revolution = s.revolution, + lat = s.lat, long = s.long, duration = s.duration, contourWkt = s.contourWkt, roll = s.roll, + status = s.status, source = s.source) + surveyStore[id] = saved + saved + } + } + + private val dropModeRepository = mock(DropModeRepository::class.java).also { repo -> + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val d = inv.arguments[0] as DropModeEntity + val id = ++modeAutoId + val saved = DropModeEntity(id = id, plan = d.plan, timeStart = d.timeStart, revolution = d.revolution, + station = d.station, duration = d.duration) + dropStore[id] = saved + saved + } + } + + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo -> + `when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv -> + val dropId = inv.arguments[0] as Long + dropSurveyStore.filter { it.dropMode?.id == dropId } + } + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + val link = inv.arguments[0] as DropModeSurveyEntity + dropSurveyStore.add(link) + link + } + } + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = planRepository, + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = mock(ModeBookingRepository::class.java), + bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java), + ballisticsService = mock(BallisticsService::class.java), + complexMissionService = mock(ComplexMissionService::class.java), + satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java), + satelliteMissionTypeFactory = SatelliteMissionTypeFactory( + listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()), + DefaultSatelliteMissionType() + ) + ) + + // --- replace: полный клон --- + + @Test + fun `replace clone copies all survey and drop modes with remapped survey links`() { + val (missionId, _, survey1, survey2, drop) = buildSourceMission(t(1), t(5)) + addDropLink(drop, survey1) + addDropLink(drop, survey2) + + val result = service.cloneMission(missionId, MissionCloneRequest(mode = "replace")) + + val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, clonedSurveys.size) + assertEquals(1, clonedDrops.size) + // Ссылки переведены на новые survey IDs + val clonedLinks = dropSurveyStore.filter { it.dropMode?.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, clonedLinks.size) + assertTrue(clonedLinks.all { it.surveyMode?.plan?.mission?.missionId == result.newMissionId }) + } + + // --- fork-forward: фильтрация по окну --- + + @Test + fun `fork-forward clone includes mode that starts before window but ends inside`() { + // Включение начинается за 5 мин до окна и заканчивается внутри → пересекается. + // buildSourceMission создаёт 2 survey (оба перекрывают окно) + 1 drop. + val (missionId) = buildSourceMission(t(0).minusMinutes(5), t(0).plusMinutes(10)) + val result = service.cloneMission( + missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(2, cloned.size) + } + + @Test + fun `fork-forward clone excludes mode whose stop equals window start (closed left is open right semantics)`() { + // Включение заканчивается ровно в windowStart → не пересекается (modeStop > windowStart is false). + // Явно создаём одну съёмку длительностью 30 минут, заканчивающуюся в t(0). + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + // duration 1800s = 30 min; начало: t(0) - 30min = строго до окна, конец ровно в t(0). + survey(plan, t(0).minusMinutes(30), duration = 1800.0) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(cloned.isEmpty()) + } + + @Test + fun `fork-forward clone excludes mode starting exactly at window stop (open upper bound)`() { + // Включение начинается ровно в windowStop → не пересекается (open right bound [start, stop)). + // Явно создаём одну съёмку: start = t(1) (= windowStop), duration 10min. + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + survey(plan, t(1), duration = 600.0) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(0), windowStop = t(1)) + ) + val cloned = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(cloned.isEmpty()) + } + + @Test + fun `fork-forward drops drop mode when all its surveys are outside window`() { + // Две съёмки вне окна; сброс на них тоже не клонируется (пустой drop). + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val sourcePlan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[sourcePlan.id!!] = sourcePlan + val outSurvey = survey(sourcePlan, start = t(0).minusHours(2), duration = 60.0) + val drop = drop(sourcePlan, start = t(0).minusMinutes(30)) + addDropLink(drop, outSurvey) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2)) + ) + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertTrue(clonedDrops.isEmpty()) + } + + @Test + fun `fork-forward keeps a survey even when its drop falls outside the window`() { + // Съёмка внутри окна [t(1), t(2)), но её сброс — до окна. Сброс не клонируется, + // съёмка остаётся (будет пересброшена в новом плане) — намеренная семантика fork-forward. + val source = MissionEntity(satelliteId = 100) + missionStore[source.missionId] = source + val plan = PlanEntity(id = ++planAutoId, mission = source, planNumber = 1) + planStore[plan.id!!] = plan + val inSurvey = survey(plan, start = t(1).plusMinutes(10), duration = 600.0) + val outDrop = drop(plan, start = t(0)) + addDropLink(outDrop, inSurvey) + + val result = service.cloneMission( + source.missionId, + MissionCloneRequest(mode = "fork-forward", windowStart = t(1), windowStop = t(2)) + ) + val clonedSurveys = surveyStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + val clonedDrops = dropStore.values.filter { it.plan?.mission?.missionId == result.newMissionId } + assertEquals(1, clonedSurveys.size) + assertTrue(clonedDrops.isEmpty()) + } + + @Test + fun `fork-forward requires windowStart and windowStop`() { + val (missionId) = buildSourceMission(t(0), t(1)) + assertThrows { + service.cloneMission(missionId, MissionCloneRequest(mode = "fork-forward")) + } + } + + // --- helpers --- + + data class SourceFixture( + val missionId: UUID, + val plan: PlanEntity, + val survey1: SurveyModeEntity, + val survey2: SurveyModeEntity, + val drop: DropModeEntity + ) + + private fun buildSourceMission(surveyStart: LocalDateTime, surveyStop: LocalDateTime): SourceFixture { + val mission = MissionEntity(satelliteId = 100) + missionStore[mission.missionId] = mission + val plan = PlanEntity(id = ++planAutoId, mission = mission, planNumber = 1) + planStore[plan.id!!] = plan + val duration = java.time.Duration.between(surveyStart, surveyStop).toMillis().toDouble() / 1000.0 + val s1 = survey(plan, surveyStart, duration) + val s2 = survey(plan, surveyStart.plusMinutes(1), duration) + val d = drop(plan, surveyStart.plusMinutes(5)) + return SourceFixture(mission.missionId, plan, s1, s2, d) + } + + private fun survey(plan: PlanEntity, start: LocalDateTime, duration: Double = 300.0): SurveyModeEntity { + val id = ++modeAutoId + val s = SurveyModeEntity(id = id, plan = plan, timeStart = start, revolution = id, + lat = 55.0, long = 37.0, duration = duration, roll = 0.0, source = SurveyModeSource.MANUAL, + status = SurveyModeStatus.PLANNED) + surveyStore[id] = s + return s + } + + private fun drop(plan: PlanEntity, start: LocalDateTime): DropModeEntity { + val id = ++modeAutoId + val d = DropModeEntity(id = id, plan = plan, timeStart = start, + revolution = id, station = "102", duration = 60.0) + dropStore[id] = d + return d + } + + private fun addDropLink(drop: DropModeEntity, survey: SurveyModeEntity) { + dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey)) + } + + private fun t(hour: Int): LocalDateTime = LocalDateTime.of(2026, 6, 10, hour, 0, 0) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { org.mockito.ArgumentMatchers.any(); return null as T } + private fun anyUUID(): UUID { org.mockito.ArgumentMatchers.any(); return UUID.randomUUID() } + private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt new file mode 100644 index 0000000..639e5e7 --- /dev/null +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionEditModesTest.kt @@ -0,0 +1,293 @@ +package space.nstart.pcp.pcp_mission_planing_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeBookingEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.ModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.PlanEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeEntity +import space.nstart.pcp.pcp_mission_planing_service.entity.SurveyModeSource +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.DropModeSurveyRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.MissionRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeBookingRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.ModeRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.PlanRepository +import space.nstart.pcp.pcp_mission_planing_service.repository.SurveyModeRepository +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.DefaultSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.EmissioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.ReflixioSatelliteMissionType +import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory +import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.DropRouteSaveRequestDTO +import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildRequestDTO +import java.time.LocalDateTime +import java.util.UUID + +class MissionEditModesTest { + + private val surveyStore = linkedMapOf() + private val dropStore = linkedMapOf() + private val dropSurveyStore = mutableListOf() + private val bookingStore = mutableListOf() + + private var autoId = 10L + + private val missionRepository = mock(MissionRepository::class.java) + + private val modeRepository = mock(ModeRepository::class.java).also { repo -> + `when`(repo.findById(anyLong())).thenAnswer { inv -> + val id = inv.arguments[0] as Long + java.util.Optional.ofNullable(surveyStore[id] ?: dropStore[id]) + } + } + + private val surveyModeRepository = mock(SurveyModeRepository::class.java).also { repo -> + doAnswer { inv -> + val s = inv.arguments[0] as SurveyModeEntity + surveyStore.remove(s.id) + null + }.`when`(repo).delete(anyNonNull()) + } + + private val dropModeRepository = mock(DropModeRepository::class.java).also { repo -> + doAnswer { inv -> + val d = inv.arguments[0] as DropModeEntity + dropStore.remove(d.id) + null + }.`when`(repo).delete(anyNonNull()) + doAnswer { inv -> + dropStore.remove(inv.arguments[0] as Long) + null + }.`when`(repo).deleteById(anyLong()) + } + + private val dropModeSurveyRepository = mock(DropModeSurveyRepository::class.java).also { repo -> + `when`(repo.findAllBySurveyMode_Id(anyLong())).thenAnswer { inv -> + val sid = inv.arguments[0] as Long + dropSurveyStore.filter { it.surveyMode?.id == sid } + } + `when`(repo.findAllByDropMode_IdOrderBySurveyMode_TimeStartAsc(anyLong())).thenAnswer { inv -> + val did = inv.arguments[0] as Long + dropSurveyStore.filter { it.dropMode?.id == did } + } + doAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val links = inv.arguments[0] as Iterable + dropSurveyStore.removeAll(links.toSet()) + null + }.`when`(repo).deleteAll(anyNonNull()) + } + + private val modeBookingRepository = mock(ModeBookingRepository::class.java).also { repo -> + `when`(repo.findAllByMode_IdIn(anyNonNull())).thenAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val ids = inv.arguments[0] as Collection + bookingStore.filter { it.mode?.id in ids } + } + doAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val bs = inv.arguments[0] as Iterable + bookingStore.removeAll(bs.toSet()) + null + }.`when`(repo).deleteAll(anyNonNull()) + } + + private val service = MissionPlaningService( + missionRepository = missionRepository, + modeRepository = modeRepository, + planRepository = mock(PlanRepository::class.java), + surveyModeRepository = surveyModeRepository, + dropModeRepository = dropModeRepository, + dropModeSurveyRepository = dropModeSurveyRepository, + modeBookingRepository = modeBookingRepository, + bookedSlotsEventPublisher = mock(BookedSlotsEventPublisher::class.java), + ballisticsService = mock(BallisticsService::class.java), + complexMissionService = mock(ComplexMissionService::class.java), + satelliteCatalogClient = mock(space.nstart.pcp.pcp_mission_planing_service.service.catalog.SatelliteCatalogClient::class.java), + satelliteMissionTypeFactory = SatelliteMissionTypeFactory( + listOf(DefaultSatelliteMissionType(), EmissioSatelliteMissionType(), ReflixioSatelliteMissionType()), + DefaultSatelliteMissionType() + ) + ) + + private val plan = PlanEntity(id = 1L, planNumber = 1) + + // --- DELETE mode --- + + @Test + fun `deleting a survey removes its bookings, links and the orphaned drop`() { + val survey = survey() + val drop = drop() + link(drop, survey) + booking(survey) + + service.deleteMode(survey.id!!) + + assertFalse(surveyStore.containsKey(survey.id)) + assertTrue(bookingStore.isEmpty()) + assertFalse(dropStore.containsKey(drop.id), "сброс без единственной съёмки должен быть удалён") + assertTrue(dropSurveyStore.isEmpty()) + } + + @Test + fun `deleting a survey keeps a drop still referenced by another survey`() { + val survey1 = survey() + val survey2 = survey() + val drop = drop() + link(drop, survey1) + link(drop, survey2) + + service.deleteMode(survey1.id!!) + + assertFalse(surveyStore.containsKey(survey1.id)) + assertTrue(surveyStore.containsKey(survey2.id)) + assertTrue(dropStore.containsKey(drop.id), "сброс с оставшейся съёмкой не удаляется") + assertEquals(1, dropSurveyStore.size) + assertEquals(survey2.id, dropSurveyStore.single().surveyMode?.id) + } + + @Test + fun `deleting a drop removes the drop and its links but keeps surveys`() { + val survey = survey() + val drop = drop() + link(drop, survey) + + service.deleteMode(drop.id!!) + + assertFalse(dropStore.containsKey(drop.id)) + assertTrue(dropSurveyStore.isEmpty()) + assertTrue(surveyStore.containsKey(survey.id), "съёмка независима от сброса и сохраняется") + } + + @Test + fun `deleting an unknown mode yields 404`() { + val ex = assertThrows { service.deleteMode(999L) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- save routes больше не создают живую миссию --- + + @Test + fun `saveSurveyRoute on a missing mission yields 404 instead of auto-creating`() { + // missionRepository не застаблен → findById возвращает пустой Optional. + val ex = assertThrows { + service.saveSurveyRoute(UUID.randomUUID(), SurveyRouteBuildRequestDTO(satelliteId = 25544)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `saveDropRoute on a missing mission yields 404 instead of auto-creating`() { + val ex = assertThrows { + service.saveDropRoute( + UUID.randomUUID(), + DropRouteSaveRequestDTO(satelliteId = 25544, surveyModeIds = listOf(1L)) + ) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- validate (заглушка) --- + + @Test + fun `validate of an existing mission returns an empty conflict list`() { + val missionId = UUID.randomUUID() + `when`(missionRepository.existsById(missionId)).thenReturn(true) + + val result = service.validateMission(missionId) + + assertEquals(missionId, result.missionId) + assertTrue(result.conflicts.isEmpty(), "MVP-заглушка не считает конфликтов") + } + + @Test + fun `validate of a missing mission yields 404`() { + val ex = assertThrows { service.validateMission(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- createBlankMission (план новой цепочки «без истории») --- + + @Test + fun `createBlankMission persists a mission and its plan with the given window`() { + val saved = arrayOfNulls(1) + `when`(missionRepository.save(anyNonNull())).thenAnswer { inv -> + val m = inv.arguments[0] as space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity + saved[0] = m + m + } + val start = LocalDateTime.of(2026, 6, 10, 12, 0) + val stop = LocalDateTime.of(2026, 6, 10, 12, 10) + + val response = service.createBlankMission( + space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest( + satelliteId = 25544, missionStart = start, missionStop = stop + ) + ) + + assertEquals(saved[0]!!.missionId, response.newMissionId) + assertEquals(25544L, saved[0]!!.satelliteId) + assertEquals(start, saved[0]!!.missionStart) + assertEquals(stop, saved[0]!!.missionStop) + assertEquals("NEW", saved[0]!!.status) + } + + @Test + fun `createBlankMission with stop not after start yields 400`() { + val t = LocalDateTime.of(2026, 6, 10, 12, 0) + val ex = assertThrows { + service.createBlankMission( + space.nstart.pcp.pcp_mission_planing_service.controller.MissionBlankRequest( + satelliteId = 25544, missionStart = t, missionStop = t + ) + ) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + // --- helpers --- + + private fun survey(): SurveyModeEntity { + val id = ++autoId + val s = SurveyModeEntity( + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 12, 0), + revolution = id, lat = 55.0, long = 37.0, duration = 60.0, roll = 0.0, + status = SurveyModeStatus.PLANNED, source = SurveyModeSource.MANUAL + ) + surveyStore[id] = s + return s + } + + private fun drop(): DropModeEntity { + val id = ++autoId + val d = DropModeEntity( + id = id, plan = plan, timeStart = LocalDateTime.of(2026, 6, 10, 13, 0), + revolution = id, station = "102", duration = 60.0 + ) + dropStore[id] = d + return d + } + + private fun link(drop: DropModeEntity, survey: SurveyModeEntity) { + dropSurveyStore.add(DropModeSurveyEntity(dropMode = drop, surveyMode = survey)) + } + + private fun booking(survey: SurveyModeEntity) { + bookingStore.add(ModeBookingEntity(id = ++autoId, mode = survey, slotId = autoId)) + } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { org.mockito.ArgumentMatchers.any(); return null as T } + private fun anyLong(): Long { org.mockito.ArgumentMatchers.anyLong(); return 0L } +} diff --git a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt index b4e7943..54f43f8 100644 --- a/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt +++ b/services/pcp-mission-planing-service/src/test/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningServiceTest.kt @@ -41,8 +41,6 @@ import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.Refli import space.nstart.pcp.pcp_mission_planing_service.service.satellite_type.SatelliteMissionTypeFactory import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID import java.math.BigDecimal @@ -93,7 +91,7 @@ class MissionPlaningServiceTest { val overlappingSurvey = SurveyModeEntity( id = 1L, plan = plan, - timeStart = intervalStart.minusMinutes(5).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.minusMinutes(5), revolution = 10, lat = 55.0, long = 37.0, @@ -105,7 +103,7 @@ class MissionPlaningServiceTest { val inWindowSurvey = SurveyModeEntity( id = 2L, plan = plan, - timeStart = intervalStart.plusMinutes(10).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.plusMinutes(10), revolution = 11, lat = 56.0, long = 38.0, @@ -117,7 +115,7 @@ class MissionPlaningServiceTest { val inWindowDrop = DropModeEntity( id = 3L, plan = plan, - timeStart = intervalStart.plusMinutes(20).atOffset(ZoneOffset.UTC), + timeStart = intervalStart.plusMinutes(20), revolution = 12, station = "station-1", duration = 120.0 @@ -126,7 +124,7 @@ class MissionPlaningServiceTest { `when`( modeRepository.findAllByPlan_Mission_SatelliteIdAndTimeStartIsNotNullAndTimeStartLessThanEqualOrderByTimeStartAsc( 77, - intervalStop.atOffset(ZoneOffset.UTC) + intervalStop ) ).thenReturn(listOf(overlappingSurvey, inWindowSurvey, inWindowDrop)) `when`(modeBookingRepository.findAllByMode_IdIn(listOf(1L, 2L))).thenReturn( @@ -188,12 +186,12 @@ class MissionPlaningServiceTest { ) val exactMode = SurveyModeEntity( id = 10L, - timeStart = routePassport.intervalBegin.atOffset(ZoneOffset.UTC), + timeStart = routePassport.intervalBegin, revolution = 321 ) val nearMode = SurveyModeEntity( id = 11L, - timeStart = routePassport.intervalBegin.plusSeconds(1).atOffset(ZoneOffset.UTC), + timeStart = routePassport.intervalBegin.plusSeconds(1), revolution = 321 ) @@ -201,8 +199,8 @@ class MissionPlaningServiceTest { surveyModeRepository.findAllBySatelliteNumberAndRevolutionAndTimeStartBetween( "22", 321, - OffsetDateTime.of(routePassport.intervalBegin.minusSeconds(1), ZoneOffset.UTC), - OffsetDateTime.of(routePassport.intervalBegin.plusSeconds(1), ZoneOffset.UTC) + routePassport.intervalBegin.minusSeconds(1), + routePassport.intervalBegin.plusSeconds(1) ) ).thenReturn(listOf(nearMode, exactMode)) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt new file mode 100644 index 0000000..8a998c8 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfig.kt @@ -0,0 +1,131 @@ +package org.nstart.dep265.requestservice.config + +import com.fasterxml.jackson.core.JsonProcessingException +import jakarta.validation.ConstraintViolationException +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.common.TopicPartition +import org.apache.kafka.common.header.Headers +import org.apache.kafka.common.header.internals.RecordHeader +import org.apache.kafka.common.header.internals.RecordHeaders +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.util.backoff.ExponentialBackOff +import java.nio.charset.StandardCharsets + +/** + * Обработка ошибок Kafka-консьюмера маршрутов: bounded retry + DLQ. + * + * Без этого «ядовитое» сообщение бесконечно ретраится и блокирует партицию. Невалидные по контракту + * (ошибка десериализации/валидации) уходят в DLQ сразу, прочие — после ограниченного числа попыток. + * + * Активируется только при заданном `spring.kafka.bootstrap-servers`, чтобы не требовать Kafka в тестах + * без брокера. + */ +@Configuration +@ConditionalOnProperty(name = ["spring.kafka.bootstrap-servers"], matchIfMissing = false) +class KafkaConsumerConfig( + @param:Value("\${spring.kafka.bootstrap-servers}") + private val bootstrapServers: String, + @param:Value("\${spring.kafka.consumer.group-id}") + private val groupId: String, + @param:Value("\${spring.kafka.consumer.auto-offset-reset:latest}") + private val autoOffsetReset: String, +) { + + @Bean + fun routeConsumerFactory(): ConsumerFactory = + DefaultKafkaConsumerFactory( + mutableMapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to autoOffsetReset, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ), + ) + + @Bean + fun kafkaListenerContainerFactory( + routeKafkaErrorHandler: DefaultErrorHandler, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { + setConsumerFactory(routeConsumerFactory()) + setCommonErrorHandler(routeKafkaErrorHandler) + } + + @Bean + fun routeDeadLetterPublishingRecoverer( + kafkaTemplate: KafkaTemplate, + routeConsumerProperties: RouteConsumerProperties, + ): DeadLetterPublishingRecoverer = + DeadLetterPublishingRecoverer(kafkaTemplate) { record, _ -> + TopicPartition(routeConsumerProperties.dlqTopic, record.partition()) + }.apply { + addHeadersFunction { record, exception -> routeDlqHeaders(record, exception) } + } + + @Bean + fun routeKafkaErrorHandler( + routeDeadLetterPublishingRecoverer: DeadLetterPublishingRecoverer, + routeConsumerProperties: RouteConsumerProperties, + ): DefaultErrorHandler = + DefaultErrorHandler( + routeDeadLetterPublishingRecoverer, + routeConsumerBackOff(routeConsumerProperties), + ).apply { + // Контрактные ошибки (битый JSON, провал валидации) бесполезно ретраить — сразу в DLQ. + addNotRetryableExceptions( + JsonProcessingException::class.java, + ConstraintViolationException::class.java, + ) + setCommitRecovered(true) + } + + private fun routeConsumerBackOff(properties: RouteConsumerProperties): ExponentialBackOff = + ExponentialBackOff().apply { + initialInterval = properties.backoffMs + multiplier = RETRY_BACKOFF_MULTIPLIER + maxInterval = properties.maxBackoffMs + maxAttempts = (properties.maxAttempts - 1).coerceAtLeast(0) + } + + private fun routeDlqHeaders( + record: ConsumerRecord<*, *>, + exception: Exception, + ): Headers = + RecordHeaders().apply { + addStringHeader(HEADER_SERVICE_NAME, SERVICE_NAME) + addStringHeader(HEADER_EXCEPTION_CLASS, exception::class.java.name) + addStringHeader(HEADER_EXCEPTION_MESSAGE, exception.message.orEmpty()) + addStringHeader(HEADER_ORIGINAL_TOPIC, record.topic()) + addStringHeader(HEADER_ORIGINAL_PARTITION, record.partition().toString()) + addStringHeader(HEADER_ORIGINAL_OFFSET, record.offset().toString()) + } + + private fun Headers.addStringHeader(name: String, value: String) { + add(RecordHeader(name, value.toByteArray(StandardCharsets.UTF_8))) + } + + private companion object { + const val SERVICE_NAME = "pcp-request-service" + const val RETRY_BACKOFF_MULTIPLIER = 2.0 + + const val HEADER_SERVICE_NAME = "pcp-service-name" + const val HEADER_EXCEPTION_CLASS = "pcp-exception-class" + const val HEADER_EXCEPTION_MESSAGE = "pcp-exception-message" + const val HEADER_ORIGINAL_TOPIC = "pcp-original-topic" + const val HEADER_ORIGINAL_PARTITION = "pcp-original-partition" + const val HEADER_ORIGINAL_OFFSET = "pcp-original-offset" + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt index 78f6747..284d328 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/KafkaProducerConfig.kt @@ -15,7 +15,7 @@ class KafkaProducerConfig { @Bean @ConditionalOnMissingBean fun requestProducerFactory( - @Value("\${spring.kafka.bootstrap-servers:192.168.100.160:19092}") bootstrapServers: String, + @Value("\${spring.kafka.bootstrap-servers:localhost:29092}") bootstrapServers: String, ): ProducerFactory { return DefaultKafkaProducerFactory( mapOf( diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt index 2bcd7d9..96721b0 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxProperties.kt @@ -4,7 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "pcp.outbox") data class OutboxProperties( - val requestCompletedTopic: String = "pcp.request.completed.v1", + /** Топик терминальных статусов заявки (COMPLETED/EXPIRED) для geoportal. */ + val requestStatusTopic: String = "pcp.request.status.v1", val publishBatchSize: Int = 50, val publishFixedDelayMs: Long = 5_000, + /** Сколько раз пытаемся опубликовать событие до перевода в терминальный FAILED. */ + val maxAttempts: Int = 10, + /** Backoff после первой неудачной публикации. */ + val initialBackoffMs: Long = 1_000, + /** Верхняя граница backoff между попытками. */ + val maxBackoffMs: Long = 60_000, ) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt index f4d6de2..db18f26 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/OutboxTransactionConfig.kt @@ -22,4 +22,15 @@ class OutboxTransactionConfig { propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW } } + + /** + * Каждый батч планировщика истечения коммитится отдельно, поэтому между батчами + * row-level блокировки (FOR UPDATE SKIP LOCKED) освобождаются. + */ + @Bean("requestExpiryTransactionOperations") + fun requestExpiryTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations { + return TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + } } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt new file mode 100644 index 0000000..967e151 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RequestExpiryProperties.kt @@ -0,0 +1,19 @@ +package org.nstart.dep265.requestservice.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Настройки планировщика истечения заявок: периодически переводит просроченные заявки + * (status ACCEPTED/ACTIVE, окно которых уже закончилось) в EXPIRED. + */ +@ConfigurationProperties(prefix = "pcp.request.expiry") +data class RequestExpiryProperties( + /** Включён ли планировщик. */ + val enabled: Boolean = true, + /** Период запуска (мс), читается также в @Scheduled. */ + val fixedDelayMs: Long = 60_000, + /** Сколько заявок захватывать за одну транзакцию (FOR UPDATE SKIP LOCKED). */ + val batchSize: Int = 200, + /** Верхняя граница числа батчей за один прогон, чтобы не удерживать поток планировщика. */ + val maxBatchesPerRun: Int = 50, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt new file mode 100644 index 0000000..dd92386 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/config/RouteConsumerProperties.kt @@ -0,0 +1,28 @@ +package org.nstart.dep265.requestservice.config + +import jakarta.validation.constraints.Min +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.validation.annotation.Validated + +/** + * Настройки обработки ошибок Kafka-консьюмера маршрутов: DLQ-топик и bounded retry. + * «Ядовитое» (невалидное по контракту) сообщение уходит в DLQ, а не блокирует партицию. + */ +@Validated +@ConfigurationProperties(prefix = "app.kafka.consumer.route") +data class RouteConsumerProperties( + /** Топик dead-letter для маршрутов, не прошедших обработку. */ + val dlqTopic: String = "pcp.route.georeference-dlq.v1", + + /** Максимум попыток обработки, включая первичную доставку. */ + @field:Min(1) + val maxAttempts: Long = 5, + + /** Начальная задержка retry, мс. */ + @field:Min(0) + val backoffMs: Long = 1_000, + + /** Верхняя граница задержки retry, мс. */ + @field:Min(0) + val maxBackoffMs: Long = 30_000, +) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt index c1d5367..dd56310 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/controller/RequestController.kt @@ -22,7 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID @RestController @@ -41,10 +41,10 @@ class RequestController( @RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) kpp: Int?, @RequestParam(required = false) highPriorityTransmit: Boolean?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?, @RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int, @@ -73,10 +73,10 @@ class RequestController( @RequestParam(required = false) surveyType: SurveyTypeDto?, @RequestParam(required = false) kpp: Int?, @RequestParam(required = false) highPriorityTransmit: Boolean?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: LocalDateTime?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: LocalDateTime?, @RequestParam(defaultValue = "false") includeDeleted: Boolean, @RequestParam(defaultValue = "0") page: Int, @RequestParam(defaultValue = "50") size: Int, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt index 669b84d..7c4cabb 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/GridManagementDtos.kt @@ -3,7 +3,7 @@ package org.nstart.dep265.requestservice.dto import com.fasterxml.jackson.annotation.JsonFormat import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.NotNull -import java.time.OffsetDateTime +import java.time.LocalDateTime /** * Текущие настройки сетки. @@ -38,5 +38,5 @@ data class GridRebuildResponseDto( /** Дата-время завершения перестроения в UTC. */ @field:JsonFormat(shape = JsonFormat.Shape.STRING) - val rebuiltAt: OffsetDateTime, + val rebuiltAt: LocalDateTime, ) diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt index 2131f87..efdddf1 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt @@ -11,7 +11,7 @@ import jakarta.validation.constraints.Min import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID /** @@ -48,11 +48,11 @@ data class CreateRequestDto( /** Начало временного окна выполнения заявки. */ @field:NotNull - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ @field:NotNull - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -60,6 +60,15 @@ data class CreateRequestDto( /** Признак высокоприоритетной передачи. */ val highPriorityTransmit: Boolean = false, + /** + * Требуемый процент покрытия заявки (0 < x ≤ 100), при достижении которого заявка + * считается выполненной (COMPLETED). Это **прогресс съёмки**, не путать с [importance] + * (важность заявки для карты приоритетов ячеек). По умолчанию — 100%. + */ + @field:DecimalMin(value = "0", inclusive = false) + @field:DecimalMax("100") + val coverageRequiredPercent: Double = 100.0, + /** Параметры оптической съемки. */ @field:Valid val optics: OpticsParamsDto? = null, @@ -120,10 +129,10 @@ data class RequestResponseDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -141,13 +150,13 @@ data class RequestResponseDto( val coverage: CoverageStateDto, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -170,10 +179,10 @@ data class RequestSummaryResponseDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -185,13 +194,13 @@ data class RequestSummaryResponseDto( val coveragePercent: Double, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -237,10 +246,10 @@ data class RequestMapItemDto( val importance: Double, /** Начало временного окна выполнения заявки. */ - val beginDateTime: OffsetDateTime, + val beginDateTime: LocalDateTime, /** Окончание временного окна выполнения заявки. */ - val endDateTime: OffsetDateTime, + val endDateTime: LocalDateTime, /** Список номеров КПП для сброса. */ val kpp: List = emptyList(), @@ -252,13 +261,13 @@ data class RequestMapItemDto( val coveragePercent: Double, /** Время создания заявки. */ - val createdAt: OffsetDateTime, + val createdAt: LocalDateTime, /** Время последнего обновления заявки. */ - val updatedAt: OffsetDateTime, + val updatedAt: LocalDateTime, /** Время soft delete, если заявка удалена. */ - val deletedAt: OffsetDateTime? = null, + val deletedAt: LocalDateTime? = null, ) /** @@ -317,7 +326,7 @@ data class DeleteRequestResponseDto( val id: UUID, /** Время soft delete. */ - val deletedAt: OffsetDateTime, + val deletedAt: LocalDateTime, ) /** @@ -421,16 +430,16 @@ data class RequestListFilter( val highPriorityTransmit: Boolean? = null, /** Нижняя граница beginDateTime, включительно. */ - val beginFrom: OffsetDateTime? = null, + val beginFrom: LocalDateTime? = null, /** Верхняя граница beginDateTime, включительно. */ - val beginTo: OffsetDateTime? = null, + val beginTo: LocalDateTime? = null, /** Нижняя граница endDateTime, включительно. */ - val endFrom: OffsetDateTime? = null, + val endFrom: LocalDateTime? = null, /** Верхняя граница endDateTime, включительно. */ - val endTo: OffsetDateTime? = null, + val endTo: LocalDateTime? = null, /** Включать soft-deleted заявки в результат. */ val includeDeleted: Boolean = false, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt index 77c8805..03fdee2 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/entity/OutboxEventEntity.kt @@ -48,6 +48,14 @@ class OutboxEventEntity( @Column(name = "created_at", nullable = false) var createdAt: LocalDateTime, + /** Число выполненных попыток публикации. */ + @Column(name = "attempts", nullable = false) + var attempts: Int = 0, + + /** Не раньше этого момента событие снова попадёт в выборку публикации (backoff). */ + @Column(name = "next_attempt_at", nullable = false) + var nextAttemptAt: LocalDateTime = createdAt, + @Column(name = "published_at") var publishedAt: LocalDateTime? = null, diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt index 02459c1..890ac1c 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepository.kt @@ -16,8 +16,9 @@ interface OutboxEventRepository : JpaRepository { fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List /** - * Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already - * being published by another transaction. + * Locks publishable events so parallel service instances skip rows already being published by + * another transaction. Учитывает backoff (`next_attempt_at`) и предел попыток (`attempts`): + * терминальные FAILED (`attempts >= maxAttempts`) и ещё не «дозревшие» строки исключаются. */ @Query( value = """ @@ -25,6 +26,8 @@ interface OutboxEventRepository : JpaRepository { FROM outbox_events WHERE event_type = :eventType AND status IN ('NEW', 'FAILED') + AND attempts < :maxAttempts + AND next_attempt_at <= :now ORDER BY CASE WHEN status = 'NEW' THEN 0 ELSE 1 END, created_at ASC @@ -33,8 +36,10 @@ interface OutboxEventRepository : JpaRepository { """, nativeQuery = true, ) - fun findRequestCompletedPublishableForUpdateSkipLocked( + fun findPublishableForUpdateSkipLocked( @Param("eventType") eventType: String, + @Param("now") now: LocalDateTime, + @Param("maxAttempts") maxAttempts: Int, @Param("batchSize") batchSize: Int, ): List @@ -52,6 +57,8 @@ interface OutboxEventRepository : JpaRepository { aggregate_id, payload, status, + attempts, + next_attempt_at, created_at, published_at, error_message @@ -63,6 +70,8 @@ interface OutboxEventRepository : JpaRepository { :aggregateId, CAST(:payload AS jsonb), :status, + 0, + :createdAt, :createdAt, NULL, NULL diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt index 8b05de9..895660d 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/repository/RequestRepository.kt @@ -106,4 +106,27 @@ interface RequestRepository : JpaRepository { ): List fun findByStatusIn(statuses: Collection): List + + /** + * Захватывает порцию просроченных заявок (окно уже закончилось, статус ещё незавершённый) + * под row-level lock. FOR UPDATE SKIP LOCKED позволяет нескольким инстансам разбирать + * непересекающиеся строки, не конкурируя за одни и те же заявки. + */ + @Query( + value = """ + SELECT * + FROM requests + WHERE status IN ('ACCEPTED', 'ACTIVE') + AND end_date_time < :now + AND deleted_at IS NULL + ORDER BY end_date_time ASC + LIMIT :batchSize + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true, + ) + fun findExpirableForUpdateSkipLocked( + @Param("now") now: LocalDateTime, + @Param("batchSize") batchSize: Int, + ): List } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt index 7cf002b..39d0438 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/GridRebuildService.kt @@ -7,8 +7,7 @@ import org.nstart.dep265.requestservice.repository.RequestCellRepository import org.nstart.dep265.requestservice.repository.RequestRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.time.OffsetDateTime -import java.time.ZoneOffset +import java.time.LocalDateTime class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running") @@ -41,7 +40,7 @@ class GridRebuildService( return GridRebuildResponseDto( cellsCount = earthCellRepository.count(), rebuiltRequestProjectionsCount = activeRequests.size.toLong(), - rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC), + rebuiltAt = LocalDateTime.now(), ) } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt index 31627eb..7077639 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherService.kt @@ -11,6 +11,7 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionCallback import org.springframework.transaction.support.TransactionOperations +import java.time.Duration import java.time.LocalDateTime import java.util.concurrent.ExecutionException @@ -41,60 +42,91 @@ class OutboxPublisherService( return try { transactionOperations.execute( TransactionCallback { - val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( - eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, + val now = LocalDateTime.now() + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE, + now = now, + maxAttempts = outboxProperties.maxAttempts, batchSize = batchSize, ).distinctBy { it.id } events.forEach { event -> - publishAndUpdateStatus(event) + publishAndUpdateStatus(event, now) } events.size }, ) ?: 0 } catch (exception: Exception) { - log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception) + log.error("Failed to process REQUEST_TERMINAL_STATUS outbox event batch", exception) 0 } } - private fun publishAndUpdateStatus(event: OutboxEventEntity) { + private fun publishAndUpdateStatus(event: OutboxEventEntity, now: LocalDateTime) { try { kafkaTemplate .send( - outboxProperties.requestCompletedTopic, + outboxProperties.requestStatusTopic, event.aggregateId.toString(), event.payload.toString(), ) .get() event.status = OutboxEventStatus.PUBLISHED - event.publishedAt = LocalDateTime.now() + event.publishedAt = now event.errorMessage = null outboxEventRepository.save(event) log.info( - "Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", + "Published REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, topic={}", event.id, event.aggregateId, - outboxProperties.requestCompletedTopic, + outboxProperties.requestStatusTopic, ) } catch (exception: Exception) { val publishException = exception.unwrapExecutionException() + + event.attempts += 1 event.status = OutboxEventStatus.FAILED event.errorMessage = publishException.shortMessage() + // attempts >= maxAttempts → терминальный FAILED (выборка публикации его уже не вернёт); + // иначе планируем повтор с backoff. + if (event.attempts < outboxProperties.maxAttempts) { + event.nextAttemptAt = now.plus(Duration.ofMillis(calculateBackoffMs(event.attempts))) + } outboxEventRepository.save(event) + val exhausted = event.attempts >= outboxProperties.maxAttempts log.warn( - "Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}", + "Failed to publish REQUEST_TERMINAL_STATUS outbox event: eventId={}, requestId={}, " + + "topic={}, attempts={}, maxAttempts={}, exhausted={}, nextAttemptAt={}", event.id, event.aggregateId, - outboxProperties.requestCompletedTopic, + outboxProperties.requestStatusTopic, + event.attempts, + outboxProperties.maxAttempts, + exhausted, + event.nextAttemptAt, publishException, ) } } + /** + * Удваивает backoff от [OutboxProperties.initialBackoffMs] до [OutboxProperties.maxBackoffMs]. + */ + private fun calculateBackoffMs(attempts: Int): Long { + var backoffMs = outboxProperties.initialBackoffMs.coerceAtMost(outboxProperties.maxBackoffMs) + repeat((attempts - 1).coerceAtLeast(0)) { + backoffMs = if (backoffMs >= outboxProperties.maxBackoffMs / 2) { + outboxProperties.maxBackoffMs + } else { + (backoffMs * 2).coerceAtMost(outboxProperties.maxBackoffMs) + } + } + return backoffMs + } + private fun Exception.unwrapExecutionException(): Throwable { return if (this is ExecutionException && cause != null) { cause!! diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt new file mode 100644 index 0000000..fd73d08 --- /dev/null +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryService.kt @@ -0,0 +1,118 @@ +package org.nstart.dep265.requestservice.service + +import org.nstart.dep265.requestservice.config.RequestExpiryProperties +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime + +/** + * Планировщик истечения заявок. + * + * [RequestLifecycleService.refreshStatus] вызывается только при матчинге маршрута, поэтому заявка, + * к которой не пришло ни одного маршрута, никогда не уходила в EXPIRED после окончания окна. + * Этот воркер периодически переводит такие заявки в EXPIRED. + * + * Несколько инстансов безопасны: батч строк захватывается через FOR UPDATE SKIP LOCKED, так что + * параллельные инстансы разбирают непересекающиеся заявки. + */ +@Service +class RequestExpiryService( + private val requestRepository: RequestRepository, + + private val requestLifecycleService: RequestLifecycleService, + + private val requestStatusOutboxService: RequestStatusOutboxService, + + private val requestExpiryProperties: RequestExpiryProperties, + + @param:Qualifier("requestExpiryTransactionOperations") + private val transactionOperations: TransactionOperations, +) { + private val log = LoggerFactory.getLogger(this::class.java) + + @Scheduled(fixedDelayString = "\${pcp.request.expiry.fixed-delay-ms:60000}") + fun expireOverdueScheduledBatch() { + if (!requestExpiryProperties.enabled) { + return + } + expireOverdue() + } + + /** + * Переводит просроченные заявки (ACCEPTED/ACTIVE с истёкшим окном) в EXPIRED. + * + * @return число заявок, перешедших в EXPIRED за прогон. + */ + fun expireOverdue(): Int { + val batchSize = requestExpiryProperties.batchSize.coerceAtLeast(0) + if (batchSize == 0) { + return 0 + } + val maxBatches = requestExpiryProperties.maxBatchesPerRun.coerceAtLeast(1) + val now = LocalDateTime.now() + + var expiredTotal = 0 + var batchIndex = 0 + while (batchIndex < maxBatches) { + val result = try { + transactionOperations.execute( + TransactionCallback { expireBatch(now, batchSize) }, + ) ?: ExpireBatchResult.EMPTY + } catch (exception: Exception) { + log.error("Failed to process request expiry batch", exception) + break + } + + expiredTotal += result.expiredCount + batchIndex += 1 + if (result.claimedCount < batchSize) { + break + } + } + + if (expiredTotal > 0) { + log.info("Request expiry sweep finished: expired={}", expiredTotal) + } + return expiredTotal + } + + private fun expireBatch(now: LocalDateTime, batchSize: Int): ExpireBatchResult { + val candidates = requestRepository.findExpirableForUpdateSkipLocked(now, batchSize) + var expiredCount = 0 + + candidates.forEach { request -> + val refresh = requestLifecycleService.refreshStatus(request, now) + if (refresh.statusChanged) { + request.updatedAt = now + requestRepository.save(request) + if (request.status == RequestStatus.EXPIRED) { + expiredCount += 1 + // Терминальный статус → уведомляем geoportal через тот же transactional outbox. + requestStatusOutboxService.createTerminalIfAbsent(request, now) + log.info( + "Request expired: requestId={}, endDateTime={}", + request.id, + request.endDateTime, + ) + } + } + } + + return ExpireBatchResult(claimedCount = candidates.size, expiredCount = expiredCount) + } + + private data class ExpireBatchResult( + val claimedCount: Int, + val expiredCount: Int, + ) { + companion object { + val EMPTY = ExpireBatchResult(claimedCount = 0, expiredCount = 0) + } + } +} diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index 13df5cb..e420f55 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -39,8 +39,6 @@ import org.springframework.data.domain.Sort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.UUID class InvalidRequestListSortException(message: String) : IllegalArgumentException(message) @@ -53,6 +51,7 @@ class RequestService( private val requestCellRepository: RequestCellRepository, private val geometryService: GeometryService, private val requestGridProjectionService: RequestGridProjectionService, + private val requestLifecycleService: RequestLifecycleService, private val entityManager: EntityManager, ) { @Transactional @@ -80,10 +79,12 @@ class RequestService( remainingGeometry = normalizedGeometryWkt, geometryArea = geometryArea, remainingArea = geometryArea, - coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT, + // Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки, + // в отличие от importance — важности заявки для карты приоритетов ячеек. + coverageRequiredPercent = request.coverageRequiredPercent, importance = importance, - beginDateTime = request.beginDateTime.toUtcLocalDateTime(), - endDateTime = request.endDateTime.toUtcLocalDateTime(), + beginDateTime = request.beginDateTime, + endDateTime = request.endDateTime, kpp = request.kpp.toMutableList(), highPriorityTransmit = request.highPriorityTransmit, surveyType = surveyType, @@ -92,6 +93,10 @@ class RequestService( updatedAt = now, ) + // Окно заявки может уже идти или закончиться к моменту приёма — сразу выставляем + // корректный статус (ACCEPTED/ACTIVE/EXPIRED), а не всегда ACCEPTED. + requestLifecycleService.refreshStatus(requestEntity, now) + persistNewRequest(requestEntity) replaceSurveyParams(requestEntity.id, request.optics, request.rsa) requestGridProjectionService.rebuildProjection(requestEntity) @@ -99,7 +104,7 @@ class RequestService( return CreateRequestResponseDto( id = requestEntity.id, name = requestEntity.name, - status = RequestStatusDto.ACCEPTED, + status = requestEntity.status.toDto(), surveyType = requestEntity.surveyType.toDto(), importance = requestEntity.importance, message = "Заявка успешно создана", @@ -162,7 +167,7 @@ class RequestService( requestCellRepository.deleteByRequestId(request.id) return DeleteRequestResponseDto( id = request.id, - deletedAt = deletedAt.toOffsetDateTime(), + deletedAt = deletedAt, ) } @@ -206,10 +211,10 @@ class RequestService( surveyType = query.surveyType?.toEntity(), kpp = query.kpp, highPriorityTransmit = query.highPriorityTransmit, - beginFrom = query.beginFrom?.toUtcLocalDateTime(), - beginTo = query.beginTo?.toUtcLocalDateTime(), - endFrom = query.endFrom?.toUtcLocalDateTime(), - endTo = query.endTo?.toUtcLocalDateTime(), + beginFrom = query.beginFrom, + beginTo = query.beginTo, + endFrom = query.endFrom, + endTo = query.endTo, pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()), ) @@ -223,16 +228,16 @@ class RequestService( surveyType = surveyType.toDto(), geometry = geometry, importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, optics = optics, rsa = rsa, coverage = coverage(), - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -252,14 +257,14 @@ class RequestService( status = status.toDto(), surveyType = surveyType.toDto(), importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, coveragePercent = coverage().currentPercent, - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -271,14 +276,14 @@ class RequestService( surveyType = surveyType.toDto(), geometry = geometry, importance = importance, - beginDateTime = beginDateTime.toOffsetDateTime(), - endDateTime = endDateTime.toOffsetDateTime(), + beginDateTime = beginDateTime, + endDateTime = endDateTime, kpp = kpp, highPriorityTransmit = highPriorityTransmit, coveragePercent = coverage().currentPercent, - createdAt = createdAt.toOffsetDateTime(), - updatedAt = updatedAt.toOffsetDateTime(), - deletedAt = deletedAt?.toOffsetDateTime(), + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt, ) } @@ -361,10 +366,6 @@ class RequestService( return Sort.by(direction, property) } - private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime() - - private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC) - private fun Throwable.isDuplicateRequestId(): Boolean { return generateSequence(this) { throwable -> throwable.cause } .filterIsInstance() @@ -375,7 +376,6 @@ class RequestService( } private companion object { - const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0 const val POSTGRES_UNIQUE_VIOLATION = "23505" const val REQUESTS_PRIMARY_KEY = "requests_pkey" const val DEFAULT_SORT_FIELD = "createdAt" diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt similarity index 55% rename from services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt rename to services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt index 044c9af..088dfa1 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxService.kt @@ -12,8 +12,15 @@ import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime import java.util.UUID +/** + * Складывает в transactional outbox единое событие о **терминальном** статусе заявки + * (`COMPLETED` или `EXPIRED`) для geoportal. Идемпотентно по `(event_type, aggregate_id)`: + * на заявку приходится не более одного терминального события. + * + * Публикуется [OutboxPublisherService] в топик `pcp.request.status.v1` со статусом в payload. + */ @Service -class RequestCompletedOutboxService( +class RequestStatusOutboxService( private val outboxEventRepository: OutboxEventRepository, private val objectMapper: ObjectMapper, @@ -23,12 +30,17 @@ class RequestCompletedOutboxService( private val log = LoggerFactory.getLogger(this::class.java) @Transactional(propagation = Propagation.MANDATORY) - fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) { - val completedAt = request.completedAt ?: return - val payload = RequestCompletedPayload( + fun createTerminalIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) { + val status = request.status + require(status == RequestStatus.COMPLETED || status == RequestStatus.EXPIRED) { + "Терминальное outbox-событие требует COMPLETED/EXPIRED, получено: $status" + } + + val payload = RequestStatusPayload( requestId = request.id, - status = RequestStatus.COMPLETED.name, - completedAt = completedAt, + status = status.name, + // Для COMPLETED — момент достижения покрытия, для EXPIRED — момент фиксации истечения. + occurredAt = request.completedAt ?: eventCreatedAt, coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea), matchedAt = request.lastMatchedAt, eventCreatedAt = eventCreatedAt, @@ -36,7 +48,7 @@ class RequestCompletedOutboxService( val inserted = outboxEventRepository.insertIfAbsent( id = UUID.randomUUID(), - eventType = REQUEST_COMPLETED_EVENT_TYPE, + eventType = REQUEST_STATUS_EVENT_TYPE, aggregateType = REQUEST_AGGREGATE_TYPE, aggregateId = request.id, payload = objectMapper.writeValueAsString(payload), @@ -46,23 +58,23 @@ class RequestCompletedOutboxService( if (inserted == 0) { log.debug( - "REQUEST_COMPLETED outbox event already exists for requestId={}", + "Терминальное outbox-событие уже существует для requestId={}", request.id, ) } } - private data class RequestCompletedPayload( + private data class RequestStatusPayload( val requestId: UUID, val status: String, - val completedAt: LocalDateTime, + val occurredAt: LocalDateTime, val coveragePercent: Double, val matchedAt: LocalDateTime?, val eventCreatedAt: LocalDateTime, ) companion object { - const val REQUEST_COMPLETED_EVENT_TYPE = "REQUEST_COMPLETED" + const val REQUEST_STATUS_EVENT_TYPE = "REQUEST_TERMINAL_STATUS" const val REQUEST_AGGREGATE_TYPE = "REQUEST" } } diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt index 0fb6ed7..5e5934c 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingService.kt @@ -31,7 +31,7 @@ class RouteMatchingService( private val requestLifecycleService: RequestLifecycleService, - private val requestCompletedOutboxService: RequestCompletedOutboxService, + private val requestStatusOutboxService: RequestStatusOutboxService, @param:Qualifier("routeMatchingTransactionOperations") private val routeMatchingTransactionOperations: TransactionOperations, @@ -168,7 +168,7 @@ class RouteMatchingService( val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt) requestRepository.save(request) if (statusRefresh.completedNow) { - requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt) + requestStatusOutboxService.createTerminalIfAbsent(request, matchedAt) } return true } diff --git a/services/pcp-request-service/src/main/resources/application.yaml b/services/pcp-request-service/src/main/resources/application.yaml index 50f2cbc..962dede 100644 --- a/services/pcp-request-service/src/main/resources/application.yaml +++ b/services/pcp-request-service/src/main/resources/application.yaml @@ -7,13 +7,16 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} pcp: outbox: - request-completed-topic: pcp.request.completed.v1 + request-status-topic: pcp.request.status.v1 publish-batch-size: 50 publish-fixed-delay-ms: 5000 + max-attempts: 10 + initial-backoff-ms: 1000 + max-backoff-ms: 60000 diff --git a/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql b/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql index 96df62e..6ba242d 100644 --- a/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql +++ b/services/pcp-request-service/src/main/resources/db/migration/V1__create_schema.sql @@ -100,6 +100,8 @@ CREATE TABLE IF NOT EXISTS outbox_events ( aggregate_id UUID NOT NULL, payload JSONB NOT NULL, status VARCHAR(32) NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMP NOT NULL DEFAULT now(), created_at TIMESTAMP NOT NULL, published_at TIMESTAMP NULL, error_message TEXT NULL, @@ -203,6 +205,7 @@ CREATE INDEX IF NOT EXISTS idx_request_kpp_request_id ON request_kpp(request_id) CREATE INDEX IF NOT EXISTS idx_request_kpp_kpp ON request_kpp(kpp); CREATE INDEX IF NOT EXISTS idx_request_route_matches_route_id ON request_route_matches(route_id); CREATE INDEX IF NOT EXISTS idx_outbox_events_status_created_at ON outbox_events(status, created_at); +CREATE INDEX IF NOT EXISTS idx_outbox_events_due ON outbox_events(event_type, status, next_attempt_at, created_at); CREATE INDEX IF NOT EXISTS idx_earth_cells_cell_num ON earth_cells(cell_num); CREATE INDEX IF NOT EXISTS idx_earth_cells_contour_geom ON earth_cells USING GIST (contour_geom); CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id); diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt index 7fb6226..42a202a 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/MigrationSchemaTest.kt @@ -49,8 +49,11 @@ class MigrationSchemaTest { assertTrue(outboxEvents.contains("payload jsonb not null")) assertTrue(outboxEvents.contains("status varchar(32) not null")) + assertTrue(outboxEvents.contains("attempts integer not null default 0")) + assertTrue(outboxEvents.contains("next_attempt_at timestamp not null")) assertTrue(outboxEvents.contains("constraint uk_outbox_events_event_type_aggregate_id unique (event_type, aggregate_id)")) assertTrue(schema.contains("on outbox_events(status, created_at)")) + assertTrue(schema.contains("on outbox_events(event_type, status, next_attempt_at, created_at)")) } @Test diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt new file mode 100644 index 0000000..b5c74b7 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/config/KafkaConsumerConfigTest.kt @@ -0,0 +1,175 @@ +package org.nstart.dep265.requestservice.config + +import jakarta.validation.ConstraintViolation +import jakarta.validation.ConstraintViolationException +import org.apache.kafka.clients.consumer.Consumer +import org.apache.kafka.clients.consumer.ConsumerRecord +import org.apache.kafka.clients.producer.ProducerRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.DefaultErrorHandler +import org.springframework.kafka.listener.MessageListenerContainer +import org.springframework.kafka.support.SendResult +import java.nio.charset.StandardCharsets +import java.util.concurrent.CompletableFuture + +class KafkaConsumerConfigTest { + + private val contextRunner = ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration::class.java)) + .withUserConfiguration(TestConfiguration::class.java, KafkaConsumerConfig::class.java) + .withPropertyValues( + "spring.kafka.bootstrap-servers=localhost:9092", + "spring.kafka.consumer.group-id=pcp-request-service", + "spring.kafka.consumer.auto-offset-reset=earliest", + "app.kafka.consumer.route.dlq-topic=$DLQ_TOPIC", + "app.kafka.consumer.route.max-attempts=3", + "app.kafka.consumer.route.backoff-ms=0", + "app.kafka.consumer.route.max-backoff-ms=0", + ) + + @Test + fun `creates error handler and recoverer beans`() { + contextRunner.run { context -> + assertNotNull(context.getBean(DefaultErrorHandler::class.java)) + assertNotNull(context.getBean(DeadLetterPublishingRecoverer::class.java)) + + val properties = context.getBean(RouteConsumerProperties::class.java) + assertEquals(DLQ_TOPIC, properties.dlqTopic) + assertEquals(3L, properties.maxAttempts) + } + } + + @Test + fun `recoverer publishes original record to configured dlq topic with diagnostics headers`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val recoverer = context.getBean(DeadLetterPublishingRecoverer::class.java) + val record = consumerRecord() + + recoverer.accept(record, ConstraintViolationException("route payload invalid", emptySet>())) + + val published = capturePublishedRecord(kafkaTemplate) + assertEquals(DLQ_TOPIC, published.topic()) + assertEquals(record.partition(), published.partition()) + assertEquals(record.key(), published.key()) + assertEquals(record.value(), published.value()) + assertEquals("pcp-request-service", published.headers().stringHeader("pcp-service-name")) + assertEquals( + ConstraintViolationException::class.java.name, + published.headers().stringHeader("pcp-exception-class"), + ) + assertEquals("route payload invalid", published.headers().stringHeader("pcp-exception-message")) + assertEquals(ROUTE_TOPIC, published.headers().stringHeader("pcp-original-topic")) + assertEquals("3", published.headers().stringHeader("pcp-original-partition")) + assertEquals("42", published.headers().stringHeader("pcp-original-offset")) + } + } + + @Test + fun `contract violation is recovered to dlq without retry`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val errorHandler = context.getBean(DefaultErrorHandler::class.java) + + val handled = errorHandler.handleOne( + ConstraintViolationException("route payload invalid", emptySet>()), + consumerRecord(), + mockConsumer(), + mock(MessageListenerContainer::class.java), + ) + + assertTrue(handled) + verify(kafkaTemplate, times(1)).send(anyProducerRecord()) + } + } + + @Test + fun `transient exception is retried with bounded policy before dlq recovery`() { + contextRunner.run { context -> + val kafkaTemplate = context.kafkaTemplate() + val errorHandler = context.getBean(DefaultErrorHandler::class.java) + val record = consumerRecord() + val consumer = mockConsumer() + val container = mock(MessageListenerContainer::class.java) + val exception = IllegalStateException("database is temporarily unavailable") + + // maxAttempts=3 → две первые доставки ретраятся (seek), на третьей — DLQ. + assertFalse(errorHandler.handleOne(exception, record, consumer, container)) + assertFalse(errorHandler.handleOne(exception, record, consumer, container)) + verify(kafkaTemplate, never()).send(anyProducerRecord()) + + assertTrue(errorHandler.handleOne(exception, record, consumer, container)) + verify(kafkaTemplate, times(1)).send(anyProducerRecord()) + } + } + + private fun consumerRecord(): ConsumerRecord = + ConsumerRecord(ROUTE_TOPIC, 3, 42L, "route-key", """{"routeId":"route-1"}""") + + private fun capturePublishedRecord( + kafkaTemplate: KafkaTemplate, + ): ProducerRecord { + val captor = producerRecordCaptor() + verify(kafkaTemplate, times(1)).send(captor.capture()) + return captor.value + } + + @Suppress("UNCHECKED_CAST") + private fun producerRecordCaptor(): ArgumentCaptor> = + ArgumentCaptor.forClass(ProducerRecord::class.java) as ArgumentCaptor> + + @Suppress("UNCHECKED_CAST") + private fun mockConsumer(): Consumer = + mock(Consumer::class.java) as Consumer + + private fun org.apache.kafka.common.header.Headers.stringHeader(name: String): String? = + lastHeader(name)?.value()?.toString(StandardCharsets.UTF_8) + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RouteConsumerProperties::class) + private class TestConfiguration { + + @Bean + @Suppress("UNCHECKED_CAST") + fun kafkaTemplate(): KafkaTemplate { + val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate + val sendResult = mock(SendResult::class.java) as SendResult + `when`(kafkaTemplate.send(anyProducerRecord())) + .thenReturn(CompletableFuture.completedFuture(sendResult)) + return kafkaTemplate + } + } + + private companion object { + const val ROUTE_TOPIC = "pcp.route.georeference.v1" + const val DLQ_TOPIC = "pcp.route.georeference-dlq.v1" + + @Suppress("UNCHECKED_CAST") + fun anyProducerRecord(): ProducerRecord = + (any(ProducerRecord::class.java) as ProducerRecord?) + ?: ProducerRecord("stub-topic", "stub-key", "stub-payload") + + @Suppress("UNCHECKED_CAST") + fun org.springframework.context.ApplicationContext.kafkaTemplate(): KafkaTemplate = + getBean(KafkaTemplate::class.java) as KafkaTemplate + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt index 71ef3cd..9a094e1 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/GridManagementControllerTest.kt @@ -21,7 +21,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.MockMvcBuilders -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.Optional class GridManagementControllerTest { @@ -97,7 +97,7 @@ class GridManagementControllerTest { GridRebuildResponseDto( cellsCount = 2, rebuiltRequestProjectionsCount = 2, - rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"), + rebuiltAt = LocalDateTime.of(2026, 5, 20, 10, 15, 30), ) ).`when`(gridRebuildService).rebuildGrid() @@ -105,7 +105,7 @@ class GridManagementControllerTest { .andExpect(status().isOk) .andExpect(jsonPath("$.cellsCount").value(2)) .andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2)) - .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z")) + .andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30")) } @Test diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt index c91a450..13dbf15 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/controller/RequestControllerOpenApiSkeletonTest.kt @@ -25,6 +25,7 @@ import org.nstart.dep265.requestservice.repository.RequestRepository import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository import org.nstart.dep265.requestservice.service.GeometryService import org.nstart.dep265.requestservice.service.RequestGridProjectionService +import org.nstart.dep265.requestservice.service.RequestLifecycleService import org.nstart.dep265.requestservice.service.RequestService import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest @@ -98,6 +99,7 @@ class RequestControllerOpenApiSkeletonTest { requestCellRepository = requestCellRepository, geometryService = GeometryService(), requestGridProjectionService = mock(RequestGridProjectionService::class.java), + requestLifecycleService = RequestLifecycleService(GeometryService()), entityManager = entityManager, ) ) @@ -114,6 +116,7 @@ class RequestControllerOpenApiSkeletonTest { @Test fun `post v1 requests returns create response`() { + // Окно в будущем → приём детерминированно даёт ACCEPTED (createRequest зовёт refreshStatus). mockMvc.perform( post("/v1/requests") .contentType(MediaType.APPLICATION_JSON) @@ -125,8 +128,8 @@ class RequestControllerOpenApiSkeletonTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2090-01-01T00:00:00Z", + "endDateTime": "2090-01-31T23:59:59Z", "kpp": [1, 2, 3], "highPriorityTransmit": false, "optics": { diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt index fcec078..d9cabac 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtoContractTest.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import java.time.OffsetDateTime +import java.time.LocalDateTime import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -25,8 +25,8 @@ class RequestApiDtoContractTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", "kpp": [1, 2, 3], "highPriorityTransmit": false, "optics": { @@ -43,8 +43,33 @@ class RequestApiDtoContractTest { assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id) assertEquals("Тест1", request.name) assertEquals(10.0, request.importance) - assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime) + assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime) assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType) + // Поле опционально: при отсутствии в payload — порог покрытия по умолчанию 100%. + assertEquals(100.0, request.coverageRequiredPercent) + } + + @Test + fun `create request dto binds explicit coverage required percent`() { + val request = objectMapper.readValue( + """ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "Тест1", + "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", + "importance": 10.0, + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", + "coverageRequiredPercent": 80.0, + "optics": { + "resultType": "PANCHROMATIC", + "resolution": 1.0 + } + } + """.trimIndent(), + ) + + assertEquals(80.0, request.coverageRequiredPercent) } @Test @@ -55,8 +80,8 @@ class RequestApiDtoContractTest { "name": "Тест1", "geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", "importance": 10.0, - "beginDateTime": "2026-01-01T00:00:00Z", - "endDateTime": "2026-01-31T23:59:59Z", + "beginDateTime": "2026-01-01T00:00:00", + "endDateTime": "2026-01-31T23:59:59", "surveyType": "OPTICS", "optics": { "resultType": "PANCHROMATIC", @@ -78,8 +103,8 @@ class RequestApiDtoContractTest { name = "Тест1", geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", importance = 10.0, - beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), - endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), + endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, @@ -87,40 +112,7 @@ class RequestApiDtoContractTest { ), ) - assertFalse(json.contains("surveyType")) - assertFalse(json.contains("app_type")) - assertFalse(json.contains("appType")) - assertFalse(json.contains("AppType")) - assertFalse(json.contains("requestType")) - } - - @Test - fun `cells list response dto matches openapi fields`() { - val json = objectMapper.writeValueAsString( - CellsListResponseDto( - items = listOf( - CellSummaryResponseDto( - cellNum = 42, - latitude = 55.0, - longitude = 37.0, - importance = 10.0, - contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", - ) - ), - page = 0, - size = 50, - totalItems = 1, - totalPages = 1, - ) - ) - - assertEquals( - setOf("items", "page", "size", "totalItems", "totalPages"), - objectMapper.readTree(json).fieldNames().asSequence().toSet(), - ) - assertEquals( - setOf("cellNum", "latitude", "longitude", "importance", "contour"), - objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(), - ) + val node = objectMapper.readTree(json) + assertFalse(node.has("surveyType"), "surveyType should not be serialized") } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt new file mode 100644 index 0000000..e65ddb2 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/dto/RequestResponseDtoContractTest.kt @@ -0,0 +1,43 @@ +package org.nstart.dep265.requestservice.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для [RequestResponseDto]: naive `LocalDateTime` → ISO без зоны + * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам. + */ +class RequestResponseDtoContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes request times as naive ISO without zone`() { + val dto = RequestResponseDto( + id = UUID.fromString("00000000-0000-0000-0000-000000000001"), + name = "request", + status = RequestStatusDto.ACTIVE, + surveyType = SurveyTypeDto.OPTICS, + geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", + importance = 1.0, + beginDateTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endDateTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + highPriorityTransmit = false, + coverage = CoverageStateDto(currentPercent = 0.0), + createdAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0), + updatedAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0) + ) + + val node = mapper.readTree(mapper.writeValueAsString(dto)) + + assertEquals("2026-05-29T01:00:00", node.get("beginDateTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endDateTime").asText()) + assertFalse(node.get("createdAt").asText().endsWith("Z"), "не ожидалась зона Z") + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt index 7cf9b2c..38fe235 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/OutboxEventRepositoryJpaTest.kt @@ -82,19 +82,77 @@ class OutboxEventRepositoryJpaTest @Autowired constructor( ) outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType)) - val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked( - eventType = "REQUEST_COMPLETED", + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = "REQUEST_TERMINAL_STATUS", + now = LocalDateTime.parse("2026-06-06T00:00:00"), + maxAttempts = 10, batchSize = 10, ) assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id }) } + @Test + @Transactional + fun `publishable query excludes events whose backoff has not elapsed`() { + val now = LocalDateTime.parse("2026-06-06T00:00:00") + val dueFailed = outboxEvent( + requestId = UUID.fromString("aaaaaaaa-0000-4000-8000-000000000001"), + status = OutboxEventStatus.FAILED, + attempts = 1, + nextAttemptAt = now.minusMinutes(1), + ) + val backoffPendingFailed = outboxEvent( + requestId = UUID.fromString("bbbbbbbb-0000-4000-8000-000000000002"), + status = OutboxEventStatus.FAILED, + attempts = 1, + nextAttemptAt = now.plusMinutes(5), + ) + outboxEventRepository.saveAllAndFlush(listOf(dueFailed, backoffPendingFailed)) + + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = "REQUEST_TERMINAL_STATUS", + now = now, + maxAttempts = 10, + batchSize = 10, + ) + + assertEquals(listOf(dueFailed.id), events.map { it.id }) + } + + @Test + @Transactional + fun `publishable query excludes events that exhausted max attempts`() { + val now = LocalDateTime.parse("2026-06-06T00:00:00") + val retriable = outboxEvent( + requestId = UUID.fromString("cccccccc-0000-4000-8000-000000000003"), + status = OutboxEventStatus.FAILED, + attempts = 9, + ) + val exhausted = outboxEvent( + requestId = UUID.fromString("dddddddd-0000-4000-8000-000000000004"), + status = OutboxEventStatus.FAILED, + attempts = 10, + ) + outboxEventRepository.saveAllAndFlush(listOf(retriable, exhausted)) + + val events = outboxEventRepository.findPublishableForUpdateSkipLocked( + eventType = "REQUEST_TERMINAL_STATUS", + now = now, + maxAttempts = 10, + batchSize = 10, + ) + + assertEquals(listOf(retriable.id), events.map { it.id }) + } + private fun outboxEvent( requestId: UUID, - eventType: String = "REQUEST_COMPLETED", + eventType: String = "REQUEST_TERMINAL_STATUS", status: OutboxEventStatus = OutboxEventStatus.NEW, createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), + attempts: Int = 0, + nextAttemptAt: LocalDateTime = createdAt, ): OutboxEventEntity { return OutboxEventEntity( id = UUID.randomUUID(), @@ -104,6 +162,8 @@ class OutboxEventRepositoryJpaTest @Autowired constructor( payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""), status = status, createdAt = createdAt, + attempts = attempts, + nextAttemptAt = nextAttemptAt, ) } } diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt new file mode 100644 index 0000000..aa4179f --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/repository/RequestRepositoryJpaTest.kt @@ -0,0 +1,104 @@ +package org.nstart.dep265.requestservice.repository + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.nstart.dep265.requestservice.PcpRequestServiceApplication +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals + +@SpringBootTest( + classes = [PcpRequestServiceApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = [ + "spring.config.import=", + "spring.cloud.config.enabled=false", + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:request-expiry;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.kafka.listener.auto-startup=false", + "spring.kafka.consumer.group-id=request-service-test", + "app.kafka.topics.route=request-service-test-route", + ], +) +class RequestRepositoryJpaTest @Autowired constructor( + private val requestRepository: RequestRepository, +) { + private val now: LocalDateTime = LocalDateTime.parse("2026-06-06T12:00:00") + + @BeforeEach + fun setUp() { + requestRepository.deleteAll() + } + + @Test + @Transactional + fun `expirable query returns only overdue accepted and active requests`() { + val overdueAccepted = request(status = RequestStatus.ACCEPTED, end = now.minusHours(1)) + val overdueActive = request(status = RequestStatus.ACTIVE, end = now.minusHours(2)) + val activeInWindow = request(status = RequestStatus.ACTIVE, end = now.plusHours(1)) + val alreadyCompleted = request(status = RequestStatus.COMPLETED, end = now.minusHours(1)) + val alreadyExpired = request(status = RequestStatus.EXPIRED, end = now.minusHours(1)) + val deletedOverdue = request( + status = RequestStatus.DELETED, + end = now.minusHours(1), + deletedAt = now.minusMinutes(30), + ) + requestRepository.saveAllAndFlush( + listOf(overdueAccepted, overdueActive, activeInWindow, alreadyCompleted, alreadyExpired, deletedOverdue), + ) + + val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 100) + + // Только незавершённые (ACCEPTED/ACTIVE) с истёкшим окном и без soft-delete, по возрастанию end. + assertEquals(listOf(overdueActive.id, overdueAccepted.id), expirable.map { it.id }) + } + + @Test + @Transactional + fun `expirable query honours the batch size limit`() { + val oldest = request(status = RequestStatus.ACTIVE, end = now.minusHours(3)) + val middle = request(status = RequestStatus.ACCEPTED, end = now.minusHours(2)) + val newest = request(status = RequestStatus.ACTIVE, end = now.minusHours(1)) + requestRepository.saveAllAndFlush(listOf(newest, oldest, middle)) + + val expirable = requestRepository.findExpirableForUpdateSkipLocked(now = now, batchSize = 2) + + assertEquals(listOf(oldest.id, middle.id), expirable.map { it.id }) + } + + private fun request( + status: RequestStatus, + end: LocalDateTime, + begin: LocalDateTime = end.minusDays(1), + deletedAt: LocalDateTime? = null, + ): RequestEntity { + val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))" + return RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 1.0, + importance = 10.0, + beginDateTime = begin, + endDateTime = end, + surveyType = RequestSurveyType.OPTICS, + status = status, + createdAt = begin.minusDays(1), + updatedAt = begin.minusDays(1), + deletedAt = deletedAt, + ) + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt index 83b327c..fac212c 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/OutboxPublisherServiceTest.kt @@ -6,6 +6,8 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.anyString import org.mockito.Mockito.doReturn import org.mockito.Mockito.doAnswer @@ -30,13 +32,16 @@ import java.util.concurrent.CompletableFuture class OutboxPublisherServiceTest { private val objectMapper = jacksonObjectMapper() private val properties = OutboxProperties( - requestCompletedTopic = "pcp.request.completed.v1", + requestStatusTopic = "pcp.request.status.v1", publishBatchSize = 50, publishFixedDelayMs = 5_000, + maxAttempts = 10, + initialBackoffMs = 1_000, + maxBackoffMs = 60_000, ) @Test - fun `new request completed event is published and marked as published`() { + fun `new terminal event is published and marked as published`() { val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4") val event = outboxEvent( requestId = requestId, @@ -50,7 +55,7 @@ class OutboxPublisherServiceTest { assertEquals(1, processedCount) verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), event.payload.toString(), ) @@ -61,11 +66,12 @@ class OutboxPublisherServiceTest { } @Test - fun `failed request completed event is picked for retry`() { + fun `failed event is picked for retry`() { val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f") val event = outboxEvent( requestId = requestId, status = OutboxEventStatus.FAILED, + attempts = 1, errorMessage = "previous failure", ) val outboxEventRepository = repositoryReturning(listOf(event)) @@ -75,7 +81,7 @@ class OutboxPublisherServiceTest { service.publishPending() verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), event.payload.toString(), ) @@ -95,14 +101,14 @@ class OutboxPublisherServiceTest { service.publishPending() verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), objectMapper.readTree(savedPayload).toString(), ) } @Test - fun `kafka failure marks event as failed and stores short error message`() { + fun `kafka failure increments attempts, schedules backoff and stores short error message`() { val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb") val event = outboxEvent(requestId = requestId) val outboxEventRepository = repositoryReturning(listOf(event)) @@ -113,17 +119,42 @@ class OutboxPublisherServiceTest { assertEquals(1, processedCount) assertEquals(OutboxEventStatus.FAILED, event.status) + assertEquals(1, event.attempts) assertTrue(event.errorMessage!!.contains("broker unavailable")) assertNull(event.publishedAt) + // backoff: следующая попытка отложена в будущее относительно создания события. + assertTrue(event.nextAttemptAt.isAfter(event.createdAt)) verify(outboxEventRepository).save(event) } + @Test + fun `event exhausting max attempts becomes terminal and is not rescheduled`() { + val requestId = UUID.fromString("c0ffee00-0000-4000-8000-000000000001") + val event = outboxEvent( + requestId = requestId, + status = OutboxEventStatus.FAILED, + attempts = properties.maxAttempts - 1, + errorMessage = "previous failure", + ) + val outboxEventRepository = repositoryReturning(listOf(event)) + val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken"))) + val service = publisherService(outboxEventRepository, kafkaTemplate) + + service.publishPending() + + assertEquals(OutboxEventStatus.FAILED, event.status) + assertEquals(properties.maxAttempts, event.attempts) + // Исчерпав попытки, событие терминально: backoff не назначается (next_attempt_at не двигаем). + assertEquals(event.createdAt, event.nextAttemptAt) + } + @Test fun `failed event is not retried repeatedly in same publish pending call`() { val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7") val event = outboxEvent( requestId = requestId, status = OutboxEventStatus.FAILED, + attempts = 1, errorMessage = "previous failure", ) val outboxEventRepository = repositoryReturning(listOf(event)) @@ -134,15 +165,18 @@ class OutboxPublisherServiceTest { assertEquals(1, processedCount) verify(kafkaTemplate, times(1)).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, requestId.toString(), event.payload.toString(), ) - verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked( - RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, - properties.publishBatchSize, + verify(outboxEventRepository, times(1)).findPublishableForUpdateSkipLocked( + anyString(), + anyLocalDateTime(), + anyInt(), + anyInt(), ) assertEquals(OutboxEventStatus.FAILED, event.status) + assertEquals(2, event.attempts) assertTrue(event.errorMessage!!.contains("still broken")) } @@ -153,6 +187,7 @@ class OutboxPublisherServiceTest { val failedEvent = outboxEvent( requestId = failedRequestId, status = OutboxEventStatus.FAILED, + attempts = 1, errorMessage = "previous failure", ) val newEvent = outboxEvent(requestId = newRequestId) @@ -164,12 +199,12 @@ class OutboxPublisherServiceTest { assertEquals(2, processedCount) verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, failedRequestId.toString(), failedEvent.payload.toString(), ) verify(kafkaTemplate).send( - properties.requestCompletedTopic, + properties.requestStatusTopic, newRequestId.toString(), newEvent.payload.toString(), ) @@ -206,9 +241,11 @@ class OutboxPublisherServiceTest { val outboxEventRepository = mock(OutboxEventRepository::class.java) doReturn(events) .`when`(outboxEventRepository) - .findRequestCompletedPublishableForUpdateSkipLocked( - RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, - properties.publishBatchSize, + .findPublishableForUpdateSkipLocked( + anyString(), + anyLocalDateTime(), + anyInt(), + anyInt(), ) return outboxEventRepository } @@ -256,20 +293,31 @@ class OutboxPublisherServiceTest { requestId: UUID, payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""", status: OutboxEventStatus = OutboxEventStatus.NEW, + attempts: Int = 0, errorMessage: String? = null, ): OutboxEventEntity { + val createdAt = LocalDateTime.parse("2026-01-01T10:00:00") return OutboxEventEntity( id = UUID.randomUUID(), - eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE, - aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE, + eventType = RequestStatusOutboxService.REQUEST_STATUS_EVENT_TYPE, + aggregateType = RequestStatusOutboxService.REQUEST_AGGREGATE_TYPE, aggregateId = requestId, payload = objectMapper.readTree(payload), status = status, - createdAt = LocalDateTime.parse("2026-01-01T10:00:00"), + createdAt = createdAt, + attempts = attempts, + nextAttemptAt = createdAt, errorMessage = errorMessage, ) } + // Регистрирует matcher, но возвращает non-null заглушку (any() на non-null LocalDateTime в Kotlin + // вернул бы null и сломал стек матчеров Mockito). + private fun anyLocalDateTime(): LocalDateTime { + any(LocalDateTime::class.java) + return LocalDateTime.now() + } + private class ImmediateTransactionOperations : TransactionOperations { override fun execute(action: TransactionCallback): T { return action.doInTransaction(SimpleTransactionStatus()) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt deleted file mode 100644 index 944eea5..0000000 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestCompletedOutboxServiceTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.nstart.dep265.requestservice.service - -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.mockingDetails -import org.nstart.dep265.requestservice.entity.RequestEntity -import org.nstart.dep265.requestservice.entity.RequestStatus -import org.nstart.dep265.requestservice.entity.RequestSurveyType -import org.nstart.dep265.requestservice.repository.OutboxEventRepository -import java.time.LocalDateTime -import java.util.UUID - -class RequestCompletedOutboxServiceTest { - private val objectMapper = jacksonObjectMapper() - .registerModule(JavaTimeModule()) - .findAndRegisterModules() - - @Test - fun `request completed payload contains required fields`() { - val outboxEventRepository = mock(OutboxEventRepository::class.java) - val service = RequestCompletedOutboxService( - outboxEventRepository = outboxEventRepository, - objectMapper = objectMapper, - geometryService = GeometryService(), - ) - val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846") - val completedAt = LocalDateTime.parse("2026-01-01T10:15:30") - val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00") - val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00") - val request = RequestEntity( - id = requestId, - name = "Тест1", - geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", - remainingGeometry = "POLYGON EMPTY", - geometryArea = 1.0, - remainingArea = 0.0, - importance = 10.0, - beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), - endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"), - surveyType = RequestSurveyType.OPTICS, - status = RequestStatus.COMPLETED, - lastMatchedAt = matchedAt, - completedAt = completedAt, - createdAt = LocalDateTime.parse("2026-01-01T09:00:00"), - updatedAt = completedAt, - ) - - service.createRequestCompletedIfAbsent(request, eventCreatedAt) - - val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { - invocation -> invocation.method.name == "insertIfAbsent" - } - val payload = objectMapper.readTree(insertInvocation.arguments[4] as String) - assertEquals(requestId.toString(), payload["requestId"].asText()) - assertEquals("COMPLETED", payload["status"].asText()) - assertNotNull(payload["completedAt"]) - assertEquals(100.0, payload["coveragePercent"].asDouble()) - assertNotNull(payload["matchedAt"]) - assertNotNull(payload["eventCreatedAt"]) - } -} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt new file mode 100644 index 0000000..31c0cb8 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestExpiryServiceTest.kt @@ -0,0 +1,203 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockingDetails +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.nstart.dep265.requestservice.config.RequestExpiryProperties +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import org.nstart.dep265.requestservice.repository.RequestRepository +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionCallback +import org.springframework.transaction.support.TransactionOperations +import java.time.LocalDateTime +import java.util.UUID + +class RequestExpiryServiceTest { + + @Test + fun `expires overdue accepted and active requests and emits terminal events`() { + val requestRepository = mock(RequestRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200)) + + val expiredAccepted = overdueRequest(status = RequestStatus.ACCEPTED) + val expiredActive = overdueRequest(status = RequestStatus.ACTIVE) + doReturn(listOf(expiredAccepted, expiredActive)) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(2, expiredCount) + assertEquals(RequestStatus.EXPIRED, expiredAccepted.status) + assertEquals(RequestStatus.EXPIRED, expiredActive.status) + verify(requestRepository).save(expiredAccepted) + verify(requestRepository).save(expiredActive) + + // По каждой истёкшей заявке — терминальное событие в outbox для geoportal. + val terminalInserts = mockingDetails(outboxEventRepository).invocations + .filter { invocation -> invocation.method.name == "insertIfAbsent" } + assertEquals(2, terminalInserts.size) + assertTrue(terminalInserts.all { invocation -> invocation.arguments[1] == "REQUEST_TERMINAL_STATUS" }) + } + + @Test + fun `keeps fetching while a full batch is returned`() { + val requestRepository = mock(RequestRepository::class.java) + val service = expiryService( + requestRepository, + mock(OutboxEventRepository::class.java), + RequestExpiryProperties(batchSize = 1), + ) + + val first = overdueRequest(status = RequestStatus.ACTIVE) + val second = overdueRequest(status = RequestStatus.ACCEPTED) + doReturn(listOf(first), listOf(second), emptyList()) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(2, expiredCount) + verify(requestRepository, times(3)) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + } + + @Test + fun `empty sweep returns zero and emits nothing`() { + val requestRepository = mock(RequestRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200)) + + doReturn(emptyList()) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(0, expiredCount) + verify(requestRepository, never()).save(any(RequestEntity::class.java)) + verifyNoInteractions(outboxEventRepository) + } + + @Test + fun `request still inside its window is not expired`() { + val requestRepository = mock(RequestRepository::class.java) + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = expiryService(requestRepository, outboxEventRepository, RequestExpiryProperties(batchSize = 200)) + + val now = LocalDateTime.now() + val inWindow = request( + status = RequestStatus.ACTIVE, + beginDateTime = now.minusDays(1), + endDateTime = now.plusDays(1), + ) + doReturn(listOf(inWindow)) + .`when`(requestRepository) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + + val expiredCount = service.expireOverdue() + + assertEquals(0, expiredCount) + assertEquals(RequestStatus.ACTIVE, inWindow.status) + verify(requestRepository, never()).save(any(RequestEntity::class.java)) + verifyNoInteractions(outboxEventRepository) + } + + @Test + fun `disabled scheduler does not sweep`() { + val requestRepository = mock(RequestRepository::class.java) + val service = expiryService( + requestRepository, + mock(OutboxEventRepository::class.java), + RequestExpiryProperties(enabled = false), + ) + + service.expireOverdueScheduledBatch() + + verify(requestRepository, never()) + .findExpirableForUpdateSkipLocked(anyLocalDateTime(), anyInt()) + } + + private fun expiryService( + requestRepository: RequestRepository, + outboxEventRepository: OutboxEventRepository, + properties: RequestExpiryProperties, + ): RequestExpiryService { + val geometryService = GeometryService() + return RequestExpiryService( + requestRepository = requestRepository, + requestLifecycleService = RequestLifecycleService(geometryService), + requestStatusOutboxService = RequestStatusOutboxService( + outboxEventRepository = outboxEventRepository, + objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules(), + geometryService = geometryService, + ), + requestExpiryProperties = properties, + transactionOperations = ImmediateTransactionOperations(), + ) + } + + private fun overdueRequest(status: RequestStatus): RequestEntity { + val now = LocalDateTime.now() + return request( + status = status, + beginDateTime = now.minusDays(10), + endDateTime = now.minusDays(1), + ) + } + + private fun request( + status: RequestStatus, + beginDateTime: LocalDateTime, + endDateTime: LocalDateTime, + ): RequestEntity { + val geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" + return RequestEntity( + id = UUID.randomUUID(), + name = "Тест1", + geometry = geometry, + remainingGeometry = geometry, + geometryArea = 1.0, + remainingArea = 1.0, + importance = 10.0, + beginDateTime = beginDateTime, + endDateTime = endDateTime, + surveyType = RequestSurveyType.OPTICS, + status = status, + createdAt = beginDateTime.minusDays(1), + updatedAt = beginDateTime.minusDays(1), + ) + } + + // Регистрирует matcher, но возвращает non-null заглушку — иначе any() на non-null LocalDateTime + // в Kotlin отдаёт null и ломает стек матчеров Mockito. + private fun anyLocalDateTime(): LocalDateTime { + any(LocalDateTime::class.java) + return LocalDateTime.now() + } + + private class ImmediateTransactionOperations : TransactionOperations { + override fun execute(action: TransactionCallback): T { + val status: TransactionStatus = SimpleTransactionStatus() + return action.doInTransaction(status) + } + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt index 50d8f8b..5603f62 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceListRequestsJpaTest.kt @@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.UUID import kotlin.test.assertEquals @@ -137,8 +136,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor( val response = service.listRequests( RequestListFilter( - beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), - beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0), + beginTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59), ) ) @@ -153,8 +152,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor( val response = service.listRequests( RequestListFilter( - endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"), - endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + endFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0), + endTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59), ) ) diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt index d6e071e..4b95e86 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt @@ -30,7 +30,6 @@ import org.springframework.data.domain.PageImpl import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Pageable import java.time.LocalDateTime -import java.time.OffsetDateTime import java.util.Optional import java.util.UUID import kotlin.test.assertEquals @@ -51,6 +50,7 @@ class RequestServiceTest { requestCellRepository = requestCellRepository, geometryService = GeometryService(), requestGridProjectionService = requestGridProjectionService, + requestLifecycleService = RequestLifecycleService(GeometryService()), entityManager = entityManager, ) @@ -96,6 +96,22 @@ class RequestServiceTest { verify(requestGridProjectionService).rebuildProjection(persistedRequests.single()) } + @Test + fun `create request passes through explicit coverage required percent from api`() { + val persistedRequests = mutableListOf() + doReturn(false) + .`when`(requestRepository) + .existsById(REQUEST_ID) + doAnswer { invocation -> + persistedRequests += invocation.getArgument(0) + null + }.`when`(entityManager).persist(any(RequestEntity::class.java)) + + service.createRequest(createRequestDto(coverageRequiredPercent = 80.0)) + + assertEquals(80.0, persistedRequests.single().coverageRequiredPercent) + } + @Test fun `get request by id returns name and importance`() { val request = existingRequest() @@ -280,14 +296,15 @@ class RequestServiceTest { verify(requestCellRepository).deleteByRequestId(REQUEST_ID) } - private fun createRequestDto(): CreateRequestDto { + private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto { return CreateRequestDto( id = REQUEST_ID, name = REQUEST_NAME, geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))", importance = REQUEST_IMPORTANCE, - beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"), - endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"), + beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0), + endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59), + coverageRequiredPercent = coverageRequiredPercent, optics = OpticsParamsDto( resultType = OpticsResultTypeDto.PANCHROMATIC, resolution = 1.0, diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt new file mode 100644 index 0000000..cc8f322 --- /dev/null +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestStatusOutboxServiceTest.kt @@ -0,0 +1,107 @@ +package org.nstart.dep265.requestservice.service + +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockingDetails +import org.nstart.dep265.requestservice.entity.RequestEntity +import org.nstart.dep265.requestservice.entity.RequestStatus +import org.nstart.dep265.requestservice.entity.RequestSurveyType +import org.nstart.dep265.requestservice.repository.OutboxEventRepository +import java.time.LocalDateTime +import java.util.UUID + +class RequestStatusOutboxServiceTest { + private val objectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .findAndRegisterModules() + + @Test + fun `completed terminal payload contains required fields`() { + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = service(outboxEventRepository) + val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846") + val completedAt = LocalDateTime.parse("2026-01-01T10:15:30") + val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00") + val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00") + val request = RequestEntity( + id = requestId, + name = "Тест1", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + remainingGeometry = "POLYGON EMPTY", + geometryArea = 1.0, + remainingArea = 0.0, + importance = 10.0, + beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"), + endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"), + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.COMPLETED, + lastMatchedAt = matchedAt, + completedAt = completedAt, + createdAt = LocalDateTime.parse("2026-01-01T09:00:00"), + updatedAt = completedAt, + ) + + service.createTerminalIfAbsent(request, eventCreatedAt) + + val payload = objectMapper.readTree(insertedPayload(outboxEventRepository)) + assertEquals(requestId.toString(), payload["requestId"].asText()) + assertEquals("COMPLETED", payload["status"].asText()) + assertEquals(completedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java)) + assertEquals(100.0, payload["coveragePercent"].asDouble()) + assertNotNull(payload["matchedAt"]) + assertNotNull(payload["eventCreatedAt"]) + } + + @Test + fun `expired terminal payload carries expired status and partial coverage`() { + val outboxEventRepository = mock(OutboxEventRepository::class.java) + val service = service(outboxEventRepository) + val requestId = UUID.fromString("9c2f0c4b-3a51-4e2d-9d8a-1f4b2c6e7a01") + val eventCreatedAt = LocalDateTime.parse("2026-02-01T00:00:00") + val request = RequestEntity( + id = requestId, + name = "Тест1", + geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))", + remainingGeometry = "POLYGON((0 0, 0.5 0, 0.5 1, 0 1, 0 0))", + geometryArea = 1.0, + remainingArea = 0.5, + importance = 10.0, + beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"), + endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"), + surveyType = RequestSurveyType.OPTICS, + status = RequestStatus.EXPIRED, + completedAt = null, + createdAt = LocalDateTime.parse("2025-12-31T00:00:00"), + updatedAt = eventCreatedAt, + ) + + service.createTerminalIfAbsent(request, eventCreatedAt) + + val payload = objectMapper.readTree(insertedPayload(outboxEventRepository)) + assertEquals("EXPIRED", payload["status"].asText()) + // completedAt отсутствует у EXPIRED → occurredAt = момент фиксации истечения. + assertEquals(eventCreatedAt, objectMapper.treeToValue(payload["occurredAt"], LocalDateTime::class.java)) + assertEquals(50.0, payload["coveragePercent"].asDouble()) + assertTrue(payload["matchedAt"].isNull) + } + + private fun service(outboxEventRepository: OutboxEventRepository): RequestStatusOutboxService { + return RequestStatusOutboxService( + outboxEventRepository = outboxEventRepository, + objectMapper = objectMapper, + geometryService = GeometryService(), + ) + } + + private fun insertedPayload(outboxEventRepository: OutboxEventRepository): String { + val insertInvocation = mockingDetails(outboxEventRepository).invocations.single { invocation -> + invocation.method.name == "insertIfAbsent" + } + return insertInvocation.arguments[4] as String + } +} diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt index 6af3400..d4fb832 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RouteMatchingServiceTest.kt @@ -288,7 +288,7 @@ class RouteMatchingServiceTest { invocation -> invocation.method.name == "insertIfAbsent" } assertEquals(1, insertInvocations.size) - assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1]) + assertEquals("REQUEST_TERMINAL_STATUS", insertInvocations.single().arguments[1]) assertEquals(request.id, insertInvocations.single().arguments[3]) assertEquals(RequestStatus.COMPLETED, request.status) } @@ -305,16 +305,16 @@ class RouteMatchingServiceTest { requestValidationService = RequestValidationService(geometryService), geometryService = geometryService, requestLifecycleService = RequestLifecycleService(geometryService), - requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService), + requestStatusOutboxService = requestStatusOutboxService(outboxEventRepository, geometryService), routeMatchingTransactionOperations = ImmediateTransactionOperations(), ) } - private fun requestCompletedOutboxService( + private fun requestStatusOutboxService( outboxEventRepository: OutboxEventRepository, geometryService: GeometryService, - ): RequestCompletedOutboxService { - return RequestCompletedOutboxService( + ): RequestStatusOutboxService { + return RequestStatusOutboxService( outboxEventRepository = outboxEventRepository, objectMapper = jacksonObjectMapper() .registerModule(JavaTimeModule()) diff --git a/services/pcp-route-processing-service/src/main/resources/application.yml b/services/pcp-route-processing-service/src/main/resources/application.yml index ee74539..0d1f387 100644 --- a/services/pcp-route-processing-service/src/main/resources/application.yml +++ b/services/pcp-route-processing-service/src/main/resources/application.yml @@ -7,13 +7,13 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} datasource: driver-class-name: org.postgresql.Driver - url: ${PCP_ROUTE_PROCESSING_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:192.168.60.201}:${PCP_POSTGRES_PORT:5432}/pcp_route_processing} + url: ${PCP_ROUTE_PROCESSING_DATASOURCE_URL:jdbc:postgresql://${PCP_POSTGRES_HOST:localhost}:${PCP_POSTGRES_PORT:5432}/pcp_route_processing} username: ${PCP_ROUTE_PROCESSING_DATASOURCE_USERNAME:postgres} password: ${PCP_ROUTE_PROCESSING_DATASOURCE_PASSWORD:password} jpa: diff --git a/services/pcp-route-processing-service/src/main/resources/test-passport-message.json b/services/pcp-route-processing-service/src/main/resources/test-passport-message.json index c0d5ef7..9f47903 100644 --- a/services/pcp-route-processing-service/src/main/resources/test-passport-message.json +++ b/services/pcp-route-processing-service/src/main/resources/test-passport-message.json @@ -98,7 +98,7 @@ ], "assets": { "schema-calibration-vh": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939", "title": "xml", "description": null, "type": "application/xml", @@ -107,7 +107,7 @@ ] }, "schema-calibration-vv": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0", "title": "xml", "description": null, "type": "application/xml", @@ -116,7 +116,7 @@ ] }, "schema-noise-vh": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c", "title": "xml", "description": null, "type": "application/xml", @@ -125,7 +125,7 @@ ] }, "schema-noise-vv": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca", "title": "xml", "description": null, "type": "application/xml", @@ -134,7 +134,7 @@ ] }, "schema-product-vh": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f", "title": "xml", "description": null, "type": "application/xml", @@ -143,7 +143,7 @@ ] }, "schema-product-vv": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee", "title": "xml", "description": null, "type": "application/xml", @@ -152,7 +152,7 @@ ] }, "safe-manifest": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53", "title": "safe", "description": null, "type": "application/xml", @@ -161,7 +161,7 @@ ] }, "vh": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648", "title": "tif", "description": null, "type": "image/tiff; application=geotiff; profile=cloud-optimized", @@ -170,7 +170,7 @@ ] }, "vv": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb", "title": "tif", "description": null, "type": "image/tiff; application=geotiff; profile=cloud-optimized", @@ -179,7 +179,7 @@ ] }, "kml": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54", "title": "kml", "description": null, "type": "application/kml", @@ -188,7 +188,7 @@ ] }, "thumbnail": { - "href": "http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0", + "href": "http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0", "title": "png", "description": null, "type": "image/png", diff --git a/services/pcp-route-processing-service/src/test/resources/passport_example.json b/services/pcp-route-processing-service/src/test/resources/passport_example.json index 0f26d6c..35b0209 100644 --- a/services/pcp-route-processing-service/src/test/resources/passport_example.json +++ b/services/pcp-route-processing-service/src/test/resources/passport_example.json @@ -1 +1 @@ -{"type":"Feature","stac_version":"1.0.0","stac_extensions":null,"id":"S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE","collection":"S1A-GRDH","geometry":"POLYGON((67.675827 38.114815, 64.779434 38.514816, 65.094017 40.015545, 68.05294 39.616795, 67.675827 38.114815))","bbox":[64.779434,40.015545,68.05294,38.114815],"properties":{"datetime":"2026-03-31T12:08:30.570573Z","created":null,"updated":"2026-03-31T12:08:30.610660Z","platform":"sentinel-1a","instruments":null,"processing:level":"GRD","route:orbit_number":"59796","missionId":"S1A","sat:orbit_state":"Descending","polarisation":["VH","VV"],"rollAngle":[30.07864486915724,30.09378936608302],"visirAngle":[30.63331560676386,46.07086702096005],"intervalBegin":"2025-06-25T01:38:54.481190","intervalEnd":"2025-06-25T01:39:19.479675","resolutionRange":"1.000000e+01","resolutionAzimuth":"1.000000e+01"},"links":[{"rel":"self","type":"application/geo+json","href":"http://192.168.1.8:8085/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE"},{"rel":"parent","type":"application/json","href":"http://192.168.1.8:8085/collections/S1A-GRDH"},{"rel":"collection","type":"application/json","href":"http://192.168.1.8:8085/collections/S1A-GRDH"},{"rel":"root","type":"application/json","href":"http://192.168.1.8:8085/"}],"assets":{"schema-calibration-vh":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-calibration-vv":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-noise-vh":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-noise-vv":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-product-vh":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-product-vv":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"safe-manifest":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53","type":"application/xml","title":"safe","description":null,"roles":["metadata"]},"vh":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648","type":"image/tiff; application=geotiff; profile=cloud-optimized","title":"tif","description":null,"roles":["visual"]},"vv":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb","type":"image/tiff; application=geotiff; profile=cloud-optimized","title":"tif","description":null,"roles":["visual"]},"kml":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54","type":"application/kml","title":"kml","description":null,"roles":["metadata"]},"thumbnail":{"href":"http://192.168.1.8:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0","type":"image/png","title":"png","description":null,"roles":["thumbnail","overview"]}}} +{"type":"Feature","stac_version":"1.0.0","stac_extensions":null,"id":"S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE","collection":"S1A-GRDH","geometry":"POLYGON((67.675827 38.114815, 64.779434 38.514816, 65.094017 40.015545, 68.05294 39.616795, 67.675827 38.114815))","bbox":[64.779434,40.015545,68.05294,38.114815],"properties":{"datetime":"2026-03-31T12:08:30.570573Z","created":null,"updated":"2026-03-31T12:08:30.610660Z","platform":"sentinel-1a","instruments":null,"processing:level":"GRD","route:orbit_number":"59796","missionId":"S1A","sat:orbit_state":"Descending","polarisation":["VH","VV"],"rollAngle":[30.07864486915724,30.09378936608302],"visirAngle":[30.63331560676386,46.07086702096005],"intervalBegin":"2025-06-25T01:38:54.481190","intervalEnd":"2025-06-25T01:39:19.479675","resolutionRange":"1.000000e+01","resolutionAzimuth":"1.000000e+01"},"links":[{"rel":"self","type":"application/geo+json","href":"http://192.168.60.146:8085/collections/S1A-GRDH/items/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE"},{"rel":"parent","type":"application/json","href":"http://192.168.60.146:8085/collections/S1A-GRDH"},{"rel":"collection","type":"application/json","href":"http://192.168.60.146:8085/collections/S1A-GRDH"},{"rel":"root","type":"application/json","href":"http://192.168.60.146:8085/"}],"assets":{"schema-calibration-vh":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=26988c6a3490f71382ae9e61ff0d7b59aca32ec652993a785d24e8dd81e70939","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-calibration-vv":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/calibration-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=edd6957a0404ead07e7a31d0759db26b3a6ad33394247c177dc41a7abf5972d0","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-noise-vh":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=3efe46114ed6dfeac898963510d3ba9c8b537c803b1fa69a5a03d5b462a1665c","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-noise-vv":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/noise-s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120818Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=0ca91479ba4132dbf13c2637f97567eb29ee9ca818009eaf501072995fe784ca","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-product-vh":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=39817f0b7610c0b20a23d0fae24e7183f0d3719ba6ab2f315c320c9c6530274f","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"schema-product-vv":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001.xml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=85d28bc2bb94724b4db7df84121d4252b423fa429204e4b25e06b9d725f3aeee","type":"application/xml","title":"xml","description":null,"roles":["metadata"]},"safe-manifest":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/manifest.safe?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120819Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbb22d57d031a412e2b46273edf32cfcb7fbdae1fa1fcc74bdcaa39a5e086f53","type":"application/xml","title":"safe","description":null,"roles":["metadata"]},"vh":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vh-20250625t013854-20250625t013919-059796-076d15-002_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120824Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=dbecc9e04e976751e855f53e9a9a8db5d6365a30ac7ec116f31b0936ee375648","type":"image/tiff; application=geotiff; profile=cloud-optimized","title":"tif","description":null,"roles":["visual"]},"vv":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/s1a-iw-grd-vv-20250625t013854-20250625t013919-059796-076d15-001_cog.tif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=34cf1e5f5174b7cb5ca5d173ff699923819d5df28486436d972efa59ac9b1ddb","type":"image/tiff; application=geotiff; profile=cloud-optimized","title":"tif","description":null,"roles":["visual"]},"kml":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/map-overlay.kml?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=92f63b9f463041c4ed87b3cb508ba8c57fd2db8ea2d22026087ba57d2ef88f54","type":"application/kml","title":"kml","description":null,"roles":["metadata"]},"thumbnail":{"href":"http://192.168.60.146:9000/stac-data/S1A/S1A_IW_GRDH_1SDV_20250625T013854_20250625T013919_059796_076D15_B5C1.SAFE/quick-look.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=minioadmin%2F20260331%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260331T120829Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=19bebb1e446add1d6e0898b4329d2a3e016f59087719ce2b51dff5c487835cb0","type":"image/png","title":"png","description":null,"roles":["thumbnail","overview"]}}} diff --git a/services/pcp-satellite-catalog-service/src/main/resources/application.yaml b/services/pcp-satellite-catalog-service/src/main/resources/application.yaml index b361978..370d5bd 100644 --- a/services/pcp-satellite-catalog-service/src/main/resources/application.yaml +++ b/services/pcp-satellite-catalog-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml b/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml index 5c5e7cc..bcdb4e8 100644 --- a/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml +++ b/services/pcp-srpring-boot-admin-server/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-stations-service/src/main/resources/application.yaml b/services/pcp-stations-service/src/main/resources/application.yaml index d70b9b3..3609f1e 100644 --- a/services/pcp-stations-service/src/main/resources/application.yaml +++ b/services/pcp-stations-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs new file mode 100644 index 0000000..e5a1271 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/editor.mjs @@ -0,0 +1,60 @@ +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const p = await (await b.newContext({ viewport: { width: 1600, height: 900 } })).newPage(); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(800); + + // выбрать КА 56756 и открыть редактор + await p.getByText("NORAD 56756", { exact: false }).first().click(); + await p.waitForTimeout(500); + await p.getByRole("tab", { name: "Редактор", exact: true }).click(); + await p.waitForTimeout(700); + + // контролы редактора + const shoot = p.getByRole("button", { name: "+ Съёмка", exact: true }); + const drop = p.getByRole("button", { name: "+ Сброс", exact: true }); + (await shoot.count()) > 0 && (await drop.count()) > 0 + ? pass("editor:controls", "+ Съёмка / + Сброс присутствуют") + : fail("editor:controls"); + + // режим «Сброс» -> ЗРВ реальных станций + await drop.click(); + const rva = await p.waitForResponse((r) => /\/api\/satellites\/\d+\/rva/.test(r.url()), { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(1000); + rva && rva.status() === 200 + ? pass("editor:drop-fetches-rva", `GET rva ${rva.status()}`) + : fail("editor:drop-fetches-rva", rva ? `status ${rva.status()}` : "не вызван"); + + const panelText = (await p.locator("aside, [class*='panel']").allInnerTexts().catch(() => [])).join(" ").replace(/\n+/g, " "); + const stationNames = ["Шпицберген", "Отрадное", "Байконур", "Ханты-Мансийск"]; + const found = stationNames.filter((s) => panelText.includes(s)); + found.length >= 2 + ? pass("editor:drop-real-stations", `зоны по станциям: ${found.join(", ")}`) + : fail("editor:drop-real-stations", `найдено станций: ${JSON.stringify(found)}`); + // зоны содержат виток + время (UTC-окно) + /виток\s*\d+/i.test(panelText) && /\d{2}:\d{2}[–-]\d{2}:\d{2}/.test(panelText) + ? pass("editor:drop-zone-format", "виток + временное окно показаны") + : fail("editor:drop-zone-format", panelText.slice(0, 200)); + + consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); +} catch (e) { + fail("SCRIPT", e.message); + console.log(e.stack); +} finally { + const npass = results.filter((r) => r[0] === "PASS").length; + const nfail = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== EDITOR SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); + await b.close(); + process.exit(nfail > 0 ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs new file mode 100644 index 0000000..49fd206 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/keep-alive.mjs @@ -0,0 +1,79 @@ +// Проверка keep-alive: состояние вкладок не сбрасывается при переключении. +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); +const p = await ctx.newPage(); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); + +const tab = (name) => p.getByRole("tab", { name, exact: true }); +// только видимая (активная) вкладка — карта присутствует и в Заявках (рисование полигона) +const mapToggle = (label) => p.locator(".tgu-map-toolbar__toggles button:visible", { hasText: label }); +const isActive = async (loc) => ((await loc.getAttribute("class")) || "").includes("is-active"); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(600); + + // --- Карта: меняем UI-состояние (выключаем слой «Станции») --- + await tab("Карта").click(); + await p.waitForTimeout(500); + const stationsToggle = mapToggle("Станции"); + (await isActive(stationsToggle)) ? pass("map:stations-on-initially") : fail("map:stations-on-initially"); + await stationsToggle.click(); + await p.waitForTimeout(150); + (await isActive(stationsToggle)) === false + ? pass("map:stations-toggled-off") + : fail("map:stations-toggled-off"); + + // --- Заявки: открываем форму создания (внутреннее состояние panelMode) --- + await tab("Заявки").click(); + await p.waitForTimeout(400); + await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click(); + await p.waitForTimeout(250); + (await p.locator(".tgu-requests-form").count()) > 0 + ? pass("requests:create-form-open") + : fail("requests:create-form-open"); + + // --- уходим на Планы и возвращаемся --- + await tab("Планы").click(); + await p.waitForTimeout(300); + + // Карта: слой «Станции» по-прежнему выключен? + await tab("Карта").click(); + await p.waitForTimeout(400); + (await isActive(mapToggle("Станции"))) === false + ? pass("map:state-preserved", "слой «Станции» остался выключен") + : fail("map:state-preserved", "состояние слоёв сбросилось"); + + // Заявки: форма создания всё ещё открыта? + await tab("Заявки").click(); + await p.waitForTimeout(400); + (await p.locator(".tgu-requests-form").count()) > 0 + ? pass("requests:state-preserved", "форма создания осталась открытой") + : fail("requests:state-preserved", "форма закрылась (состояние сброшено)"); + + // Скрытые вкладки не должны размонтироваться: в DOM присутствуют сразу несколько
+ await tab("Планы").click(); + await p.waitForTimeout(200); + const mains = await p.locator(".tgu-app-shell main").count(); + mains >= 3 + ? pass("keep-alive:tabs-mounted", `
в DOM: ${mains}`) + : fail("keep-alive:tabs-mounted", `ожидалось >=3, найдено ${mains}`); + +} catch (e) { + fail("EXCEPTION", e.message); +} finally { + if (consoleErrors.length) console.log("CONSOLE_ERRORS", JSON.stringify(consoleErrors, null, 2)); + const failed = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== ${results.length - failed}/${results.length} PASS, ${failed} FAIL ===`); + await b.close(); + process.exit(failed ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs new file mode 100644 index 0000000..2652dd8 --- /dev/null +++ b/services/pcp-tgu-ops-ui/e2e-verify/requests.mjs @@ -0,0 +1,225 @@ +import { chromium } from "playwright"; + +const BASE = "http://localhost:5174/"; +const results = []; +const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); }; +const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); }; + +const b = await chromium.launch({ headless: true }); +const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } }); +const p = await ctx.newPage(); + +const api = []; +p.on("request", (r) => { if (r.url().includes("/api/")) api.push({ m: r.method(), u: r.url() }); }); +const apiResp = []; +p.on("response", (r) => { if (r.url().includes("/api/")) apiResp.push({ m: r.request().method(), u: r.url(), s: r.status() }); }); +const consoleErrors = []; +p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message)); +// авто-принятие window.confirm (для удаления) +p.on("dialog", (d) => d.accept()); + +const tab = (name) => p.getByRole("tab", { name, exact: true }); + +try { + await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + await p.waitForTimeout(800); + + // ---- A: 4 вкладки ---- + for (const t of ["Планы", "Карта", "Редактор", "Заявки"]) { + (await tab(t).count()) > 0 ? pass(`tab:${t}`) : fail(`tab:${t}`, "не найдена"); + } + + // ---- B: Карта — дефолтные слои ---- + await tab("Карта").click(); + await p.waitForTimeout(600); + const toggles = p.locator(".tgu-map-toolbar__toggles button"); + const n = await toggles.count(); + const labels = []; + for (let i = 0; i < n; i++) { + const el = toggles.nth(i); + labels.push({ t: (await el.innerText()).trim(), active: (await el.getAttribute("class") || "").includes("is-active") }); + } + console.log("LAYER_TOGGLES", JSON.stringify(labels)); + const wanted = ["Работы плана", "Станции", "Заявки на съёмку"]; + const got = labels.map((l) => l.t); + JSON.stringify(got) === JSON.stringify(wanted) + ? pass("map:layers-picker", "ровно planWorks/stations/requests") + : fail("map:layers-picker", `got=${JSON.stringify(got)}`); + labels.every((l) => l.active) + ? pass("map:layers-default-on", "все 3 активны") + : fail("map:layers-default-on", JSON.stringify(labels)); + const forbidden = got.some((t) => /трек|track|полос|swath|маркер|spacecraft/i.test(t)); + forbidden ? fail("map:no-tracks-swath-markers", "найден лишний слой") : pass("map:no-tracks-swath-markers"); + + // ---- C: Заявки — загрузка ---- + await tab("Заявки").click(); + await p.waitForTimeout(400); + const before = apiResp.length; + await p.getByRole("button", { name: "Загрузить", exact: true }).click(); + await p.waitForResponse((r) => r.url().includes("/v1/requests/map"), { timeout: 15000 }).catch(() => {}); + await p.waitForTimeout(800); + const mapCall = apiResp.find((r) => r.u.includes("/v1/requests/map")); + mapCall && mapCall.s === 200 ? pass("requests:load", `GET map ${mapCall.s}`) : fail("requests:load", JSON.stringify(mapCall)); + const subtitle = await p.locator(".tgu-map-toolbar__subtitle").first().innerText().catch(() => ""); + console.log("REQUESTS_SUBTITLE", subtitle); + + // ---- D: создание + валидации ---- + await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click(); + await p.waitForTimeout(300); + const form = p.locator(".tgu-requests-form"); + const submitBtn = form.getByRole("button", { name: "Создать заявку", exact: true }); + const alert = form.locator(".tgu-alert--danger"); + + // d1: до рисования — кнопка disabled, подсказка про полигон + (await submitBtn.isDisabled()) ? pass("create:disabled-before-draw") : fail("create:disabled-before-draw"); + console.log("ALERT_INITIAL", await alert.innerText().catch(() => "(нет)")); + + // d2: рисуем полигон 3 кликами по карте + // :visible — keep-alive держит смонтированными карты сразу нескольких вкладок + const map = p.locator(".tgu-2d-map:visible"); + const box = await map.boundingBox(); + const pts = [ + { x: box.width * 0.45, y: box.height * 0.40 }, + { x: box.width * 0.60, y: box.height * 0.40 }, + { x: box.width * 0.52, y: box.height * 0.55 } + ]; + for (const pt of pts) { await map.click({ position: pt }); await p.waitForTimeout(150); } + const vtext = await form.locator(".tgu-requests-form__geo-status").innerText().catch(() => ""); + console.log("VERTEX_TEXT", vtext); + /Вершин:\s*3/.test(vtext) ? pass("create:3-vertices") : fail("create:3-vertices", vtext); + await form.getByRole("button", { name: "Завершить", exact: true }).click(); + await p.waitForTimeout(300); + + // d3: имя пустое -> валидация + let a = await alert.innerText().catch(() => ""); + /название/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:empty-name", a) : fail("validate:empty-name", a); + + // заполняем имя + await form.getByPlaceholder("Название задания").fill("E2E-VERIFY"); + await p.waitForTimeout(200); + + // d4: оба блока выключены + const opticsCb = form.locator("label.tgu-requests-form__block-toggle", { hasText: "Оптическая" }).getByRole("checkbox"); + await opticsCb.uncheck(); + await p.waitForTimeout(200); + a = await alert.innerText().catch(() => ""); + /хотя бы один блок/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:no-optics-no-rsa", a) : fail("validate:no-optics-no-rsa", a); + await opticsCb.check(); + await p.waitForTimeout(200); + + // d5: from >= to + const begin = form.locator('input[type="datetime-local"]').nth(0); + const end = form.locator('input[type="datetime-local"]').nth(1); + const beginVal = await begin.inputValue(); + await end.fill("2000-01-01T00:00"); + await p.waitForTimeout(200); + a = await alert.innerText().catch(() => ""); + /раньше конца/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:from-after-to", a) : fail("validate:from-after-to", a); + // восстановить корректный конец + await end.fill("2100-01-01T00:00"); + await p.waitForTimeout(200); + + // d6: submit -> POST 201 + новая заявка + (await submitBtn.isEnabled()) ? pass("create:enabled-when-valid") : fail("create:enabled-when-valid"); + const postBefore = apiResp.length; + await submitBtn.click(); + const postResp = await p.waitForResponse((r) => r.url().includes("/v1/requests") && r.request().method() === "POST", { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(1000); + if (postResp && (postResp.status() === 201 || postResp.status() === 200)) { + pass("create:POST", `status ${postResp.status()}`); + // проверим тело запроса: WKT lon lat + id + const reqBody = postResp.request().postDataJSON?.() || JSON.parse(postResp.request().postData() || "{}"); + const wktOk = /POLYGON\s*\(\(/.test(reqBody.geometry || ""); + const idOk = /^[0-9a-f-]{36}$/i.test(reqBody.id || ""); + wktOk && idOk ? pass("create:payload", `wkt+uuid ok`) : fail("create:payload", JSON.stringify({ id: reqBody.id, geo: (reqBody.geometry||"").slice(0,30) })); + console.log("CREATE_WKT", (reqBody.geometry || "").slice(0, 80)); + } else { + fail("create:POST", postResp ? `status ${postResp.status()}` : "нет ответа"); + } + const noticeTxt = await p.locator(".tgu-alert--success").innerText().catch(() => ""); + /создана/i.test(noticeTxt) ? pass("create:notice", noticeTxt) : fail("create:notice", noticeTxt || "(нет)"); + + // ---- E: детали + удаление ---- + // клик по нарисованному полигону -> панель деталей. Свипаем точки внутри треугольника, + // т.к. точное соответствие пиксель↔гео зависит от проекции. + const delBtn = p.getByRole("button", { name: /Удалить заявку/, exact: false }); + const rows = p.locator(".tgu-map-request-row"); + const detailsHeading = p.locator("aside .tgu-panel-heading span", { hasText: "Заявки в точке" }); + const tip = p.locator(".tgu-2d-map__tooltip"); + // hover-свип: ищем пиксель, где появляется тултип нашей заявки (надёжно к проекции/виду) + const mbox = await map.boundingBox(); + let target = null; + outer: + for (let gx = 0.40; gx <= 0.65; gx += 0.02) { + for (let gy = 0.35; gy <= 0.58; gy += 0.02) { + await p.mouse.move(mbox.x + mbox.width * gx, mbox.y + mbox.height * gy); + await p.waitForTimeout(25); + if ((await tip.count()) > 0 && /E2E-VERIFY/.test(await tip.innerText().catch(() => ""))) { + target = { x: mbox.width * gx, y: mbox.height * gy }; + break outer; + } + } + } + let opened = false; + if (target) { + await map.click({ position: target }); + await p.waitForTimeout(400); + if ((await delBtn.count()) > 0 || (await detailsHeading.count()) > 0) opened = true; + } + if (opened) { + // если в точке несколько заявок — выбрать строку, чтобы появилась кнопка удаления + if ((await delBtn.count()) === 0 && (await rows.count()) > 0) { + await rows.first().click(); + await p.waitForTimeout(300); + } + pass("details:open", `панель деталей открылась (строк в точке: ${await rows.count() || 1})`); + + const initialDel = p.getByRole("button", { name: "Удалить заявку", exact: true }); + const confirmTitle = p.locator(".tgu-requests-confirm__title", { hasText: "Удалить заявку?" }); + const yesBtn = p.getByRole("button", { name: "Да, удалить", exact: true }); + const noBtn = p.getByRole("button", { name: "Нет, не удалять", exact: true }); + + // шаг 1: клик «Удалить заявку» -> появляется панель подтверждения с Да/Нет + await initialDel.first().click(); + await p.waitForTimeout(250); + (await confirmTitle.count()) > 0 && (await yesBtn.count()) > 0 && (await noBtn.count()) > 0 + ? pass("delete:confirm-panel", "панель «Удалить заявку?» с «Да, удалить»/«Нет, не удалять»") + : fail("delete:confirm-panel", `title=${await confirmTitle.count()} yes=${await yesBtn.count()} no=${await noBtn.count()}`); + + // «Нет, не удалять» — не шлёт DELETE и закрывает подтверждение + const delCallsBefore = apiResp.filter((r) => r.m === "DELETE").length; + await noBtn.click(); + await p.waitForTimeout(300); + (await confirmTitle.count()) === 0 && apiResp.filter((r) => r.m === "DELETE").length === delCallsBefore + ? pass("delete:cancel", "«Нет, не удалять» отменяет без DELETE") + : fail("delete:cancel"); + + // «Да, удалить» -> DELETE + await initialDel.first().click(); + await p.waitForTimeout(200); + await yesBtn.click(); + const delResp = await p.waitForResponse((r) => r.url().includes("/v1/requests/") && r.request().method() === "DELETE", { timeout: 15000 }).catch(() => null); + await p.waitForTimeout(800); + delResp && (delResp.status() === 200 || delResp.status() === 204) + ? pass("delete:DELETE", `status ${delResp.status()}`) + : fail("delete:DELETE", delResp ? `status ${delResp.status()}` : "нет DELETE-запроса"); + } else { + fail("details:open", "панель деталей не открылась (не попал по полигону)"); + } + + // ---- console errors ---- + consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8))); + +} catch (e) { + fail("SCRIPT", e.message); + console.log(e.stack); +} finally { + console.log("\n=== API CALLS (последние 25) ==="); + for (const r of apiResp.slice(-25)) console.log(r.s, r.m, r.u.replace("http://localhost:5174", "")); + const npass = results.filter((r) => r[0] === "PASS").length; + const nfail = results.filter((r) => r[0] === "FAIL").length; + console.log(`\n=== SUMMARY: ${npass} PASS / ${nfail} FAIL ===`); + await b.close(); + process.exit(nfail > 0 ? 1 : 0); +} diff --git a/services/pcp-tgu-ops-ui/package-lock.json b/services/pcp-tgu-ops-ui/package-lock.json index 62a92dc..01facb3 100644 --- a/services/pcp-tgu-ops-ui/package-lock.json +++ b/services/pcp-tgu-ops-ui/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "@vitejs/plugin-react": "^5.1.1", + "d3-geo": "^3.1.1", "react": "^19.2.0", "react-dom": "^19.2.0", + "topojson-client": "^3.1.0", "typescript": "^5.9.3", "vite": "^7.2.4" }, @@ -18,6 +20,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "jsdom": "^27.2.0", + "playwright": "^1.60.0", "vitest": "^4.0.14" } }, @@ -1512,6 +1515,11 @@ "node": ">=18" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1560,6 +1568,28 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", @@ -1768,6 +1798,14 @@ "node": ">= 14" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1937,6 +1975,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2166,6 +2248,19 @@ "integrity": "sha512-sc2nGvGbixlJRHwTh/qQdPXTxJU1UDJboGPQm4d/01YUJ9r/u6aeIulQvEaxUlvKDN7hb1qCLjax+jhVAPLa/g==", "dev": true }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/services/pcp-tgu-ops-ui/package.json b/services/pcp-tgu-ops-ui/package.json index 88418f0..2f18c72 100644 --- a/services/pcp-tgu-ops-ui/package.json +++ b/services/pcp-tgu-ops-ui/package.json @@ -11,15 +11,18 @@ }, "dependencies": { "@vitejs/plugin-react": "^5.1.1", - "vite": "^7.2.4", - "typescript": "^5.9.3", + "d3-geo": "^3.1.1", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "topojson-client": "^3.1.0", + "typescript": "^5.9.3", + "vite": "^7.2.4" }, "devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "jsdom": "^27.2.0", + "playwright": "^1.60.0", "vitest": "^4.0.14" } } diff --git a/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts b/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts new file mode 100644 index 0000000..d9cf52e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/ballisticsApi.ts @@ -0,0 +1,176 @@ +import { TguApiError } from "../model/tguTypes"; +import { toLocalIso } from "../utils/localTime"; + +const BASE = "/api/pcp-ballistics"; + +export type PointVisibilityParam = { + /** NORAD-идентификатор КА. */ + noradId: number; + /** Идентификатор объекта (цели) из запроса. */ + objectId: string; + /** Момент видимости (локальное время, ISO без зоны). */ + time: string; + /** Номер витка. */ + revolution: number; + /** Крен (gamma), град. */ + gamma: number; + /** Угол Солнца, град. */ + sunAngle: number; + /** Наклонная дальность. */ + range: number; + /** Направление витка. */ + revSign: "ASC" | "DESC"; +}; + +export type PointVisibilityFilters = { + /** Минимальный угол Солнца, град. */ + sunAngleMin?: number; + /** Минимальный крен, град. */ + rollMin?: number; + /** Максимальный крен, град. */ + rollMax?: number; +}; + +export type GeoPointTarget = { + id?: string; + lat: number; + lon: number; + height?: number; +}; + +/** + * Параметры видимости точки на заданном интервале времени из pcp-ballistics-service + * (POST /api/obj-view/mpl-point). Возвращает по одному моменту видимости на виток. + */ +export async function fetchPointVisibility( + noradId: number, + target: GeoPointTarget, + fromMs: number, + toMs: number, + filters: PointVisibilityFilters = {} +): Promise { + const body = { + satellites: [noradId], + timeStart: toLocalDateTime(fromMs), + timeStop: toLocalDateTime(toMs), + obj: { + id: target.id ?? "", + position: { + lat: target.lat, + long: target.lon, + height: target.height ?? 0 + } + }, + sunAngleMin: filters.sunAngleMin ?? null, + rollMin: filters.rollMin ?? null, + rollMax: filters.rollMax ?? null + }; + + let response: Response; + try { + response = await fetch(`${BASE}/api/obj-view/mpl-point`, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису баллистики.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Ошибка сервиса баллистики.", "http", response.status); + } + + if (!text.trim()) { + return []; + } + + return JSON.parse(text) as PointVisibilityParam[]; +} + +/** Целеуказание (момент/угол места/азимут/дальность) границы зоны радиовидимости. */ +export type RvaTargetPosition = { + /** Момент (локальное время, ISO без зоны). */ + time: string; + /** Угол места, град. */ + elevation: number; + /** Азимут, град. */ + azimuth: number; + /** Наклонная дальность. */ + range: number; +}; + +/** Зона радиовидимости (ЗРВ) станции для КА на витке. */ +export type RvaZone = { + /** NORAD-идентификатор КА. */ + noradId: number; + /** Номер станции приёма (совпадает с number из /api/stations). */ + stationId: number; + /** Номер витка. */ + revolution: number; + /** Вход в зону. */ + onStart: RvaTargetPosition; + /** Максимум (кульминация). */ + onMaximum: RvaTargetPosition; + /** Выход из зоны. */ + onStop: RvaTargetPosition; + /** Длительность зоны, секунды. */ + duration: number; +}; + +/** + * Зоны радиовидимости (ЗРВ) для КА на заданном интервале из pcp-ballistics-service + * (GET /api/satellites/{norad}/rva). Баллистика считает зоны по реальным станциям приёма + * (каталог берёт у сервиса станций); каждая зона помечена номером станции (stationId). + */ +export async function fetchSatelliteRva( + noradId: number, + fromMs: number, + toMs: number +): Promise { + const params = new URLSearchParams({ + time_start: toLocalDateTime(fromMs), + time_stop: toLocalDateTime(toMs) + }); + const url = `${BASE}/api/satellites/${noradId}/rva?${params.toString()}`; + + let response: Response; + try { + response = await fetch(url, { headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису баллистики.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Ошибка сервиса баллистики.", "http", response.status); + } + if (!text.trim()) { + return []; + } + + return JSON.parse(text) as RvaZone[]; +} + +/** LocalDateTime без зоны (локальные настенные часы): YYYY-MM-DDTHH:mm:ss. */ +function toLocalDateTime(ms: number): string { + return toLocalIso(ms); +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts new file mode 100644 index 0000000..2f379b6 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts @@ -0,0 +1,198 @@ +import { TguApiError } from "../model/tguTypes"; +import { toLocalIso } from "../utils/localTime"; + +/** + * Расчёт комплексного плана (этап 4) напрямую в pcp-complex-mission-service через Vite-прокси + * (`/api/pcp-complex` → :7002), как и остальные сервисы — без BFF. Это тот самый snapshot, + * который затем потребляет редактор (stage 5, `calculateSurveys`). + */ +const BASE = "/api/pcp-complex"; + +/** + * Максимальная длина интервала расчёта комплексного плана — 10 суток. Дольше считать запрещено + * (расчёт по всем КА на больший горизонт слишком тяжёлый и теряет смысл для оперативного плана). + */ +export const MAX_COMPLEX_PLAN_RANGE_DAYS = 10; +export const MAX_COMPLEX_PLAN_RANGE_MS = MAX_COMPLEX_PLAN_RANGE_DAYS * 24 * 60 * 60 * 1000; + +/** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */ +export type ComplexPlanRunStatus = "CREATED" | "RUNNING" | "COMPLETED" | "FAILED"; + +export type RunComplexPlanParams = { + /** Начало интервала расчёта (эпоха-мс, локальное). */ + fromMs: number; + /** Конец интервала расчёта (эпоха-мс, локальное). */ + toMs: number; + /** NORAD-id КА, по которым считаем (все эксплуатируемые КА вкладки «Планы»). */ + satelliteIds: number[]; +}; + +/** + * Ответ синхронного расчёта комплексного плана (ComplexPlanRunResponseDTO). + * Расчёт уже завершён к моменту ответа: пишет snapshot’ы этапа 4 и возвращает их id + статистику. + */ +export type ComplexPlanRunResult = { + runId: number; + status: ComplexPlanRunStatus; + intervalStart: string; + intervalEnd: string; + satelliteIds: number[]; + durationMs: number | null; + errorMessage: string | null; + calculatedModesCount: number; + bookedModesCount: number; + complanModesCount: number; + mixedModesCount: number; + affectedSatellitesCount: number; + processedCellsCount: number; + bookedSlotLinksCount: number; + snapshotIds: number[]; + totalRequestsArea: number; + coveredArea: number; + coveredAreaPercent: number; +}; + +/** + * Краткая карточка прогона из истории расчётов (ComplexPlanRunSummaryDTO). + * Возвращается списком из GET /api/com-plan/runs, отсортированным по убыванию даты создания. + */ +export type ComplexPlanRunSummary = { + runId: number; + status: ComplexPlanRunStatus; + intervalStart: string; + intervalEnd: string; + satelliteIds: number[]; + requestedBy: string | null; + createdAt: string; + startedAt: string | null; + finishedAt: string | null; + durationMs: number | null; + errorMessage: string | null; + calculatedModesCount: number; + bookedModesCount: number; + complanModesCount: number; + mixedModesCount: number; + affectedSatellitesCount: number; + processedCellsCount: number; + bookedSlotLinksCount: number; + snapshotIds: number[]; + totalRequestsArea: number; + coveredArea: number; + coveredAreaPercent: number; +}; + +/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */ +export type ComplexPlanMode = { + id: number; + satelliteId: number; + source: string; + type: "SURVEY" | "DROP" | string; + startTime: string; + endTime: string; + revolution: number; + lat: number; + longitude: number; + duration: number; + /** Контур полосы съёмки, WKT POLYGON (lon lat, ...). Для сбросов обычно отсутствует. */ + contourWkt: string | null; + roll: number; + cellNum: number; + bookedSlotIds: number[]; + snapshotId: number | null; + /** Объём режима, МБ (скорость накопления/сброса × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; +}; + +/** + * Запуск расчёта комплексного плана на интервал по списку КА + * (pcp-complex-mission-service, POST /api/com-plan/process). Вызов синхронный: возвращает + * завершённый run со статистикой и id записанных snapshot’ов. Бэк отклоняет запуск, если по + * одному из КА уже идёт пересекающийся расчёт. + */ +export async function runComplexPlan(params: RunComplexPlanParams): Promise { + const body = { + intervalStart: toLocalIso(params.fromMs), + intervalEnd: toLocalIso(params.toMs), + satelliteIds: params.satelliteIds + }; + + const url = `${BASE}/api/com-plan/process`; + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json", "X-Requested-By": "pcp-tgu-ops-ui" }, + body: JSON.stringify(body) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось рассчитать комплексный план.", "http", response.status); + } + if (!text.trim()) { + throw new TguApiError("Сервис комплексного плана вернул пустой ответ.", "http", response.status); + } + + return JSON.parse(text) as ComplexPlanRunResult; +} + +/** + * История прогонов комплексного плана (pcp-complex-mission-service, GET /api/com-plan/runs). + * Список отсортирован бэком по убыванию даты создания, поэтому первый завершённый элемент — + * это последний построенный комплексный план. + */ +export async function fetchComplexPlanRuns(): Promise { + const url = `${BASE}/api/com-plan/runs`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось загрузить историю расчётов.", "http", response.status); + } + if (!text.trim()) return []; + return JSON.parse(text) as ComplexPlanRunSummary[]; +} + +/** + * Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте + * (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes). + */ +export async function fetchComplexPlanRunModes(runId: number): Promise { + const url = `${BASE}/api/com-plan/runs/${encodeURIComponent(runId)}/modes`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось загрузить результат расчёта.", "http", response.status); + } + if (!text.trim()) return []; + return JSON.parse(text) as ComplexPlanMode[]; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts new file mode 100644 index 0000000..368632c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts @@ -0,0 +1,339 @@ +import { TguApiError } from "../model/tguTypes"; + +const BASE = "/api/pcp-mission"; + +/** Способ построения окна маршрута относительно момента видимости. */ +export type SurveyRouteAnchor = "CENTERED" | "FORWARD"; + +export type BuildSurveyRouteParams = { + /** Идентификатор КА (NORAD — тот же, что в obj-view/mpl-point). */ + satelliteId: number; + /** Момент видимости (локальное время, ISO без зоны). */ + time: string; + /** Длительность съёмки, секунды. */ + durationSeconds: number; + /** Крен (gamma), градусы. */ + roll: number; + /** Угол захвата (полуширина полосы), градусы. */ + captureAngle: number; + /** Номер витка (0 — определить по орбитальным точкам). */ + revolution?: number; + /** Способ построения окна относительно момента видимости. */ + anchor?: SurveyRouteAnchor; + /** + * Начало интервала плана (локальное время, ISO без зоны). Передаётся при сохранении, + * чтобы при автосоздании миссии/плана задать корректное окно (для пересечений/наложений). + */ + planStart?: string; + /** Конец интервала плана (локальное время, ISO без зоны). См. planStart. */ + planEnd?: string; +}; + +export type SurveyRoute = { + satelliteId: number; + /** Начало съёмки (локальное время, ISO без зоны). */ + timeStart: string; + /** Длительность съёмки, секунды. */ + duration: number; + revolution: number; + /** Крен (gamma), градусы. */ + roll: number; + /** Угол захвата, градусы. */ + captureAngle: number; + /** Широта центра контура, градусы. */ + lat: number; + /** Долгота центра контура, градусы. */ + longitude: number; + /** Контур полосы съёмки, WKT POLYGON (lon lat, ...). */ + contourWkt: string; + /** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; + /** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */ + surveyModeId?: number; +}; + +/** + * Построение одного маршрута съёмки по параметрам видимости точки без сохранения + * (pcp-mission-planing-service, POST /api/missions/survey-route/build). Live-превью. + */ +export async function buildSurveyRoute(params: BuildSurveyRouteParams): Promise { + return postSurveyRoute(`${BASE}/api/missions/survey-route/build`, params); +} + +/** + * Построение и сохранение маршрута съёмки в БД по идентификатору выбранного плана + * (pcp-mission-planing-service, POST /api/missions/{planId}/survey-route). + * planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). + */ +export async function saveSurveyRoute(planId: string, params: BuildSurveyRouteParams): Promise { + return postSurveyRoute(`${BASE}/api/missions/${encodeURIComponent(planId)}/survey-route`, params); +} + +export type SaveDropRouteParams = { + /** Идентификатор КА (NORAD). */ + satelliteId: number; + /** Идентификатор/номер станции приёма (из ЗРВ). */ + station: string; + /** Номер витка зоны радиовидимости. */ + revolution: number; + /** Начало зоны (локальное время, ISO без зоны). */ + zoneStart: string; + /** Конец зоны (локальное время, ISO без зоны). */ + zoneStop: string; + /** Идентификаторы режимов съёмки (survey_modes), сбрасываемых в этой зоне. */ + surveyModeIds: number[]; + /** Начало интервала плана (для автосоздания миссии/плана). */ + planStart?: string; + /** Конец интервала плана. */ + planEnd?: string; +}; + +/** Сохранённый режим сброса (ответ /drop-route). */ +export type DropMode = { + id: number; + planId: number; + timeStart: string | null; + revolution: number; + type: "DROP"; + station: string | null; + duration: number; + surveys: number[]; + /** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; +}; + +/** + * Ручное добавление сброса по выбранной зоне радиовидимости + * (pcp-mission-planing-service, POST /api/missions/{planId}/drop-route). + * planId — идентификатор выбранного плана в pcp-tgu-service (= mission_id). + */ +export async function saveDropRoute(planId: string, params: SaveDropRouteParams): Promise { + const body = { + satelliteId: params.satelliteId, + station: params.station, + revolution: params.revolution, + zoneStart: params.zoneStart, + zoneStop: params.zoneStop, + surveyModeIds: params.surveyModeIds, + ...(params.planStart ? { planStart: params.planStart } : {}), + ...(params.planEnd ? { planEnd: params.planEnd } : {}) + }; + + const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/drop-route`; + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Ошибка сохранения сброса.", "http", response.status); + } + if (!text.trim()) { + throw new TguApiError("Сервис планирования миссий вернул пустой ответ.", "http", response.status); + } + + return JSON.parse(text) as DropMode; +} + +/** Итог расчёта плана съёмки (ответ /surveys/calculate). Времена — локальные. */ +export type SurveyCalculationResult = { + missionId: string; + completed: boolean; + comPlanSnapshotId: number | null; + surveyModesCount: number; + manualModesCount: number; + complanModesCount: number; + slotsModesCount: number; + mixedModesCount: number; + bookedSlotIdsCount: number; +}; + +/** + * Запуск расчёта плана съёмки для плана-черновика + * (pcp-mission-planing-service, POST /api/missions/{planId}/surveys/calculate). + * Источник комплексного плана (snapshot этапа 4) бэкенд выбирает сам по интервалу плана. + * Пересчёт сохраняет ручные маршруты (source=MANUAL), затирая только пересчитываемые. + */ +export async function calculateSurveys(planId: string): Promise { + const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/surveys/calculate`; + let response: Response; + try { + response = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось рассчитать съёмку.", "http", response.status); + } + return JSON.parse(text) as SurveyCalculationResult; +} + +/** + * Запуск расчёта плана сбросов для плана-черновика по уже рассчитанным/добавленным съёмкам + * (pcp-mission-planing-service, POST /api/missions/{planId}/drops/calculate). Возвращает 204. + */ +export async function calculateDrops(planId: string): Promise { + const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/drops/calculate`; + let response: Response; + try { + response = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Не удалось рассчитать сбросы.", "http", response.status); + } +} + +/** + * Очистка плана-черновика (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes). + * Сбросы удаляются всегда; `keepManual=true` сохраняет ручные маршруты съёмки (source=MANUAL), + * `keepManual=false` (по умолчанию) удаляет все съёмки, включая ручные. Возвращает 204. + */ +export async function clearPlanModes(planId: string, keepManual = false): Promise { + const query = keepManual ? "?keepManual=true" : ""; + const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes${query}`; + let response: Response; + try { + response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Не удалось очистить план.", "http", response.status); + } +} + +/** + * Перестроение контура существующего режима съёмки по новым параметрам окна/крена + * (pcp-mission-planing-service, PATCH /api/missions/survey-modes/{modeId}). Правка включения + * в плане-черновике: контур пересчитывается тем же алгоритмом, что и /survey-route/build. + */ +export async function updateSurveyRoute(modeId: number, params: BuildSurveyRouteParams): Promise { + return postSurveyRoute(`${BASE}/api/missions/survey-modes/${encodeURIComponent(modeId)}`, params, "PATCH"); +} + +/** + * Удаление включения (съёмки или сброса) из плана-черновика + * (pcp-mission-planing-service, DELETE /api/missions/modes/{modeId}). Для съёмки бэк снимает + * брони слотов и связи со сбросами, осиротевший сброс удаляется; для сброса — только его связи. + */ +export async function deleteMode(modeId: number): Promise { + const url = `${BASE}/api/missions/modes/${encodeURIComponent(modeId)}`; + let response: Response; + try { + response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Ошибка удаления включения.", "http", response.status); + } +} + +/** Уровень конфликта валидации: error блокирует «Применить», warning — информативный. */ +export type ConflictSeverity = "error" | "warning"; + +/** Один конфликт на затронутом включении (дифф по режимам). */ +export type ModeConflict = { + /** id режима (survey или drop), к которому относится конфликт. */ + modeId: number; + /** Код правила (TIME_OVERLAP / TARGET_VISIBILITY / COMMS_SESSION / DROP_ORDER / ONBOARD_LIMIT). */ + rule: string; + severity: ConflictSeverity; + message: string; + /** id второго участника парного правила (например, пересечения по времени), если есть. */ + relatedModeId?: number | null; +}; + +export type MissionValidation = { + missionId: string; + conflicts: ModeConflict[]; +}; + +/** + * Валидация миссии-черновика на конфликты/наложения включений + * (pcp-mission-planing-service, POST /api/missions/{id}/validate). MVP: бэк-заглушка возвращает + * корректный контракт с пустым списком конфликтов; контракт стабилен для UI. + */ +export async function validateMission(missionId: string): Promise { + const url = `${BASE}/api/missions/${encodeURIComponent(missionId)}/validate`; + let response: Response; + try { + response = await fetch(url, { method: "POST", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Ошибка валидации плана.", "http", response.status); + } + return JSON.parse(text) as MissionValidation; +} + +async function postSurveyRoute( + url: string, + params: BuildSurveyRouteParams, + method: "POST" | "PATCH" = "POST" +): Promise { + const body = { + satelliteId: params.satelliteId, + time: params.time, + durationSeconds: params.durationSeconds, + roll: params.roll, + captureAngle: params.captureAngle, + revolution: params.revolution ?? 0, + anchor: params.anchor ?? "CENTERED", + ...(params.planStart ? { planStart: params.planStart } : {}), + ...(params.planEnd ? { planEnd: params.planEnd } : {}) + }; + + let response: Response; + try { + response = await fetch(url, { + method, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования миссий.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Ошибка построения маршрута съёмки.", "http", response.status); + } + if (!text.trim()) { + throw new TguApiError("Сервис планирования миссий вернул пустой ответ.", "http", response.status); + } + + return JSON.parse(text) as SurveyRoute; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/api/requestApi.ts b/services/pcp-tgu-ops-ui/src/api/requestApi.ts index c864f01..7d3e8d4 100644 --- a/services/pcp-tgu-ops-ui/src/api/requestApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/requestApi.ts @@ -1,4 +1,4 @@ -import type { RequestFilter, RequestMapItem } from "../model/requestTypes"; +import type { CreateRequestPayload, CreateRequestResult, RequestFilter, RequestMapItem } from "../model/requestTypes"; const BASE = "/api/pcp-request/v1"; const PAGE_SIZE = 500; @@ -60,3 +60,43 @@ export async function fetchRequestsForMap( truncated: first.totalPages > MAX_PAGES }; } + +type ErrorDetail = { field?: string; message?: string }; +type ErrorBody = { code?: string; message?: string; details?: ErrorDetail[] }; + +async function readErrorMessage(response: Response, fallback: string): Promise { + try { + const body = (await response.json()) as ErrorBody; + const details = body.details?.map((d) => (d.field ? `${d.field}: ${d.message}` : d.message)).filter(Boolean); + const detailText = details && details.length > 0 ? ` (${details.join("; ")})` : ""; + if (body.message) return `${body.message}${detailText}`; + } catch { + // тело ответа не JSON — используем fallback + } + return `${fallback}: ${response.status}`; +} + +export async function createRequest(payload: CreateRequestPayload): Promise { + const response = await fetch(`${BASE}/requests`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Не удалось создать заявку")); + } + + return response.json() as Promise; +} + +export async function deleteRequest(id: string): Promise { + const response = await fetch(`${BASE}/requests/${encodeURIComponent(id)}`, { + method: "DELETE", + headers: { Accept: "application/json" } + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, "Не удалось удалить заявку")); + } +} diff --git a/services/pcp-tgu-ops-ui/src/api/stationsApi.ts b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts new file mode 100644 index 0000000..aede019 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/stationsApi.ts @@ -0,0 +1,18 @@ +export type StationDto = { + id: string | null; + number: number; + name: string; + position: { lat: number; long: number; height: number }; + elevationMin: number; + elevationMax: number; +}; + +export async function fetchStations(): Promise { + const response = await fetch("/api/stations", { + headers: { Accept: "application/json" } + }); + if (!response.ok) { + throw new Error(`Ошибка загрузки станций: ${response.status}`); + } + return response.json() as Promise; +} diff --git a/services/pcp-tgu-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts index 149ad5e..61384b8 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguApi.ts @@ -5,6 +5,7 @@ import { type TguPlanDecisionMessage, type TguPlatform } from "../model/tguTypes"; +import { toLocalIso } from "../utils/localTime"; export type FlightLinePoint = { time: string; @@ -27,10 +28,9 @@ export async function fetchFlightLine( fromMs: number, toMs: number ): Promise { - const toLocalDT = (ms: number) => new Date(ms).toISOString().slice(0, 19); const query = new URLSearchParams({ - time_start: toLocalDT(fromMs), - time_stop: toLocalDT(toMs) + time_start: toLocalIso(fromMs), + time_stop: toLocalIso(toMs) }); return fetchJson( `/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}` @@ -67,6 +67,36 @@ export async function sendPlanDecision( ); } +/** + * «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить + * индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый. + */ +export async function dismissPlan(planId: string): Promise { + await fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, + { method: "POST" } + ); +} + +/** «Вернуть на учёт» ранее снятый план. */ +export async function restorePlan(planId: string): Promise { + await fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, + { method: "POST" } + ); +} + +/** + * ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ. + * decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»). + */ +export async function confirmPlanLayin(planId: string): Promise { + await fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, + { method: "POST" } + ); +} + async function fetchJson(url: string, init?: RequestInit, decisionRequest = false): Promise { let response: Response; try { diff --git a/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts new file mode 100644 index 0000000..cf9abd4 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts @@ -0,0 +1,230 @@ +import { TguApiError } from "../model/tguTypes"; + +/** + * Вызовы версионирования/выдачи плана в pcp-tgu-service напрямую через Vite-прокси + * (`/api/pcp-tgu` → :7011), как и pcp-mission напрямую на :7010. BFF не используется. + */ +const BASE = "/api/pcp-tgu"; + +/** Режим наполнения черновика; та же строка уходит в clone миссии. */ +export type DraftMode = "replace" | "fork-forward"; + +export type CreateDraftRequest = { + mode: DraftMode; + /** Начало окна целевой ЗРВ (naive, ISO без зоны, локальное). Обязательно для fork-forward. */ + windowStart?: string; + /** Конец окна целевой ЗРВ. Обязательно для fork-forward. */ + windowStop?: string; + /** КПП целевой ЗРВ. Обязательно для fork-forward. */ + windowKppId?: string; +}; + +export type CreateDraftResponse = { + /** Идентификатор созданного черновика (= mission_id клон-миссии). */ + draftPlanId: string; +}; + +export type PromoteDraftResponse = { + processInstanceKey: number; + processDefinitionKey: number; + bpmnProcessId: string; + version: number; + planId: string; + attemptId: string; +}; + +/** План КА из проекции pcp-tgu-service (persisted ∪ будущая проекция). Времена — ISO с `Z`. */ +export type TguPlanResponse = { + planId: string; + spacecraftId: string; + startTime: string; + endTime: string; + kppId: string; + status: string; +}; + +/** «Сырое» окно ЗРВ КА (граница реальной зоны) из pcp-tgu-service. Времена — ISO с `Z`. */ +export type ZrvWindowResponse = { + startTime: string; + endTime: string; + kppId: string; +}; + +/** + * Запрос создания плана новой цепочки (вкладка «Планы»). Окно набирается из двух ЗРВ: + * `windowStart`/`windowKppId` — старт выбранной стартовой зоны, `windowStop` — конец конечной. + * Времена — naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка. `basePlanId` — предыстория + * (история тянется) либо `null` (цепочка без истории, пустой план). + */ +export type CreateChainPlanRequest = { + spacecraftId: string; + basePlanId: string | null; + windowStart: string; + windowStop: string; + windowKppId: string; +}; + +export type CreateChainPlanResponse = { + draftPlanId: string; + /** Прогноз будущей цепочки от созданного плана (what-if, состояние КА не тронуто). */ + projectedChain: TguPlanResponse[]; +}; + +/** + * Цепочка планов одного КА из pcp-tgu-service (GET /api/plans/{spacecraftId}): persisted планы плюс + * будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет + * выбранное окно ровно с этой же проекцией (assertWindowMatchesProjection). + */ +export async function fetchSpacecraftPlans(spacecraftId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось получить планы КА.", "http", response.status); + } + return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : []; +} + +/** + * «Сырые» ЗРВ КА (границы реальных зон) из pcp-tgu-service (GET /api/plans/{spacecraftId}/zrv). + * Из них оператор набирает окно нового плана и видит доступные зоны на таймлайне. + */ +export async function fetchZrvWindows(spacecraftId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}/zrv`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось получить ЗРВ КА.", "http", response.status); + } + return text.trim() ? (JSON.parse(text) as ZrvWindowResponse[]) : []; +} + +/** + * Черновики (DRAFT) из pcp-tgu-service (GET /api/plans/drafts), опц. по одному КА. Основной + * read-path планов их отсекает, поэтому режим «Черновики» во вкладке «Планы» читает их отдельно. + */ +export async function fetchDraftPlans(spacecraftId?: string): Promise { + const url = spacecraftId + ? `${BASE}/api/plans/drafts?spacecraftId=${encodeURIComponent(spacecraftId)}` + : `${BASE}/api/plans/drafts`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось получить черновики.", "http", response.status); + } + return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : []; +} + +/** + * Создаёт план новой цепочки (pcp-tgu-service, POST /api/plans/new-chain). Возвращает план-кандидат + * (DRAFT) и прогноз будущей цепочки от него. Кандидат становится рабочим после ручной отправки + * (promote) и приёмки. 404 — базы нет; 422 — окно не из ЗРВ / база чужого КА / нечисловой КА. + */ +export async function createChainPlan(request: CreateChainPlanRequest): Promise { + return postJson( + `${BASE}/api/plans/new-chain`, + request, + "Не удалось создать план новой цепочки." + ); +} + +/** + * Создаёт черновик плана (DRAFT) на основе базового плана + * (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает + * draftPlanId, в который адресуются правки (Ф3) и который применяется promote (Ф5). + */ +export async function createDraft(basePlanId: string, request: CreateDraftRequest): Promise { + return postJson( + `${BASE}/api/plans/${encodeURIComponent(basePlanId)}/draft`, + request, + "Не удалось создать черновик плана." + ); +} + +/** + * «Применить» черновик: промоутит DRAFT в живую цепочку и выдаёт его + * (pcp-tgu-service, POST /api/plans/{draftPlanId}/promote). Под оптимистичным локом базы и + * гардом точки невозврата: 409 — база уехала / уже идёт выдача; 422 — не DRAFT / слот сместился / + * заморожено; 404 — черновика нет. + */ +export async function promoteDraft(draftPlanId: string): Promise { + return postJson( + `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}/promote`, + undefined, + "Не удалось применить черновик плана." + ); +} + +/** + * Отказ от черновика (pcp-tgu-service, DELETE /api/plans/{draftPlanId}): удаляет DRAFT и его + * клон-миссию, снимая блокировку «один открытый черновик на базу». 404 — нет; 422 — не DRAFT. + */ +export async function abandonDraft(draftPlanId: string): Promise { + const url = `${BASE}/api/plans/${encodeURIComponent(draftPlanId)}`; + let response: Response; + try { + response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + if (!response.ok) { + const text = await response.text(); + throw new TguApiError(extractErrorText(text) || "Не удалось отменить черновик плана.", "http", response.status); + } +} + +async function postJson(url: string, body: unknown, fallbackMessage: string): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: body === undefined + ? { Accept: "application/json" } + : { Accept: "application/json", "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network"); + } + + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || fallbackMessage, "http", response.status); + } + if (!text.trim()) { + return undefined as T; + } + return JSON.parse(text) as T; +} + +function extractErrorText(body: string): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const parsed = JSON.parse(trimmed) as Record; + for (const field of ["message", "error", "reason", "detail"]) { + const value = parsed[field]; + if (typeof value === "string" && value.trim()) { + return value; + } + } + } catch { + return trimmed; + } + return trimmed; +} diff --git a/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts b/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts new file mode 100644 index 0000000..c597537 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/components/timeFields.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { maskTime } from "./timeFields"; + +describe("maskTime", () => { + it("не пропускает невозможные значения", () => { + expect(maskTime("99:99", false)).toBe("09:09"); // 9>2 → 09; 9>5 → 09 + expect(maskTime("3070", false)).toBe("03:07"); // 3>2 → 03; 7>5 → 07 + expect(maskTime("2599", false)).toBe("23:09"); // 25→23 (зажим), 9>5 → 09 + }); + + it("расставляет двоеточия и ограничивает длину", () => { + expect(maskTime("1234", false)).toBe("12:34"); + expect(maskTime("12", false)).toBe("12"); + expect(maskTime("123456789", false)).toBe("12:34"); + }); + + it("поддерживает секунды", () => { + expect(maskTime("123456", true)).toBe("12:34:56"); + expect(maskTime("235959", true)).toBe("23:59:59"); + expect(maskTime("990000", true)).toBe("09:09:00"); + }); + + it("игнорирует мусор и лишние двоеточия", () => { + expect(maskTime("1a2:b3", false)).toBe("12:3"); + expect(maskTime("", false)).toBe(""); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/components/timeFields.tsx b/services/pcp-tgu-ops-ui/src/components/timeFields.tsx new file mode 100644 index 0000000..916e58c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/components/timeFields.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState, type CSSProperties } from "react"; + +/** + * Ввод времени суток в строгом 24-часовом формате `ЧЧ:ММ[:СС]` (локальное). + * + * Нативные ``/`type="datetime-local"` рендерят время по локали + * браузера/ОС (часто AM/PM) и не подчиняются атрибуту `lang`, поэтому время вводим + * обычным текстовым полем с собственной валидацией. Локальный черновик позволяет + * печатать промежуточные значения; наружу отдаём только корректное `ЧЧ:ММ[:СС]`. + */ +type Time24FieldProps = { + /** Текущее значение в формате `ЧЧ:ММ` или `ЧЧ:ММ:СС`. */ + value: string; + /** Показывать и принимать секунды. */ + withSeconds?: boolean; + /** Вызывается при вводе корректного значения. */ + onChange: (value: string) => void; + className?: string; + style?: CSSProperties; + "aria-label"?: string; +}; + +const TIME_RE_HM = /^([01]\d|2[0-3]):[0-5]\d$/; +const TIME_RE_HMS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/; + +/** + * Забирает из потока цифр один сегмент времени `00..max` (часы → 23, мин/сек → 59). + * Если первая цифра заведомо больше старшего разряда (напр. `9` для часов), сразу + * дополняет нулём (`09`) и переходит к следующему сегменту; двузначное значение + * сверх максимума зажимается. Возвращает [сегмент, остаток цифр]. + */ +function takeSegment(digits: string, max: number): [string, string] { + if (digits.length === 0) return ["", ""]; + const firstMax = Math.floor(max / 10); + if (Number(digits[0]) > firstMax) { + return [`0${digits[0]}`, digits.slice(1)]; + } + let segment = digits.slice(0, 2); + if (segment.length === 2 && Number(segment) > max) { + segment = String(max); + } + return [segment, digits.slice(2)]; +} + +/** + * Маскирует произвольный ввод в `ЧЧ:ММ[:СС]`: оставляет только цифры, расставляет + * двоеточия и зажимает каждый сегмент в допустимый диапазон, поэтому невозможные + * значения (`99:99`, `30:70`) набрать нельзя — они приводятся к корректным. + */ +export function maskTime(input: string, withSeconds: boolean): string { + const digits = input.replace(/\D/g, "").slice(0, withSeconds ? 6 : 4); + const segments: string[] = []; + let [segment, rest] = takeSegment(digits, 23); + segments.push(segment); + if (rest.length > 0) { + [segment, rest] = takeSegment(rest, 59); + segments.push(segment); + } + if (withSeconds && rest.length > 0) { + [segment, rest] = takeSegment(rest, 59); + segments.push(segment); + } + return segments.join(":"); +} + +export function Time24Field({ value, withSeconds = false, onChange, className, style, ...rest }: Time24FieldProps) { + const re = withSeconds ? TIME_RE_HMS : TIME_RE_HM; + const [draft, setDraft] = useState(value); + const [editing, setEditing] = useState(false); + + // Пока поле не редактируется — синхронизируем отображение с внешним значением. + useEffect(() => { + if (!editing) setDraft(value); + }, [value, editing]); + + return ( + setEditing(true)} + onChange={(event) => { + const next = maskTime(event.target.value, withSeconds); + setDraft(next); + if (re.test(next)) onChange(next); + }} + onBlur={() => { + setEditing(false); + if (re.test(draft)) onChange(draft); + else setDraft(value); // невалидный ввод — откат к последнему корректному + }} + /> + ); +} + +/** + * Замена нативного ``: нативный календарь для даты + * (у даты нет проблемы AM/PM) + [Time24Field] для времени в 24-часовом формате. + * Контракт значения тот же — строка `YYYY-MM-DDTHH:mm` (локальное), как у datetime-local. + */ +type DateTime24FieldProps = { + value: string; + onChange: (value: string) => void; + className?: string; +}; + +export function DateTime24Field({ value, onChange, className }: DateTime24FieldProps) { + const sep = value.indexOf("T"); + const datePart = sep >= 0 ? value.slice(0, sep) : value; + const timePart = sep >= 0 ? value.slice(sep + 1, sep + 6) : ""; + + const combine = (date: string, time: string) => `${date}T${time || "00:00"}`; + + return ( + + onChange(combine(event.target.value, timePart))} + /> + onChange(combine(datePart, time))} + /> + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx new file mode 100644 index 0000000..97701f8 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationDashboard.jsx @@ -0,0 +1,226 @@ +/* ============================================================ + ConstellationDashboard.jsx — wiring, RAF clock, memory sim (ESM) + Default export: — монтируется как вкладка. + ============================================================ */ +import React, { useState, useEffect, useRef, useMemo } from "react"; +import MapView from "./MapView.jsx"; +import Timeline from "./Timeline.jsx"; +import { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +import * as SIM from "./sim.js"; +import { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; +import "./dashboard.css"; + +function buildEnv() { + const epochMs = Date.now(); + const sats = SATELLITES, stations = STATIONS, rois = ROIS; + const prohibits = SIM.buildProhibits(sats, epochMs); + const env = { epochMs, sats, stations, rois, prohibits }; + const domain = { start: epochMs - 6 * 3600e3, end: epochMs + 18 * 3600e3 }; + const sched = SIM.buildSchedule(sats, env, domain.start, domain.end, 1); + return { env, domain, sched }; +} + +function findInterval(list, t) { + if (!list) return null; + for (const iv of list) if (t >= iv.start && t < iv.end) return iv; + return null; +} + +// Без пропсов — встроенная симуляция (buildEnv). С пропсами env/domain/sched — +// реальные данные из API (см. ConstellationTab + realData.ts, INTEGRATION.md §5). +// Обёртка монтирует компонент только когда данные готовы, поэтому cfg стабилен +// от первого рендера и mount-once замыкания RAF/apiRef остаются валидными. +function App({ env: extEnv, domain: extDomain, sched: extSched } = {}) { + const cfg = useMemo( + () => (extEnv ? { env: extEnv, domain: extDomain, sched: extSched } : buildEnv()), + [extEnv, extDomain, extSched] + ); + const { env, domain, sched } = cfg; + + const apiRef = useRef({}); + const simRef = useRef(env.epochMs); + const playingRef = useRef(true); + const speedRef = useRef(10); + const liveRef = useRef(false); + const focusRef = useRef(null); + const hoverRef = useRef(null); + const memRef = useRef(Object.fromEntries(env.sats.map((s) => [s.id, env.memAt ? env.memAt(s, env.epochMs) : s.mem0]))); + const lastT = useRef(performance.now()); + + const [, setTick] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(10); + const [live, setLive] = useState(false); + const [focusId, setFocusId] = useState(null); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(null); + const [hover, setHover] = useState(null); + + function setFocus(id) { focusRef.current = id; setFocusId(id); } + + // map api callbacks + useEffect(() => { + const a = apiRef.current; + a.simTime = () => simRef.current; + a.onCursor = (ll) => setCursor(ll); + a.onPick = (id, x, y) => { hoverRef.current = id; setHover(id ? { id, x, y } : null); }; + a.onClickPick = () => { if (hoverRef.current) setFocus(focusRef.current === hoverRef.current ? null : hoverRef.current); }; + a.onReady = () => a.draw && a.draw(simRef.current, { focusId: focusRef.current }); + }, []); + + // RAF loop + useEffect(() => { + let raf; + let acc = 0; + const loop = (now) => { + const dt = Math.min(100, now - lastT.current); + lastT.current = now; + if (playingRef.current) { + if (liveRef.current) simRef.current = Date.now(); + else simRef.current += dt * speedRef.current; + if (simRef.current > domain.end) simRef.current = domain.end; + if (simRef.current < domain.start) simRef.current = domain.start; + // memory dynamics — только когда нет авторитетного источника заполнения борта + if (!env.memAt) { + const secEq = (dt * speedRef.current) / 1000; + for (const s of env.sats) { + const stt = SIM.statusAt(s, simRef.current, env); + let m = memRef.current[s.id]; + if (stt.state === "capture") m += 0.7 * secEq; + else if (stt.state === "downlink") m -= 1.5 * secEq; + else m += 0.02 * secEq; // idle housekeeping drift + memRef.current[s.id] = Math.max(0, Math.min(100, m)); + } + } + } + // Реальное заполнение борта (объёмы маршрутов плана) — сэмплируем каждый кадр, + // чтобы значение следовало и за скрабом на паузе. + if (env.memAt) { + for (const s of env.sats) memRef.current[s.id] = env.memAt(s, simRef.current); + } + if (apiRef.current.draw) apiRef.current.draw(simRef.current, { focusId: focusRef.current }); + acc += dt; + if (acc >= 80) { acc = 0; setTick((t) => (t + 1) % 1e6); } + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + return () => cancelAnimationFrame(raf); + }, []); + + // ---- controls ---- + function onPlay() { const p = !playingRef.current; playingRef.current = p; setPlaying(p); if (!p) { liveRef.current = false; setLive(false); } } + function onSpeed(s) { speedRef.current = s; setSpeed(s); if (s !== 1) { liveRef.current = false; setLive(false); } } + function onNow() { liveRef.current = true; playingRef.current = true; speedRef.current = 1; simRef.current = Date.now(); setLive(true); setPlaying(true); setSpeed(1); } + function onStep(d) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, simRef.current + d)); } + function onScrub(t) { liveRef.current = false; setLive(false); simRef.current = Math.max(domain.start, Math.min(domain.end, t)); } + + // ---- derived (per render) ---- + const tNow = simRef.current; + const statuses = {}; + const counts = { capture: 0, downlink: 0, prohibit: 0, eclipse: 0, optic: 0, radar: 0, total: env.sats.length }; + for (const s of env.sats) { + const st = SIM.statusAt(s, tNow, env); + statuses[s.id] = st; + if (counts[st.state] !== undefined) counts[st.state]++; + if (st.eclipse) counts.eclipse++; + if (s.payload === "optic") counts.optic++; else counts.radar++; + } + const events = []; + for (const s of env.sats) { + const st = statuses[s.id]; + let iv = null, type = null, target = null, color = null; + if (st.state === "capture") { iv = findInterval(sched[s.id].capture, tNow); type = "СЪЁМКА"; target = st.roi.name; color = STATUS_META.capture.c; } + else if (st.state === "downlink") { iv = findInterval(sched[s.id].downlink, tNow); type = "СБРОС"; target = "ст. " + st.station.name; color = STATUS_META.downlink.c; } + else if (st.state === "prohibit") { iv = findInterval(sched[s.id].prohibit, tNow); type = "ЗАПРЕТ"; target = st.prohibit.reason; color = STATUS_META.prohibit.c; } + if (type) events.push({ id: s.id, satId: s.id, sat: s.name, type, target, color, remain: fmtDur((iv ? iv.end : tNow) - tNow) }); + } + events.sort((a, b) => a.remain.localeCompare(b.remain)); + + const focusSat = focusId ? env.sats.find((s) => s.id === focusId) : null; + + return ( +
+
+
+ +
+
+ +
+ {focusSat ? ( + setFocus(null)} /> + ) : ( +
КЛИК ПО АППАРАТУ — ФОКУС И ПОДСВЕТКА СОБЫТИЙ
+ )} +
+ +
+ {cursor ? `${Math.abs(cursor.lat).toFixed(3)}° ${cursor.lat >= 0 ? "с.ш." : "ю.ш."}, ${Math.abs(cursor.lon).toFixed(3)}° ${cursor.lon >= 0 ? "в.д." : "з.д."}` : "—"} +
+ {hover && hover.id !== focusId && ( +
+ {env.sats.find((s) => s.id === hover.id)?.name} +
+ )} +
+
+
+ ТАЙМЛАЙН СОБЫТИЙ +
+ + + + + +
+ {live ? "РЕЖИМ LIVE · ×1" : "СИМУЛЯЦИЯ ×" + speed} · перетащите для скраба +
+ +
+
+
+
+ ); +} + +function FocusCard({ sat, status, mem, onClose }) { + const meta = STATUS_META[status.state]; + return ( +
+ +
+
{sat.name}
+
+ NORAD {sat.norad}·{sat.group} + ·{status.eclipse ? "☾ " : ""}{meta.t} +
+
+
+
{Math.abs(status.p.lat).toFixed(2)}° {status.p.lat >= 0 ? "N" : "S"} · {Math.abs(status.p.lon).toFixed(2)}° {status.p.lon >= 0 ? "E" : "W"}
+
ПАМЯТЬ {Math.round(mem)}%
+
+
+ ); +} + +function MapLegend() { + return ( +
+
СТАНЦИЯ ПРИЁМА
+
ОПТИКА
+
ЛОКАЦИЯ
+
МАРШРУТ СЪЁМКИ
+
СБРОС НА СТАНЦИЮ
+
СВЯЗЬ С КА
+
ТЕНЬ ЗЕМЛИ
+
+ ); +} +function LegItem({ c, t, hatch }) { + return {t}; +} + +export default App; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx new file mode 100644 index 0000000..8e26e85 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/ConstellationTab.tsx @@ -0,0 +1,75 @@ +/* ============================================================ + ConstellationTab.tsx — обёртка вкладки «Группировка» с реальными данными. + Грузит состав/геометрию/расписание из API (realData.ts) и монтирует + прототипный ConstellationDashboard уже с готовыми env/domain/sched — поэтому + его mount-once замыкания (RAF/canvas) видят стабильные данные с первого рендера. + ============================================================ */ +import { useEffect, useState, type ComponentType } from "react"; +import ConstellationDashboardImpl from "./ConstellationDashboard.jsx"; +import { loadConstellationEnv, type ConstellationConfig } from "./realData"; +import "./dashboard.css"; + +// ConstellationDashboard — JS-модуль прототипа; типизируем точку монтирования. +const ConstellationDashboard = ConstellationDashboardImpl as ComponentType; + +type LoadState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; cfg: ConstellationConfig }; + +export function ConstellationTab() { + const [state, setState] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ kind: "loading" }); + loadConstellationEnv() + .then((cfg) => { + if (!cancelled) setState({ kind: "ready", cfg }); + }) + .catch((error: unknown) => { + if (!cancelled) { + setState({ kind: "error", message: error instanceof Error ? error.message : "Не удалось загрузить данные группировки." }); + } + }); + return () => { + cancelled = true; + }; + }, []); + + if (state.kind === "loading") { + return ; + } + if (state.kind === "error") { + return ; + } + if (state.cfg.env.sats.length === 0) { + return ; + } + return ; +} + +function ConstellationNotice({ text, tone }: { text: string; tone?: "error" }) { + return ( +
+ {text} +
+ ); +} + +export default ConstellationTab; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx new file mode 100644 index 0000000..e8952ef --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/MapView.jsx @@ -0,0 +1,360 @@ +/* ============================================================ + MapView.jsx — canvas world map (ESM, default export) + Renders: land + graticule, ROIs, stations, night/terminator, + satellite sine tracks, footprints, capture swaths, downlinks. + Imperative draw(simTime, ui) called from the app RAF loop. + ============================================================ */ +import React, { useRef, useEffect } from "react"; +import { geoEquirectangular, geoPath, geoGraticule, geoCircle } from "d3-geo"; +import { feature } from "topojson-client"; +import * as SIM from "./sim.js"; +import { hexA } from "./util.js"; +// Карта мира раздаётся со своего origin (Vite ?url), без рантайм-зависимости от CDN +// (см. INTEGRATION.md §3). Файл: assets/land-110m.json (world-atlas@2). +import landUrl from "./assets/land-110m.json?url"; + +// keep the original call-sites (d3.* / topojson.*) working unchanged +const d3 = { geoEquirectangular, geoPath, geoGraticule, geoCircle }; +const topojson = { feature }; + +// Подспутниковая точка: реальный сэмплер env.subSat (flight-line) или встроенная +// симуляция SIM.subSat. См. INTEGRATION.md §5.2. +function subSatOf(env, sat, tMs) { + return env.subSat ? env.subSat(sat, tMs) : SIM.subSat(sat, tMs, env.epochMs); +} + +const MAP_C = { + ocean: "#0a0e0f", + land: "#171c1e", + landStroke: "#252d30", + grat: "rgba(120,150,150,0.06)", + gratText: "rgba(150,180,178,0.25)", + amber: "#d99a4e", + blue: "#5b9bd5", + link: "#6f8fae", + red: "#d9685f", + teal: "#5fb3a3", + night: "rgba(20,28,46,0.55)", + nightEdge: "rgba(90,120,170,0.18)", + station: "#cdd6d3", +}; + +function MapView({ env, apiRef }) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + const st = useRef({ + w: 0, h: 0, dpr: 1, proj: null, path: null, + base: null, land: null, ready: false, + }); + + // ---- load world land (topojson via unpkg, graceful fallback) ---- + useEffect(() => { + let alive = true; + fetch(landUrl) + .then((r) => r.json()) + .then((topo) => { + if (!alive) return; + st.current.land = topojson.feature(topo, topo.objects.land); + buildBase(); + if (apiRef.current.onReady) apiRef.current.onReady(); + }) + .catch(() => { buildBase(); }); + return () => { alive = false; }; + }, []); + + // ---- sizing ---- + function resize() { + const wrap = wrapRef.current, cv = canvasRef.current; + if (!wrap || !cv) return; + const r = wrap.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + st.current.w = r.width; st.current.h = r.height; st.current.dpr = dpr; + cv.width = r.width * dpr; cv.height = r.height * dpr; + cv.style.width = r.width + "px"; cv.style.height = r.height + "px"; + const proj = d3.geoEquirectangular() + .scale(r.width / (2 * Math.PI)) + .translate([r.width / 2, r.height / 2]) + .rotate([-10, 0]); + st.current.proj = proj; + buildBase(); + } + useEffect(() => { + resize(); + const ro = new ResizeObserver(resize); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + // ---- cached base layer (land, graticule, ROIs, stations) ---- + function buildBase() { + const s = st.current; + if (!s.w || !s.proj) return; + const dpr = s.dpr; + const off = document.createElement("canvas"); + off.width = s.w * dpr; off.height = s.h * dpr; + const ctx = off.getContext("2d"); + ctx.scale(dpr, dpr); + const path = d3.geoPath(s.proj, ctx); + + // ocean + ctx.fillStyle = MAP_C.ocean; + ctx.fillRect(0, 0, s.w, s.h); + + // graticule + const grat = d3.geoGraticule().step([30, 30]); + ctx.beginPath(); path(grat()); + ctx.strokeStyle = MAP_C.grat; ctx.lineWidth = 1; ctx.stroke(); + + // land + if (s.land) { + ctx.beginPath(); path(s.land); + ctx.fillStyle = MAP_C.land; ctx.fill(); + ctx.strokeStyle = MAP_C.landStroke; ctx.lineWidth = 0.6; ctx.stroke(); + } + + // ROIs (заявки) — faint amber dashed (planar path, avoids d3 winding-complement) + ctx.save(); + ctx.lineWidth = 1; ctx.setLineDash([4, 3]); + for (const r of env.rois) { + tracePoly(ctx, s.proj, r.poly); + ctx.fillStyle = "rgba(217,154,78,0.05)"; ctx.fill(); + ctx.strokeStyle = "rgba(217,154,78,0.5)"; ctx.stroke(); + } + ctx.restore(); + + // station labels + ctx.font = "9px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const stn of env.stations) { + const pt = s.proj([stn.lon, stn.lat]); if (!pt) continue; + ctx.fillStyle = "rgba(205,214,211,0.55)"; + ctx.fillText(stn.name.toUpperCase(), pt[0] + 8, pt[1] - 6); + } + + s.base = off; s.path = path; s.ready = true; + } + + // ---- helpers ---- + function project(lon, lat) { + const p = st.current.proj && st.current.proj([lon, lat]); + return p; + } + // draw a great-circle-ish track polyline, breaking at antimeridian wrap + function strokeTrack(ctx, pts, color, width, alpha) { + const s = st.current, half = s.w * 0.5; + ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; + ctx.lineWidth = width; ctx.lineJoin = "round"; ctx.lineCap = "round"; + ctx.beginPath(); + let prev = null, started = false; + for (const ll of pts) { + const p = s.proj(ll); if (!p) { started = false; continue; } + if (started && prev && Math.abs(p[0] - prev[0]) > half) started = false; + if (!started) { ctx.moveTo(p[0], p[1]); started = true; } + else ctx.lineTo(p[0], p[1]); + prev = p; + } + ctx.stroke(); ctx.restore(); + } + + // ---- main per-frame draw ---- + function draw(tMs, ui) { + const s = st.current, cv = canvasRef.current; + if (!cv || !s.proj) return; + const ctx = cv.getContext("2d"); + ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0); + ctx.clearRect(0, 0, s.w, s.h); + if (s.base) ctx.drawImage(s.base, 0, 0, s.w, s.h); + + const path = d3.geoPath(s.proj, ctx); + const focus = ui.focusId; + + // --- night overlay (тень Земли) --- + const ss = SIM.subSolar(tMs); + const anti = [SIM.wrapLon(ss.lon + 180), -ss.lat]; + const nightPoly = d3.geoCircle().center(anti).radius(90)(); + ctx.beginPath(); path(nightPoly); + ctx.fillStyle = MAP_C.night; ctx.fill(); + ctx.lineWidth = 1; ctx.strokeStyle = MAP_C.nightEdge; ctx.stroke(); + + // --- per-sat dynamic --- + const dash = (tMs / 30) % 16; + const sats = env.sats; + // tracks first (under glyphs) + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const pts = []; + for (let dm = -52; dm <= 52; dm += 1.6) { + pts.push([0, 0]); // placeholder replaced below + } + // build actual track points + pts.length = 0; + for (let dm = -52; dm <= 52; dm += 1.6) { + const p = subSatOf(env, sat, tMs + dm * 60000); + if (p) pts.push([p.lon, p.lat]); + } + strokeTrack(ctx, pts, sat.color, dim ? 0.8 : 1.4, dim ? 0.10 : 0.30); + } + + // events + glyphs + for (const sat of sats) { + const dim = focus && focus !== sat.id; + const stt = SIM.statusAt(sat, tMs, env); + const p = stt.p; + const pt = s.proj([p.lon, p.lat]); if (!pt) continue; + const baseA = dim ? 0.25 : 1; + + // footprint circle (swath of view) + const fp = d3.geoCircle().center([p.lon, p.lat]).radius(sat.payload === "radar" ? 6.5 : 8)(); + ctx.beginPath(); path(fp); + ctx.fillStyle = hexA(sat.color, dim ? 0.03 : 0.055); ctx.fill(); + ctx.strokeStyle = hexA(sat.color, dim ? 0.08 : 0.16); ctx.lineWidth = 1; ctx.stroke(); + + // capture (маршрут съёмки) — amber swath along track + ROI highlight + if (stt.state === "capture") { + const seg = []; + for (let dm = -7; dm <= 7; dm += 1) { + const q = subSatOf(env, sat, tMs + dm * 60000); + if (q) seg.push([q.lon, q.lat]); + } + strokeTrack(ctx, seg, MAP_C.amber, dim ? 3 : 6, dim ? 0.3 : 0.85); + ctx.setLineDash([]); + // контур съёмки есть не у всех режимов плана — рисуем только когда задан + if (stt.roi && stt.roi.poly && stt.roi.poly.length) { + tracePoly(ctx, s.proj, stt.roi.poly); + ctx.fillStyle = "rgba(217,154,78,0.16)"; ctx.fill(); + ctx.strokeStyle = MAP_C.amber; ctx.lineWidth = 1.4; ctx.stroke(); + } + } + + // link (связь с КА) — пунктир к станции на всём окне радиовидимости (ЗРВ), + // даже когда сброс не идёт. Тоньше/бледнее сброса; реальный сброс ляжет поверх. + if (env.sched) { + const lk = SIM.intervalAt((env.sched[sat.id] || {}).link, tMs); + if (lk && lk.lon != null && lk.lat != null) { + const lp = s.proj([lk.lon, lk.lat]); + if (lp) { + ctx.save(); + ctx.setLineDash([3, 6]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.link, dim ? 0.22 : 0.55); ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(lp[0], lp[1]); ctx.stroke(); + ctx.restore(); + } + } + } + + // downlink (сброс) — animated dashed line to station + if (stt.state === "downlink" && stt.station && stt.station.lon != null && stt.station.lat != null) { + const sp = s.proj([stt.station.lon, stt.station.lat]); + if (sp) { + ctx.save(); + ctx.setLineDash([5, 5]); ctx.lineDashOffset = -dash; + ctx.strokeStyle = hexA(MAP_C.blue, dim ? 0.4 : 0.95); ctx.lineWidth = 1.6; + ctx.beginPath(); ctx.moveTo(pt[0], pt[1]); ctx.lineTo(sp[0], sp[1]); ctx.stroke(); + ctx.restore(); + // station glow + ctx.beginPath(); ctx.arc(sp[0], sp[1], 7, 0, 7); + ctx.fillStyle = hexA(MAP_C.blue, 0.18); ctx.fill(); + } + } + + // stations as triangles (drawn here so glow sits under) + // (drawn once globally below to avoid repaint per sat) -> skip + + // sat glyph + drawSatGlyph(ctx, pt[0], pt[1], sat, stt, baseA, sat.id === focus); + + // label + if (!dim || sat.id === focus) { + ctx.font = "10px 'IBM Plex Mono', monospace"; + ctx.textAlign = "left"; ctx.textBaseline = "middle"; + ctx.fillStyle = hexA("#e6ebe9", baseA); + ctx.fillText(sat.name, pt[0] + 11, pt[1] + 0.5); + } + } + + // stations triangles on top + for (const stn of env.stations) { + const sp = s.proj([stn.lon, stn.lat]); if (!sp) continue; + drawTriangle(ctx, sp[0], sp[1], 5, MAP_C.station); + } + } + + function drawSatGlyph(ctx, x, y, sat, stt, alpha, isFocus) { + const ringColor = stt.state === "capture" ? MAP_C.amber + : stt.state === "downlink" ? MAP_C.blue + : stt.state === "prohibit" ? MAP_C.red : null; + // halo + ctx.beginPath(); ctx.arc(x, y, 9, 0, 7); + ctx.fillStyle = hexA(sat.color, 0.10 * alpha); ctx.fill(); + // ring + if (ringColor) { + ctx.beginPath(); ctx.arc(x, y, 7.5, 0, 7); + ctx.strokeStyle = hexA(ringColor, alpha); ctx.lineWidth = 1.6; ctx.setLineDash([]); ctx.stroke(); + } + if (isFocus) { + ctx.beginPath(); ctx.arc(x, y, 11, 0, 7); + ctx.strokeStyle = hexA(MAP_C.teal, 0.9); ctx.lineWidth = 1; ctx.stroke(); + } + // diamond body + ctx.save(); ctx.translate(x, y); ctx.rotate(Math.PI / 4); + const r = 3.4; + ctx.fillStyle = stt.eclipse ? hexA(sat.color, 0.5 * alpha) : hexA(sat.color, alpha); + ctx.strokeStyle = hexA("#05080a", alpha); ctx.lineWidth = 1; + ctx.beginPath(); ctx.rect(-r, -r, r * 2, r * 2); ctx.fill(); ctx.stroke(); + ctx.restore(); + } + + function drawTriangle(ctx, x, y, r, color) { + ctx.beginPath(); + ctx.moveTo(x, y - r); ctx.lineTo(x + r, y + r * 0.8); ctx.lineTo(x - r, y + r * 0.8); + ctx.closePath(); + ctx.fillStyle = hexA(color, 0.85); ctx.fill(); + ctx.strokeStyle = "rgba(10,14,16,0.9)"; ctx.lineWidth = 0.8; ctx.stroke(); + } + + // pointer → lon/lat + nearest sat + function onMove(e) { + const s = st.current, r = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - r.left, y = e.clientY - r.top; + const ll = s.proj && s.proj.invert([x, y]); + if (apiRef.current.onCursor) apiRef.current.onCursor(ll ? { lon: ll[0], lat: ll[1] } : null); + // hit test sats handled by app via getHover; expose nearest + if (apiRef.current.onPick) { + let best = null, bd = 16; + for (const sat of env.sats) { + const p = subSatOf(env, sat, apiRef.current.simTime()); + const pp = p && s.proj([p.lon, p.lat]); if (!pp) continue; + const d = Math.hypot(pp[0] - x, pp[1] - y); + if (d < bd) { bd = d; best = sat.id; } + } + apiRef.current.onPick(best, x, y); + } + } + function onClick() { + if (apiRef.current.onClickPick) apiRef.current.onClickPick(); + } + + // expose imperative API + useEffect(() => { + apiRef.current.draw = draw; + apiRef.current.project = project; + }); + + return ( +
+ apiRef.current.onCursor && apiRef.current.onCursor(null)} onClick={onClick} /> +
+ ); +} + +// trace a small lon/lat polygon as a planar projected path (no antimeridian crossing) +function tracePoly(ctx, proj, poly) { + ctx.beginPath(); + poly.forEach((pt, i) => { + const p = proj(pt); if (!p) return; + if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); + }); + ctx.closePath(); +} + +export default MapView; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx new file mode 100644 index 0000000..b1e86ea --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/Panels.jsx @@ -0,0 +1,182 @@ +/* ============================================================ + Panels.jsx — header + left panel (ESM) + Exports: Header, SidePanel, STATUS_META, fmtDur. + ============================================================ */ +import React from "react"; +import { COL } from "./data.js"; + +const STATUS_META = { + operational: { c: "#5cae7e", t: "НА ОРБИТЕ" }, + capture: { c: "#d99a4e", t: "СЪЁМКА" }, + downlink: { c: "#5b9bd5", t: "СБРОС" }, + prohibit: { c: "#d9685f", t: "ЗАПРЕТ" }, +}; + +// Время в проекте — локальное настенное (см. память project_tgu_ui_utc): часы, планы +// и flight-line идут в зоне браузера без UTC-семантики. Поэтому local-геттеры, не UTC. +function fmtClock(ms) { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, "0"); + return { + time: `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`, + date: `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}`, + dow: ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"][d.getDay()], + }; +} +function fmtDur(ms) { + const s = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(s / 60), ss = s % 60; + return `${m}:${String(ss).padStart(2, "0")}`; +} + +function Header({ simTime, playing, speed, live, onPlay, onSpeed, onNow, onStep }) { + const ck = fmtClock(simTime); + const speeds = [1, 10, 60, 600]; + return ( +
+
+ +
+
СОСТОЯНИЕ ГРУППИРОВКИ
+
MISSION OPS · ТГУ
+
+
+ +
+ + + +
+
+ {speeds.map((s) => ( + + ))} +
+ +
+ +
+
+ {live ? "LIVE" : "СИМ"} +
+
+ {ck.time} + местн. +
+
{ck.dow} · {ck.date}
+
+
+ ); +} + +function SummaryTiles({ counts }) { + const tiles = [ + { k: "capture", label: "СНИМАЮТ", n: counts.capture, c: STATUS_META.capture.c }, + { k: "downlink", label: "СБРОС", n: counts.downlink, c: STATUS_META.downlink.c }, + { k: "prohibit", label: "ЗАПРЕТ", n: counts.prohibit, c: STATUS_META.prohibit.c }, + { k: "eclipse", label: "В ТЕНИ", n: counts.eclipse, c: "#7d92c4" }, + ]; + return ( +
+
+ {tiles.map((t) => ( +
+
{t.n}
+
{t.label}
+
+ ))} +
+
+ ОПТИКА {counts.optic} + ЛОКАЦИЯ {counts.radar} + {counts.total} КА +
+
+ ); +} + +function SatRow({ sat, status, mem, focus, onClick }) { + const meta = STATUS_META[status.state]; + const memColor = mem > 92 ? "#d9685f" : mem > 78 ? "#d99a4e" : "#5fb3a3"; + return ( +
+ +
+
+ {sat.name} + + {status.eclipse && }{meta.t} + +
+
+ NORAD {sat.norad} +
+ {Math.round(mem)}% +
+
+
+ ); +} + +function Roster({ sats, statuses, memory, focusId, onFocus, query }) { + const q = query.trim().toLowerCase(); + const filtered = sats.filter((s) => + !q || s.name.toLowerCase().includes(q) || String(s.norad).includes(q) || s.group.toLowerCase().includes(q)); + const groups = []; + let curD = null, curG = null; + filtered.forEach((s) => { + if (s.domain !== curD) { groups.push({ type: "domain", label: s.domain, color: s.color }); curD = s.domain; curG = null; } + if (s.group !== curG) { groups.push({ type: "group", label: s.group }); curG = s.group; } + groups.push({ type: "sat", sat: s }); + }); + return ( +
+ {groups.map((g, i) => { + if (g.type === "domain") return
{g.label}
; + if (g.type === "group") return
{g.label}
; + const s = g.sat; + return onFocus(focusId === s.id ? null : s.id)} />; + })} +
+ ); +} + +function ActivityFeed({ events, onFocus, focusId }) { + return ( +
+
АКТИВНЫЕ СОБЫТИЯ {events.length}
+
+ {events.length === 0 &&
нет активных событий
} + {events.map((e) => ( +
onFocus(e.satId)}> + +
+
{e.type}{e.target}
+
{e.sat}ещё {e.remain}
+
+
+ ))} +
+
+ ); +} + +function SidePanel({ sats, statuses, memory, counts, events, focusId, onFocus, query, setQuery }) { + return ( + + ); +} + +export { Header, SidePanel, STATUS_META, fmtDur }; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx new file mode 100644 index 0000000..cbb6ba6 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/Timeline.jsx @@ -0,0 +1,163 @@ +/* ============================================================ + Timeline.jsx — bottom event timeline (ESM, default export) + Per-sat rows with capture / downlink / prohibit / shadow bands, + moving playhead, real-"now" marker, drag-to-scrub. + ============================================================ */ +import React, { useRef as useRefTL, useEffect as useEffectTL, useState as useStateTL } from "react"; + +const TL_SPAN = 12 * 3600e3; // visible window +const TL_LEAD = 0.32; // playhead position fraction +const TL_GUTTER = 188; +const TL_ROW = 22; +const TL_AXIS = 26; + +const TL_COL = { + capture: "#d99a4e", + downlink: "#5b9bd5", + link: "rgba(111,143,174,0.38)", // связь с КА (ЗРВ) — фоновое окно радиовидимости + prohibit: "#d9685f", + shadow: "rgba(120,140,180,0.30)", +}; + +function Timeline({ env, sched, simTime, domain, focusId, onScrub, onFocus, live }) { + const wrapRef = useRefTL(null); + const [w, setW] = useStateTL(1200); + const dragRef = useRefTL(false); + + useEffectTL(() => { + const ro = new ResizeObserver((es) => setW(es[0].contentRect.width)); + if (wrapRef.current) ro.observe(wrapRef.current); + return () => ro.disconnect(); + }, []); + + const areaW = Math.max(200, w - TL_GUTTER); + let viewStart = simTime - TL_LEAD * TL_SPAN; + viewStart = Math.max(domain.start, Math.min(viewStart, domain.end - TL_SPAN)); + const viewEnd = viewStart + TL_SPAN; + const X = (t) => TL_GUTTER + ((t - viewStart) / TL_SPAN) * areaW; + const clampT = (t) => Math.max(viewStart, Math.min(viewEnd, t)); + + const sats = env.sats; + const rows = []; + let y = TL_AXIS; + let lastDomain = null; + sats.forEach((s) => { + if (s.domain !== lastDomain) { rows.push({ type: "group", label: s.domain, y }); y += 16; lastDomain = s.domain; } + rows.push({ type: "sat", sat: s, y }); + y += TL_ROW; + }); + const totalH = y + 6; + + // axis ticks (hourly) + const ticks = []; + const hour = 3600e3; + let t0 = Math.ceil(viewStart / hour) * hour; + for (let t = t0; t <= viewEnd; t += hour) ticks.push(t); + + function ptFromEvent(e) { + const r = wrapRef.current.getBoundingClientRect(); + const x = e.clientX - r.left; + const frac = (x - TL_GUTTER) / areaW; + return viewStart + frac * TL_SPAN; + } + function down(e) { + if (e.clientX - wrapRef.current.getBoundingClientRect().left < TL_GUTTER) return; + dragRef.current = true; onScrub(ptFromEvent(e)); + } + function move(e) { if (dragRef.current) onScrub(ptFromEvent(e)); } + function up() { dragRef.current = false; } + useEffectTL(() => { + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }); + + const nowX = X(Date.now()); + const headX = X(simTime); + + return ( +
+ + + + + + + + + {/* axis */} + + {ticks.map((t, i) => { + const x = X(t); const d = new Date(t); + const hh = String(d.getHours()).padStart(2, "0"); + const isDay = d.getHours() === 0; + return ( + + + + {isDay ? `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}` : `${hh}:00`} + + + ); + })} + + {/* rows */} + {rows.map((r, i) => { + if (r.type === "group") { + return {r.label}; + } + const s = r.sat; + const tr = sched[s.id] || {}; + const dim = focusId && focusId !== s.id; + const cy = r.y + TL_ROW / 2; + const band = (iv, key, fill, h) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + }; + return ( + onFocus(focusId === s.id ? null : s.id)} style={{ cursor: "pointer" }}> + + + + {s.name} + {/* baseline */} + + {/* shadow (under) */} + {(tr.shadow || []).map((iv, k) => band(iv, "sh" + k, TL_COL.shadow, TL_ROW - 8))} + {/* link (связь с КА — окна радиовидимости, фоновая полоса) */} + {(tr.link || []).map((iv, k) => band(iv, "lk" + k, TL_COL.link, TL_ROW - 10))} + {/* downlink (реальный сброс из плана) */} + {(tr.downlink || []).map((iv, k) => band(iv, "dl" + k, TL_COL.downlink, 8))} + {/* capture */} + {(tr.capture || []).map((iv, k) => band(iv, "cp" + k, TL_COL.capture, 10))} + {/* prohibit (hatched, full height) */} + {(tr.prohibit || []).map((iv, k) => { + const x1 = X(Math.max(iv.start, viewStart)), x2 = X(Math.min(iv.end, viewEnd)); + if (x2 <= TL_GUTTER || x1 >= w || x2 - x1 < 0.6) return null; + return ; + })} + + ); + })} + + {/* now marker (real wall clock) */} + {nowX > TL_GUTTER && nowX < w && ( + + + СЕЙЧАС + + )} + {/* playhead */} + + + + + +
+ ); +} + +export default Timeline; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json b/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json new file mode 100644 index 0000000..fbd897c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/assets/land-110m.json @@ -0,0 +1 @@ +{"type":"Topology","objects":{"land":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0]],[[1]],[[2]],[[3]],[[4]],[[5]],[[6]],[[7]],[[8]],[[9]],[[10]],[[11]],[[12]],[[13]],[[14]],[[15]],[[16,18]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[48]],[[49]],[[50]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[58]],[[59]],[[60]],[[61]],[[62]],[[63]],[[64]],[[65]],[[66]],[[67]],[[68]],[[69]],[[70]],[[71]],[[72]],[[73]],[[74]],[[75]],[[76]],[[77]],[[78]],[[79]],[[80]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]],[[90]],[[91]],[[92,115],[114]],[[94]],[[95]],[[96]],[[97]],[[98]],[[99]],[[100]],[[101]],[[102]],[[103]],[[104]],[[105]],[[106]],[[107]],[[108]],[[109]],[[110]],[[111]],[[112]],[[113]],[[116]],[[117]],[[118]],[[119]],[[120]],[[121]],[[122]],[[123]],[[124]],[[125]],[[126]],[[127]],[[128]],[[129]]]}]}},"arcs":[[[33452,3290],[-82,-301],[-81,-266],[-582,81],[-621,-35],[-348,197],[0,23],[-152,174],[625,-23],[599,-58],[207,243],[147,208],[288,-243]],[[5775,3611],[-533,-81],[-364,208],[-163,209],[-11,35],[-180,162],[169,220],[517,-93],[277,-185],[212,-209],[76,-266]],[[37457,4468],[342,-255],[120,-359],[33,-254],[11,-301],[-430,-186],[-452,-150],[-522,-139],[-582,-116],[-658,35],[-365,197],[49,243],[593,162],[239,197],[174,254],[126,220],[168,209],[180,243],[141,0],[414,127],[419,-127]],[[16330,7154],[359,-93],[332,104],[-158,-208],[-261,-151],[-386,47],[-278,208],[60,197],[332,-104]],[[15122,7165],[425,-231],[-164,23],[-359,58],[-381,162],[202,127],[277,-139]],[[22505,8080],[305,-81],[304,69],[163,-335],[-217,46],[-337,-23],[-343,23],[-376,-35],[-283,116],[-146,243],[174,104],[353,-81],[403,-46]],[[30985,8657],[33,-266],[-49,-231],[-76,-220],[-326,-81],[-311,-116],[-364,11],[136,232],[-327,-81],[-310,-81],[-212,174],[-16,243],[305,231],[190,70],[321,-23],[82,301],[16,219],[-6,475],[158,278],[256,93],[147,-220],[65,-220],[120,-267],[92,-254],[76,-267]],[[0,529],[16,-5],[245,344],[501,-185],[32,21],[78,49],[94,61],[81,52],[41,26],[41,-1],[29,-10],[402,-246],[352,246],[63,34],[816,104],[265,-138],[130,-71],[419,-196],[789,-151],[625,-185],[1072,-139],[800,162],[1181,-116],[669,-185],[734,174],[773,162],[60,278],[-1094,23],[-898,139],[-234,231],[-745,128],[49,266],[103,243],[104,220],[-55,243],[-462,162],[-212,209],[-430,185],[675,-35],[642,93],[402,-197],[495,173],[457,220],[223,197],[-98,243],[-359,162],[-408,174],[-571,35],[-500,81],[-539,58],[-180,220],[-359,185],[-217,208],[-87,672],[136,-58],[250,-185],[457,58],[441,81],[228,-255],[441,58],[370,127],[348,162],[315,197],[419,58],[-11,220],[-97,220],[81,208],[359,104],[163,-196],[425,115],[321,151],[397,12],[375,57],[376,139],[299,128],[337,127],[218,-35],[190,-46],[414,81],[370,-104],[381,11],[364,81],[375,-57],[414,-58],[386,23],[403,-12],[413,-11],[381,23],[283,174],[337,92],[349,-127],[331,104],[300,208],[179,-185],[98,-208],[180,-197],[288,174],[332,-220],[375,-70],[321,-162],[392,35],[354,104],[418,-23],[376,-81],[381,-104],[147,254],[-180,197],[-136,209],[-359,46],[-158,220],[-60,220],[-98,440],[213,-81],[364,-35],[359,35],[327,-93],[283,-174],[119,-208],[376,-35],[359,81],[381,116],[342,70],[283,-139],[370,46],[239,451],[224,-266],[321,-104],[348,58],[228,-232],[365,-23],[337,-69],[332,-128],[218,220],[108,209],[278,-232],[381,58],[283,-127],[190,-197],[370,58],[288,127],[283,151],[337,81],[392,69],[354,81],[272,127],[163,186],[65,254],[-32,244],[-87,231],[-98,232],[-87,231],[-71,209],[-16,231],[27,232],[130,220],[109,243],[44,231],[-55,255],[-32,232],[136,266],[152,173],[180,220],[190,186],[223,173],[109,255],[152,162],[174,151],[267,34],[174,186],[196,115],[228,70],[202,150],[157,186],[218,69],[163,-151],[-103,-196],[-283,-174],[-120,-127],[-206,92],[-229,-58],[-190,-139],[-202,-150],[-136,-174],[-38,-231],[17,-220],[130,-197],[-190,-139],[-261,-46],[-153,-197],[-163,-185],[-174,-255],[-44,-220],[98,-243],[147,-185],[229,-139],[212,-185],[114,-232],[60,-220],[82,-232],[130,-196],[82,-220],[38,-544],[81,-220],[22,-232],[87,-231],[-38,-313],[-152,-243],[-163,-197],[-370,-81],[-125,-208],[-169,-197],[-419,-220],[-370,-93],[-348,-127],[-376,-128],[-223,-243],[-446,-23],[-489,23],[-441,-46],[-468,0],[87,-232],[424,-104],[311,-162],[174,-208],[-310,-185],[-479,58],[-397,-151],[-17,-243],[-11,-232],[327,-196],[60,-220],[353,-220],[588,-93],[500,-162],[398,-185],[506,-186],[690,-92],[681,-162],[473,-174],[517,-197],[272,-278],[136,-220],[337,209],[457,173],[484,186],[577,150],[495,162],[691,12],[680,-81],[560,-139],[180,255],[386,173],[702,12],[550,127],[522,128],[577,81],[614,104],[430,150],[-196,209],[-119,208],[0,220],[-539,-23],[-571,-93],[-544,0],[-77,220],[39,440],[125,128],[397,138],[468,139],[337,174],[337,174],[251,231],[380,104],[376,81],[190,47],[430,23],[408,81],[343,116],[337,139],[305,139],[386,185],[245,197],[261,173],[82,232],[-294,139],[98,243],[185,185],[288,116],[305,139],[283,185],[217,232],[136,277],[202,163],[331,-35],[136,-197],[332,-23],[11,220],[142,231],[299,-58],[71,-220],[331,-34],[360,104],[348,69],[315,-34],[120,-243],[305,196],[283,105],[315,81],[310,81],[283,139],[310,92],[240,128],[168,208],[207,-151],[288,81],[202,-277],[157,-209],[316,116],[125,232],[283,162],[365,-35],[108,-220],[229,220],[299,69],[326,23],[294,-11],[310,-70],[300,-34],[130,-197],[180,-174],[304,104],[327,24],[315,0],[310,11],[278,81],[294,70],[245,162],[261,104],[283,58],[212,162],[152,324],[158,197],[288,-93],[109,-208],[239,-139],[289,46],[196,-208],[206,-151],[283,139],[98,255],[250,104],[289,197],[272,81],[326,116],[218,127],[228,139],[218,127],[261,-69],[250,208],[180,162],[261,-11],[229,139],[54,208],[234,162],[228,116],[278,93],[256,46],[244,-35],[262,-58],[223,-162],[27,-254],[245,-197],[168,-162],[332,-70],[185,-162],[229,-162],[266,-35],[223,116],[240,243],[261,-127],[272,-70],[261,-69],[272,-46],[277,0],[229,-614],[-11,-150],[-33,-267],[-266,-150],[-218,-220],[38,-232],[310,12],[-38,-232],[-141,-220],[-131,-243],[212,-185],[321,-58],[321,104],[153,232],[92,220],[153,185],[174,174],[70,208],[147,289],[174,58],[316,24],[277,69],[283,93],[136,231],[82,220],[190,220],[272,151],[234,115],[153,197],[157,104],[202,93],[277,-58],[250,58],[272,69],[305,-34],[201,162],[142,393],[103,-162],[131,-278],[234,-115],[266,-47],[267,70],[283,-46],[261,-12],[174,58],[234,-35],[212,-127],[250,81],[300,0],[255,81],[289,-81],[185,197],[141,196],[191,163],[348,439],[179,-81],[212,-162],[185,-208],[354,-359],[272,-12],[256,0],[299,70],[299,81],[229,162],[190,174],[310,23],[207,127],[218,-116],[141,-185],[196,-185],[305,23],[190,-150],[332,-151],[348,-58],[288,47],[218,185],[185,185],[250,46],[251,-81],[288,-58],[261,93],[250,0],[245,-58],[256,-58],[250,104],[299,93],[283,23],[316,0],[255,58],[251,46],[76,290],[11,243],[174,-162],[49,-266],[92,-244],[115,-196],[234,-105],[315,35],[365,12],[250,35],[364,0],[262,11],[364,-23],[310,-46],[196,-186],[-54,-220],[179,-173],[299,-139],[310,-151],[360,-104],[375,-92],[283,-93],[315,-12],[180,197],[245,-162],[212,-185],[245,-139],[337,-58],[321,-69],[136,-232],[316,-139],[212,-208],[310,-93],[321,12],[299,-35],[332,12],[332,-47],[310,-81],[288,-139],[289,-116],[195,-173],[-32,-232],[-147,-208],[-125,-266],[-98,-209],[-131,-243],[-364,-93],[-163,-208],[-360,-127],[-125,-232],[-190,-220],[-201,-185],[-115,-243],[-70,-220],[-28,-266],[6,-220],[158,-232],[60,-220],[130,-208],[517,-81],[109,-255],[-501,-93],[-424,-127],[-528,-23],[-234,-336],[-49,-278],[-119,-220],[-147,-220],[370,-196],[141,-244],[239,-219],[338,-197],[386,-186],[419,-185],[636,-185],[142,-289],[800,-128],[53,-45],[208,-175],[767,151],[636,-186],[-99520,-142]],[[31180,18764],[361,-355],[389,-147],[-125,-296],[-264,-29],[-141,208],[-92,-239],[-238,-183],[-301,67],[-202,177],[-291,86],[-350,330],[-283,317],[-383,662],[229,-124],[390,-395],[369,-212],[143,271],[90,405],[256,244],[198,-70],[106,-274],[139,-443]],[[33736,20389],[222,-266],[-83,-207],[-375,-177],[-125,207],[-236,-266],[-139,266],[333,354],[236,-148],[167,237]],[[69522,21210],[-427,-38],[-7,314],[41,244],[19,121],[179,-186],[263,-74],[9,-112],[-77,-269]],[[90387,26479],[269,-204],[151,81],[217,113],[166,-39],[20,-702],[-95,-203],[-29,-476],[-97,162],[-193,-412],[-57,32],[-171,19],[-171,505],[-38,390],[-160,515],[7,271],[181,-52]],[[98060,26404],[63,-244],[198,239],[80,-249],[0,-249],[-103,-274],[-182,-435],[-142,-238],[103,-284],[-214,-7],[-238,-223],[-75,-387],[-157,-597],[-219,-264],[-138,-169],[-256,13],[-180,194],[-302,42],[-46,217],[149,438],[349,583],[179,111],[200,225],[238,310],[167,306],[123,441],[106,149],[41,330],[195,273],[61,-251]],[[98502,29218],[202,-622],[5,403],[126,-161],[41,-447],[224,-192],[188,-48],[158,226],[141,-69],[-67,-524],[-85,-345],[-212,12],[-74,-179],[26,-254],[-41,-110],[-105,-319],[-138,-404],[-214,-236],[-48,155],[-116,85],[160,486],[-91,326],[-299,236],[8,214],[201,206],[47,455],[-13,382],[-113,396],[8,104],[-133,244],[-218,523],[-117,418],[104,46],[151,-328],[216,-153],[78,-526]],[[96421,37487],[-105,-142],[-153,160],[-199,266],[-179,313],[-184,416],[-38,201],[119,-9],[156,-201],[122,-200],[89,-166],[228,-366],[144,-272]],[[99547,40335],[96,-171],[-46,-308],[-172,-81],[-153,73],[-27,260],[107,203],[126,-74],[69,98]],[[0,40798],[99822,-145],[-177,-124],[-36,220],[139,121],[88,33],[-99836,184]],[[0,41087],[0,-289]],[[0,41087],[57,27],[-34,-284],[-23,-32]],[[96623,40851],[-92,-78],[-93,259],[10,158],[175,-339]],[[96418,41756],[45,-476],[-75,74],[-58,-32],[-39,163],[-6,453],[133,-182]],[[63904,42571],[45,-711],[72,-276],[-28,-284],[-49,-174],[-94,347],[-53,-175],[53,-438],[-24,-250],[-77,-137],[-18,-500],[-109,-689],[-137,-814],[-172,-1120],[-106,-821],[-125,-685],[-226,-140],[-243,-250],[-160,151],[-220,211],[-77,312],[-18,524],[-98,471],[-26,425],[50,426],[128,102],[1,197],[133,447],[25,377],[-65,280],[-52,372],[-23,544],[97,331],[38,375],[138,22],[155,121],[103,107],[122,7],[158,337],[229,364],[83,297],[-38,253],[118,-71],[153,410],[6,356],[92,264],[96,-254],[74,-251],[69,-390]],[[89877,42448],[100,-464],[179,223],[92,-250],[133,-231],[-29,-262],[60,-506],[42,-295],[70,-72],[75,-505],[-27,-307],[90,-400],[301,-309],[197,-281],[186,-257],[-37,-143],[159,-371],[108,-639],[111,130],[113,-256],[68,91],[48,-626],[197,-363],[129,-226],[217,-478],[78,-475],[7,-337],[-19,-365],[132,-502],[-16,-523],[-48,-274],[-75,-527],[6,-339],[-55,-423],[-123,-538],[-205,-290],[-102,-458],[-93,-292],[-82,-510],[-107,-294],[-70,-442],[-36,-407],[14,-187],[-159,-205],[-311,-22],[-257,-242],[-127,-229],[-168,-254],[-230,262],[-170,104],[43,308],[-152,-112],[-243,-428],[-240,160],[-158,94],[-159,42],[-269,171],[-179,364],[-52,449],[-64,298],[-137,240],[-267,71],[91,287],[-67,438],[-136,-408],[-247,-109],[146,327],[42,341],[107,289],[-22,438],[-226,-504],[-174,-202],[-106,-470],[-217,243],[9,313],[-174,429],[-147,221],[52,137],[-356,358],[-195,17],[-267,287],[-498,-56],[-359,-211],[-317,-197],[-265,39],[-294,-303],[-241,-137],[-53,-309],[-103,-240],[-236,-15],[-174,-52],[-246,107],[-199,-64],[-191,-27],[-165,-315],[-81,26],[-140,-167],[-133,-187],[-203,23],[-186,0],[-295,377],[-149,113],[6,338],[138,81],[47,134],[-10,212],[34,411],[-31,350],[-147,598],[-45,337],[12,336],[-111,385],[-7,174],[-123,235],[-35,463],[-158,467],[-39,252],[122,-255],[-93,548],[137,-171],[83,-229],[-5,303],[-138,465],[-26,186],[-65,177],[31,341],[56,146],[38,295],[-29,346],[114,425],[21,-450],[118,406],[225,198],[136,252],[212,217],[126,46],[77,-73],[219,220],[168,66],[42,129],[74,54],[153,-14],[292,173],[151,262],[71,316],[163,300],[13,236],[7,321],[194,502],[117,-510],[119,118],[-99,279],[87,287],[122,-128],[34,449],[152,291],[67,233],[140,101],[4,165],[122,-69],[5,148],[122,85],[134,80],[205,-271],[155,-350],[173,-4],[177,-56],[-59,325],[133,473],[126,155],[-44,147],[121,338],[168,208],[142,-70],[234,111],[-5,302],[-204,195],[148,86],[184,-147],[148,-242],[234,-151],[79,60],[172,-182],[162,169],[105,-51],[65,113],[127,-292],[-74,-316],[-105,-239],[-96,-20],[32,-236],[-81,-295],[-99,-291],[20,-166],[221,-327],[214,-189],[143,-204],[201,-350],[78,1],[145,-151],[43,-183],[265,-200],[183,202],[55,317],[56,262],[34,324],[85,470],[-39,286],[20,171],[-32,339],[37,445],[53,120],[-43,197],[67,313],[52,325],[7,168],[104,222],[78,-289],[19,-371],[70,-71],[11,-249],[101,-300],[21,-335],[-10,-214]],[[95032,44386],[78,-203],[-194,4],[-106,363],[166,-142],[56,-22]],[[83531,44530],[-117,-11],[-368,414],[259,116],[146,-180],[97,-180],[-17,-159]],[[94680,44747],[-108,-14],[-170,60],[-58,91],[17,235],[183,-93],[91,-124],[45,-155]],[[94910,44908],[-42,-109],[-206,512],[-57,353],[94,0],[100,-473],[111,-283]],[[84565,44589],[-238,-130],[-33,71],[25,201],[119,360],[275,235],[32,139],[239,133],[194,20],[87,74],[105,-74],[-102,-160],[-289,-258],[-233,-170],[-181,-441]],[[82749,45797],[100,-158],[172,48],[69,-251],[-321,-119],[-193,-79],[-149,5],[95,340],[153,5],[74,209]],[[84139,45797],[-41,-328],[-417,-168],[-370,73],[0,216],[220,123],[174,-177],[185,45],[249,216]],[[94409,45654],[12,-119],[-218,251],[-152,212],[-104,197],[41,60],[128,-142],[228,-272],[65,-187]],[[93760,46238],[-56,-33],[-121,134],[-114,243],[14,99],[166,-250],[111,-193]],[[80172,46575],[533,-59],[61,244],[515,-284],[101,-383],[417,-108],[341,-351],[-317,-225],[-306,238],[-251,-16],[-288,44],[-260,106],[-322,225],[-204,59],[-116,-74],[-506,243],[-48,254],[-255,44],[191,564],[337,-35],[224,-231],[115,-45],[38,-210]],[[87423,46908],[-143,-402],[-27,445],[49,212],[58,200],[63,-173],[0,-282]],[[93299,46550],[-78,-59],[-120,227],[-122,375],[-59,450],[38,57],[30,-175],[84,-134],[135,-375],[131,-200],[-39,-166]],[[92217,47343],[-146,-48],[-44,-166],[-152,-144],[-142,-138],[-148,1],[-228,171],[-158,165],[23,183],[249,-86],[152,46],[42,283],[40,15],[27,-314],[158,45],[78,202],[155,211],[-30,348],[166,11],[56,-97],[-5,-327],[-93,-361]],[[85346,48536],[-104,-196],[-192,108],[-54,254],[281,29],[69,-195]],[[86241,48752],[101,-452],[-234,244],[-232,49],[-157,-39],[-192,21],[65,325],[344,24],[305,-172]],[[92538,47921],[-87,-157],[-52,348],[-65,229],[-126,193],[-158,252],[-200,174],[77,143],[150,-166],[94,-130],[117,-142],[111,-248],[106,-189],[33,-307]],[[87261,49899],[78,-955],[287,-354],[232,627],[319,356],[247,1],[238,-206],[206,-212],[298,-113],[482,-407],[513,-338],[192,-302],[154,-297],[43,-349],[462,-365],[68,-313],[-256,-64],[62,-393],[248,-388],[180,-627],[159,20],[-11,-262],[215,-100],[-84,-111],[295,-249],[-30,-171],[-184,-41],[-69,153],[-238,66],[-281,89],[-216,377],[-158,325],[-144,517],[-362,259],[-235,-169],[-170,-195],[35,-436],[-218,-203],[-155,99],[-288,25],[-247,485],[-282,118],[-69,-168],[-352,-18],[118,481],[175,164],[-72,642],[-134,496],[-538,500],[-229,50],[-417,546],[-82,-287],[-107,-52],[-63,216],[-1,257],[-212,290],[299,213],[198,-11],[-23,156],[-407,1],[-110,352],[-248,109],[-117,293],[374,143],[142,192],[446,-242],[44,-220]],[[84788,51419],[-223,-587],[-209,-113],[-267,115],[-463,-29],[-243,-85],[-39,-447],[248,-526],[150,268],[518,201],[-22,-272],[-121,86],[-121,-347],[-245,-229],[263,-757],[-50,-203],[249,-682],[-2,-388],[-148,-173],[-109,207],[134,484],[-273,-229],[-69,164],[36,228],[-200,346],[21,576],[-186,-179],[24,-689],[11,-846],[-176,-85],[-119,173],[79,544],[-43,570],[-117,4],[-86,405],[115,387],[40,469],[139,891],[58,243],[237,439],[217,-174],[350,-82],[319,25],[275,429],[48,-132]],[[85746,51249],[-15,-517],[-143,58],[-42,-359],[114,-312],[-78,-71],[-112,374],[-82,755],[56,472],[92,215],[20,-322],[164,-52],[26,-241]],[[79393,47122],[-308,-12],[-234,494],[-356,482],[-119,358],[-210,481],[-138,443],[-212,827],[-244,493],[-81,508],[-103,461],[-250,372],[-145,506],[-209,330],[-290,652],[-24,300],[178,-24],[430,-114],[246,-577],[215,-401],[153,-246],[263,-635],[283,-9],[233,-405],[161,-495],[211,-270],[-111,-482],[159,-205],[100,-15],[47,-412],[97,-330],[204,-52],[135,-374],[-70,-735],[-11,-914]],[[82742,51659],[312,-546],[-329,-70],[-93,-403],[12,-535],[-267,-404],[-7,-589],[-107,-903],[-41,210],[-316,-266],[-110,361],[-198,34],[-139,189],[-330,-212],[-101,285],[-182,-32],[-229,68],[-43,793],[-138,164],[-134,505],[-38,517],[32,548],[165,392],[204,-202],[214,110],[56,500],[119,112],[333,128],[199,467],[137,374],[110,221],[236,323],[214,411],[140,462],[112,2],[143,-299],[13,-257],[183,-165],[231,-177],[-20,-232],[-186,-29],[50,-289],[-205,-201],[-158,-533],[204,-560],[-48,-272]],[[85104,55551],[28,-392],[16,-332],[-94,-540],[-102,602],[-130,-300],[89,-435],[-79,-277],[-327,343],[-78,428],[84,280],[-176,280],[-87,-245],[-131,23],[-205,-330],[-46,173],[109,498],[175,166],[151,223],[98,-268],[212,162],[45,264],[196,15],[-16,457],[225,-280],[23,-297],[20,-218]],[[72560,54241],[-242,-135],[-132,470],[-49,849],[126,959],[192,-328],[129,-416],[134,-616],[-42,-615],[-116,-168]],[[33073,56553],[-232,-65],[-50,53],[81,163],[-6,233],[160,77],[58,-21],[-11,-440]],[[84439,56653],[-100,-195],[-87,-373],[-87,-175],[-171,409],[57,158],[70,165],[30,367],[153,35],[-44,-398],[205,570],[-26,-563]],[[82917,56084],[-369,-561],[136,414],[200,364],[167,409],[146,587],[49,-482],[-183,-325],[-146,-406]],[[83856,57606],[166,-183],[177,1],[-5,-247],[-129,-251],[-176,-178],[-10,275],[20,301],[-43,282]],[[84861,57766],[78,-660],[-214,157],[5,-199],[68,-364],[-132,-133],[-11,416],[-84,31],[-43,357],[163,-47],[-4,224],[-169,451],[266,-13],[77,-220]],[[83757,58301],[-74,-510],[-119,295],[-142,450],[238,-22],[97,-213]],[[83700,61512],[171,-168],[85,153],[26,-150],[-46,-245],[95,-423],[-73,-491],[-164,-196],[-43,-476],[62,-471],[147,-65],[123,70],[347,-328],[-27,-321],[91,-142],[-29,-272],[-216,290],[-103,310],[-71,-217],[-177,354],[-253,-87],[-138,130],[14,244],[87,151],[-83,136],[-36,-213],[-137,340],[-41,257],[-11,566],[112,-195],[29,925],[90,535],[169,-1]],[[31780,61349],[-71,-149],[-209,4],[-163,-21],[-16,253],[40,86],[227,-3],[142,-52],[50,-118]],[[28638,61137],[-84,-99],[-156,95],[-159,215],[34,135],[116,41],[64,-20],[187,-53],[147,-142],[46,-161],[-195,-11]],[[29839,62320],[241,-93],[34,101],[217,-3],[165,-152],[73,15],[50,-209],[152,11],[-9,-176],[124,-21],[136,-217],[-103,-240],[-132,128],[-127,-25],[-92,28],[-50,-107],[-106,-37],[-43,144],[-92,-85],[-111,-405],[-71,94],[-14,170],[-185,100],[-131,-41],[-169,43],[-130,-110],[-149,184],[24,190],[256,-82],[210,-47],[100,131],[-127,256],[2,226],[-175,92],[62,163],[170,-26]],[[80649,61615],[-240,-284],[-228,183],[-8,509],[137,267],[304,166],[159,-14],[62,-226],[-122,-260],[-64,-341]],[[6794,61855],[-41,-99],[-69,84],[8,165],[-46,216],[14,65],[48,97],[-19,116],[16,55],[21,-11],[107,-100],[49,-51],[45,-79],[71,-207],[-7,-33],[-108,-126],[-89,-92]],[[6645,62777],[-94,-43],[-47,125],[-32,48],[-3,37],[27,50],[99,-56],[73,-90],[-23,-71]],[[6456,63091],[-9,-63],[-149,17],[21,72],[137,-26]],[[6207,63177],[-15,-34],[-19,8],[-97,21],[-35,133],[-11,24],[74,82],[23,-38],[80,-196]],[[5737,63567],[-33,-58],[-93,107],[14,43],[43,58],[64,-12],[5,-138]],[[27867,64030],[110,-216],[260,66],[98,-138],[235,-366],[173,-267],[92,8],[165,-120],[-20,-167],[205,-24],[210,-242],[-33,-138],[-185,-75],[-187,-29],[-191,46],[-398,-57],[186,329],[-113,154],[-179,39],[-96,171],[-66,336],[-157,-23],[-259,159],[-83,124],[-362,91],[-97,115],[104,148],[-273,30],[-199,-307],[-115,-8],[-40,-144],[-138,-65],[-118,56],[146,183],[60,213],[126,131],[142,116],[210,56],[67,65],[240,-42],[219,-7],[261,-201]],[[28462,64617],[-68,-29],[-70,340],[-104,171],[60,375],[84,-23],[97,-491],[1,-343]],[[83659,64045],[-119,-485],[-146,499],[-32,438],[163,581],[223,447],[127,-176],[-49,-357],[-167,-947]],[[28383,66284],[-303,-95],[-19,219],[130,47],[184,-18],[8,-153]],[[28611,66290],[-48,-420],[-51,75],[4,309],[-124,234],[-1,67],[220,-265]],[[87399,70756],[35,-203],[-156,-357],[-114,189],[-143,-137],[-73,-346],[-181,168],[2,281],[154,352],[158,-68],[114,248],[204,-127]],[[59604,71655],[-188,-251],[21,-111],[8,-48],[-285,-240],[-136,77],[-64,237],[132,22],[19,3],[40,143],[200,-8],[253,176]],[[56583,71675],[152,-199],[216,34],[207,-42],[-7,-103],[151,71],[-35,-175],[-400,-50],[3,98],[-339,115],[52,251]],[[54311,73167],[-100,-465],[41,-183],[-58,-303],[-213,222],[-141,64],[-387,300],[38,304],[325,-54],[284,64],[211,51]],[[52558,74927],[166,-419],[-39,-782],[-126,38],[-113,-197],[-105,156],[-11,713],[-64,338],[153,-30],[139,183]],[[89159,72524],[-104,-472],[48,-296],[-145,-416],[-355,-278],[-488,-36],[-396,-675],[-186,227],[-12,442],[-483,-130],[-329,-279],[-325,-11],[282,-435],[-186,-1004],[-179,-248],[-135,229],[69,533],[-176,172],[-113,405],[263,182],[145,371],[280,306],[203,403],[553,177],[297,-121],[291,1050],[185,-282],[408,591],[158,229],[174,723],[-47,664],[117,374],[295,108],[152,-819],[-9,-479],[-256,-595],[4,-610]],[[52655,75484],[-92,-456],[-126,120],[-64,398],[56,219],[179,226],[47,-507]],[[89974,76679],[195,-126],[197,250],[62,-663],[-412,-162],[-244,-587],[-436,404],[-152,-646],[-308,-9],[-39,587],[138,455],[296,33],[81,817],[83,460],[326,-615],[213,-198]],[[32315,78082],[202,-79],[257,16],[-137,-242],[-102,-38],[-353,250],[-69,198],[105,183],[97,-288]],[[32831,79592],[-135,-11],[-360,186],[-258,279],[96,49],[365,-148],[284,-247],[8,-108]],[[15692,79240],[-140,-82],[-456,269],[-84,209],[-248,207],[-50,168],[-286,107],[-107,321],[24,137],[291,-129],[171,-89],[261,-63],[94,-204],[138,-280],[277,-244],[115,-327]],[[34407,80527],[-184,-517],[181,199],[187,-126],[-98,-206],[247,-162],[128,144],[277,-182],[-86,-433],[194,101],[36,-313],[86,-367],[-117,-520],[-125,-22],[-183,111],[60,484],[-77,75],[-322,-513],[-166,21],[196,277],[-267,144],[-298,-35],[-539,18],[-43,175],[173,208],[-121,160],[234,356],[287,941],[172,336],[241,204],[129,-26],[-54,-160],[-148,-372]],[[13136,82508],[267,47],[-84,-671],[242,-475],[-111,1],[-167,270],[-103,272],[-140,184],[-51,260],[16,188],[131,-76]],[[89901,80562],[280,-1046],[-411,195],[-171,-854],[271,-605],[-8,-413],[-211,356],[-182,-457],[-51,496],[31,575],[-32,638],[64,446],[13,790],[-163,581],[24,808],[257,271],[-110,274],[123,83],[73,-391],[96,-569],[-7,-581],[114,-597]],[[48114,81456],[-493,-349],[-393,89],[225,617],[-145,601],[378,463],[210,276],[233,24],[298,-365],[-149,-406],[46,-422],[-210,-528]],[[53524,83435],[-166,-478],[-291,333],[-39,246],[408,195],[88,-296]],[[7498,84325],[-277,-225],[-142,152],[-43,277],[252,210],[148,90],[185,-40],[117,-183],[-240,-281]],[[49165,85222],[-297,-639],[283,81],[304,-3],[-72,-481],[-250,-530],[287,-38],[270,-759],[190,-95],[171,-673],[79,-233],[337,-113],[-34,-378],[-142,-173],[111,-305],[-250,-310],[-371,6],[-473,-163],[-130,116],[-183,-276],[-257,67],[-195,-226],[-148,118],[407,621],[249,127],[-436,99],[-79,235],[291,183],[-152,319],[52,387],[414,-54],[40,343],[-190,372],[-337,104],[-66,160],[101,264],[-92,163],[-149,-279],[-17,569],[-140,301],[101,611],[216,480],[222,-47],[335,49]],[[4006,85976],[-171,-92],[-182,110],[-168,161],[274,101],[220,-54],[27,-226]],[[27981,87304],[-108,-310],[-123,50],[-73,176],[13,41],[107,177],[114,-13],[70,-121]],[[27250,87631],[-325,-326],[-196,13],[-61,160],[207,273],[381,-6],[-6,-114]],[[2297,88264],[171,-113],[173,61],[225,-156],[276,-79],[-23,-64],[-211,-125],[-211,128],[-106,107],[-245,-34],[-66,52],[17,223]],[[26344,89371],[51,-259],[143,91],[161,-155],[304,-203],[318,-184],[25,-281],[204,46],[199,-196],[-247,-186],[-432,142],[-156,266],[-275,-314],[-396,-306],[-95,346],[-377,-57],[242,292],[35,465],[95,542],[201,-49]],[[45969,89843],[-64,-382],[314,-403],[-361,-451],[-801,-405],[-240,-107],[-365,87],[-775,187],[273,261],[-605,289],[492,114],[-12,174],[-583,137],[188,385],[421,87],[433,-400],[422,321],[349,-167],[453,315],[461,-42]],[[28926,90253],[-312,-30],[-69,289],[118,331],[255,82],[217,-163],[3,-253],[-32,-82],[-180,-174]],[[0,91325],[681,-451],[728,-588],[-24,-367],[187,-147],[-64,429],[754,-88],[544,-553],[-276,-257],[-455,-61],[-7,-578],[-111,-122],[-260,17],[-212,206],[-369,172],[-62,257],[-283,96],[-315,-76],[-151,207],[60,219],[-333,-140],[126,-278],[-158,-251]],[[0,88971],[0,2354]],[[23431,91410],[-173,-207],[-374,179],[-226,-65],[-380,266],[245,183],[194,256],[295,-168],[166,-106],[84,-112],[169,-226]],[[0,92833],[99999,-404],[-305,-30],[-49,187],[-99645,247]],[[0,92833],[36,24],[235,-1],[402,-169],[-24,-81],[-286,-141],[-363,-36],[0,404]],[[24848,91640],[-1,-604],[371,463],[332,-380],[-83,-438],[269,-399],[290,427],[202,510],[16,648],[394,-45],[411,-87],[373,-293],[17,-293],[-207,-315],[196,-316],[-36,-288],[-544,-413],[-386,-91],[-287,178],[-83,-297],[-268,-498],[-81,-258],[-322,-400],[-397,-39],[-220,-250],[-18,-384],[-323,-74],[-340,-479],[-301,-665],[-108,-466],[-15,-686],[408,-99],[125,-553],[130,-448],[388,117],[517,-256],[277,-225],[199,-279],[348,-162],[294,-249],[459,-34],[302,-58],[-45,-511],[86,-594],[201,-661],[414,-561],[214,192],[150,607],[-145,934],[-196,311],[445,276],[314,415],[154,411],[-22,395],[-189,502],[-338,445],[328,619],[-121,535],[-93,922],[194,137],[476,-161],[286,-57],[230,155],[258,-200],[342,-343],[85,-229],[495,-45],[-8,-496],[92,-747],[254,-92],[201,-348],[402,328],[266,652],[184,274],[216,-527],[362,-754],[307,-709],[-112,-371],[370,-333],[250,-338],[442,-152],[179,-189],[110,-500],[216,-78],[112,-223],[20,-664],[-202,-222],[-199,-207],[-458,-210],[-349,-486],[-470,-96],[-594,125],[-417,4],[-287,-41],[-233,-424],[-354,-262],[-401,-782],[-320,-545],[236,97],[446,776],[583,493],[416,59],[245,-290],[-262,-397],[88,-637],[91,-446],[361,-295],[459,86],[278,664],[19,-429],[180,-214],[-344,-387],[-615,-351],[-276,-239],[-310,-426],[-211,44],[-11,500],[483,488],[-445,-19],[-309,-72],[48,-194],[-296,-286],[-286,-204],[-293,-175],[-159,-386],[-35,-98],[-3,-313],[92,-313],[115,-15],[-29,216],[83,-131],[-22,-169],[-188,-96],[-133,12],[-205,-104],[-121,-29],[-162,-29],[-231,-171],[408,111],[82,-112],[-389,-177],[-177,-1],[8,72],[-84,-164],[82,-27],[-60,-424],[-203,-455],[-20,152],[-61,31],[-91,147],[57,-318],[66,-106],[8,-222],[-89,-230],[-157,-472],[-25,24],[86,402],[-142,226],[-33,490],[-53,-255],[59,-375],[-175,88],[183,-186],[12,-562],[79,-41],[29,-204],[39,-591],[-176,-439],[-288,-175],[-182,-346],[-139,-38],[-141,-217],[-39,-199],[-305,-383],[-157,-281],[-131,-351],[-43,-419],[50,-411],[92,-505],[124,-418],[1,-256],[132,-685],[-9,-398],[-12,-230],[-69,-361],[-83,-74],[-137,71],[-44,259],[-106,136],[-147,508],[-129,452],[-42,231],[57,393],[-77,325],[-217,494],[-108,91],[-281,-269],[-49,30],[-135,276],[-174,146],[-314,-75],[-247,66],[-212,-41],[-118,-83],[54,-166],[-5,-240],[59,-117],[-53,-77],[-103,87],[-104,-112],[-202,18],[-207,313],[-242,-74],[-202,137],[-173,-42],[-234,-138],[-253,-438],[-276,-255],[-152,-282],[-63,-266],[-3,-407],[14,-284],[52,-201],[1,-1],[-1,-1],[-107,-516],[-49,-426],[-20,-791],[-27,-289],[48,-322],[86,-288],[56,-458],[184,-440],[65,-337],[109,-291],[295,-157],[114,-247],[244,165],[212,60],[208,106],[175,101],[176,241],[67,345],[22,496],[48,173],[188,155],[294,137],[246,-21],[169,50],[66,-125],[-9,-285],[-149,-351],[-66,-360],[51,-103],[-42,-255],[-69,-461],[-71,152],[-58,-10],[1,-87],[53,-3],[-5,-160],[-45,-256],[24,-91],[-29,-212],[18,-56],[-32,-299],[-55,-156],[-50,-19],[-55,-205],[90,-107],[24,88],[82,-75],[29,-23],[61,104],[79,8],[26,-48],[43,29],[129,-53],[128,16],[90,65],[32,66],[89,-31],[66,-40],[73,14],[55,51],[127,-82],[44,-13],[85,-110],[80,-132],[101,-91],[73,-162],[-24,-57],[-14,-132],[29,-216],[-64,-202],[-30,-237],[-9,-261],[15,-152],[7,-266],[-43,-58],[-26,-253],[19,-156],[-56,-151],[12,-159],[43,-97],[70,-321],[108,-238],[130,-252],[100,-212],[-6,-125],[111,-27],[26,48],[77,-145],[136,42],[119,150],[168,119],[95,176],[153,-34],[-10,-58],[155,-21],[124,-102],[90,-177],[105,-164],[143,-18],[209,412],[114,63],[3,195],[51,500],[159,274],[175,11],[22,123],[218,-49],[218,298],[109,132],[134,285],[98,-36],[73,-156],[-54,-199],[-8,-139],[-163,-69],[91,-268],[-3,-309],[-123,-343],[105,-469],[120,38],[62,427],[-86,208],[-14,447],[346,241],[-38,278],[97,186],[100,-415],[195,-9],[180,-330],[11,-195],[249,-6],[297,61],[159,-264],[213,-73],[155,184],[4,149],[344,35],[333,9],[-236,-175],[95,-279],[222,-44],[210,-291],[45,-473],[144,13],[109,-139],[183,-217],[171,-385],[8,-304],[105,-14],[149,-289],[109,-205],[333,-119],[30,107],[225,43],[298,-159],[95,-65],[204,-140],[294,-499],[46,-242],[95,28],[69,-327],[155,-1033],[149,-97],[7,-408],[-208,-487],[86,-178],[491,-92],[10,-593],[211,388],[349,-212],[462,-361],[135,-346],[-45,-327],[323,182],[540,-313],[415,23],[411,-489],[355,-662],[214,-170],[237,-24],[101,-186],[94,-752],[46,-358],[-110,-977],[-142,-385],[-391,-822],[-177,-668],[-206,-513],[-69,-11],[-78,-435],[20,-1107],[-77,-910],[-30,-390],[-88,-233],[-49,-790],[-282,-771],[-47,-610],[-225,-256],[-65,-355],[-302,2],[-437,-227],[-195,-263],[-311,-173],[-327,-470],[-235,-586],[-41,-441],[46,-326],[-51,-597],[-63,-289],[-195,-325],[-308,-1040],[-244,-468],[-189,-277],[-127,-562],[-183,-337],[-121,-372],[-313,-328],[-205,118],[-151,-63],[-256,253],[-189,-19],[-169,327],[-19,-308],[353,-506],[-38,-408],[173,-257],[-14,-289],[-267,-757],[-412,-317],[-557,-123],[-305,59],[59,-352],[-57,-442],[51,-298],[-167,-208],[-284,-82],[-267,216],[-108,-155],[39,-587],[188,-178],[152,186],[82,-307],[-255,-183],[-223,-367],[-41,-595],[-66,-316],[-262,-2],[-218,-302],[-80,-443],[274,-433],[265,-119],[-96,-531],[-328,-333],[-180,-692],[-254,-234],[-113,-276],[89,-614],[185,-342],[-117,30],[-247,4],[-134,-145],[-250,-213],[-45,-552],[-118,-14],[-313,192],[-318,412],[-346,338],[-87,374],[79,346],[-140,393],[-36,1007],[119,568],[293,457],[-422,172],[265,522],[94,982],[309,-208],[145,1224],[-186,157],[-87,-738],[-175,83],[87,845],[95,1095],[127,404],[-79,576],[-23,666],[117,19],[170,954],[192,945],[118,881],[-64,885],[83,487],[-34,730],[163,721],[50,1143],[89,1227],[87,1321],[-20,967],[-58,832],[-279,340],[-24,242],[-551,593],[-498,646],[-214,365],[-115,488],[46,170],[-236,775],[-274,1090],[-262,1177],[-114,269],[-87,435],[-216,386],[-198,239],[90,264],[-134,563],[86,414],[221,373],[148,442],[-60,258],[-106,-275],[-166,259],[56,167],[-47,536],[97,89],[52,368],[105,381],[-20,241],[153,126],[190,236],[-37,183],[103,44],[-12,296],[65,214],[138,40],[117,371],[106,310],[-102,141],[52,343],[-62,540],[59,155],[-44,500],[-112,315],[-93,170],[-59,319],[68,158],[-70,40],[-52,195],[-138,165],[-122,-38],[-56,-205],[-112,-149],[-61,-20],[-27,-123],[132,-321],[-75,-76],[-40,-87],[-130,-30],[-48,353],[-36,-101],[-92,35],[-56,238],[-114,39],[-72,69],[-119,-1],[-8,-128],[-32,89],[-151,131],[-56,124],[32,103],[-11,130],[-77,142],[-109,116],[-95,76],[-19,173],[-73,105],[18,-172],[-55,-141],[-64,164],[-89,58],[-38,120],[2,179],[36,187],[-78,83],[64,114],[-96,186],[-130,238],[-61,200],[-117,185],[-140,267],[31,92],[46,-89],[21,41],[-48,185],[-84,52],[-31,-140],[-161,9],[-100,57],[-115,117],[-154,37],[-79,127],[-142,103],[-174,11],[-127,117],[-149,244],[-314,636],[-144,192],[-226,154],[-156,-43],[-223,-223],[-140,-58],[-196,156],[-208,112],[-260,271],[-208,83],[-314,275],[-233,282],[-70,158],[-155,35],[-284,187],[-116,270],[-299,335],[-139,373],[-66,288],[93,57],[-29,169],[64,153],[1,204],[-93,266],[-25,235],[-94,298],[-244,587],[-280,462],[-135,368],[-238,241],[-51,145],[42,365],[-142,137],[-164,288],[-69,412],[-149,48],[-162,311],[-130,288],[-12,184],[-149,446],[-99,452],[5,227],[-201,235],[-93,-26],[-159,163],[-44,-240],[46,-284],[27,-444],[95,-243],[206,-407],[46,-139],[42,-42],[37,-203],[49,8],[56,-381],[85,-150],[59,-210],[174,-300],[92,-550],[83,-259],[77,-277],[15,-311],[134,-20],[112,-268],[100,-264],[-6,-106],[-117,-217],[-49,3],[-74,359],[-182,337],[-200,286],[-142,150],[9,432],[-42,320],[-132,183],[-191,264],[-37,-76],[-70,154],[-171,143],[-164,343],[20,44],[115,-33],[103,221],[10,266],[-214,422],[-163,163],[-102,369],[-103,388],[-129,472],[-113,531],[-46,302],[-180,340],[-130,71],[-30,169],[-156,30],[-100,159],[-258,59],[-70,95],[-34,324],[-270,594],[-231,821],[10,137],[-123,195],[-215,495],[-38,482],[-148,323],[61,489],[-10,507],[-89,453],[109,557],[67,1072],[-50,792],[-88,506],[-80,274],[33,115],[402,-200],[148,-558],[68,156],[-44,485],[-94,484],[-38,1],[-537,581],[-199,255],[-503,245],[-155,523],[40,362],[-356,252],[-48,476],[-336,429],[-6,304],[-153,223],[-245,188],[-78,515],[-358,478],[-150,558],[-267,38],[-441,15],[-326,170],[-574,613],[-266,112],[-486,211],[-385,-50],[-546,271],[-330,252],[-309,-125],[58,-411],[-154,-38],[-321,-123],[-245,-199],[-307,-126],[-40,348],[125,580],[295,182],[-76,148],[-354,-329],[-190,-394],[-400,-420],[203,-287],[-262,-424],[-299,-247],[-278,-181],[-69,-261],[-434,-305],[-87,-278],[-325,-252],[-191,45],[-259,-165],[-282,-201],[-231,-197],[-477,-169],[-43,99],[304,276],[271,182],[296,324],[345,66],[137,243],[385,353],[62,119],[205,208],[48,448],[141,349],[-320,-179],[-90,102],[-150,-215],[-181,300],[-75,-212],[-104,294],[-278,-236],[-170,0],[-24,352],[50,217],[-179,210],[-361,-113],[-235,277],[-190,142],[-1,334],[-214,252],[108,340],[226,330],[99,303],[225,43],[191,-94],[224,285],[201,-51],[212,183],[-52,270],[-155,106],[205,228],[-170,-7],[-295,-128],[-85,-131],[-219,131],[-392,-67],[-407,142],[-117,238],[-351,343],[390,247],[620,289],[228,0],[-38,-295],[586,22],[-225,366],[-342,226],[-197,295],[-267,252],[-381,187],[155,309],[493,19],[350,270],[66,287],[284,281],[271,68],[526,262],[256,-40],[427,315],[421,-124],[201,-266],[123,114],[469,-35],[-16,-136],[425,-101],[283,59],[585,-186],[534,-56],[214,-77],[370,96],[421,-177],[302,-83],[518,-142],[438,-284],[289,-55],[244,247],[336,184],[413,-72],[416,259],[455,148],[191,-245],[207,138],[62,278],[192,-63],[470,-530],[369,401],[38,-448],[341,96],[105,173],[337,-34],[424,-248],[650,-217],[383,-100],[272,38],[375,-300],[-391,-293],[502,-127],[750,70],[236,103],[296,-354],[302,299],[-283,251],[179,202],[338,27],[223,59],[224,-141],[279,-321],[310,47],[491,-266],[431,94],[405,-14],[-32,367],[247,103],[431,-200],[-2,-559],[177,471],[223,-16],[126,594],[-298,364],[-324,239],[22,653],[329,429],[366,-95],[281,-261],[378,-666],[-247,-290],[517,-120]],[[18287,93781],[-139,-277],[618,179],[386,-298],[314,302],[254,-194],[227,-580],[140,244],[-197,606],[244,86],[276,-94],[311,-239],[175,-575],[86,-417],[466,-293],[502,-279],[-31,-260],[-456,-48],[178,-227],[-94,-217],[-503,93],[-478,160],[-322,-36],[-522,-201],[-824,-103],[-374,-41],[-151,279],[-379,161],[-246,-66],[-343,468],[185,62],[429,101],[392,-26],[362,103],[-537,138],[-594,-47],[-394,12],[-146,217],[644,237],[-428,-9],[-485,156],[233,443],[193,235],[744,359],[284,-114]],[[20972,93958],[-244,-390],[-434,413],[95,83],[372,24],[211,-130]],[[28794,93770],[25,-163],[-296,17],[-299,13],[-304,-80],[-80,36],[-306,313],[12,213],[133,39],[636,-63],[479,-325]],[[25955,93803],[219,-369],[256,477],[704,242],[477,-611],[-42,-387],[550,172],[263,235],[616,-299],[383,-282],[36,-258],[515,134],[290,-376],[670,-234],[242,-238],[263,-553],[-510,-275],[654,-386],[441,-130],[400,-543],[437,-39],[-87,-414],[-487,-687],[-342,253],[-437,568],[-359,-74],[-35,-338],[292,-344],[377,-272],[114,-157],[181,-584],[-96,-425],[-350,160],[-697,473],[393,-509],[289,-357],[45,-206],[-753,236],[-596,343],[-337,287],[97,167],[-414,304],[-405,286],[5,-171],[-803,-94],[-235,203],[183,435],[522,10],[571,76],[-92,211],[96,294],[360,576],[-77,261],[-107,203],[-425,286],[-563,201],[178,150],[-294,367],[-245,34],[-219,201],[-149,-175],[-503,-76],[-1011,132],[-588,174],[-450,89],[-231,207],[290,270],[-394,2],[-88,599],[213,528],[286,241],[717,158],[-204,-382]],[[22123,94208],[331,-124],[496,75],[72,-172],[-259,-283],[420,-254],[-50,-532],[-455,-229],[-268,50],[-192,225],[-690,456],[5,189],[567,-73],[-306,386],[329,286]],[[89889,93835],[-421,-4],[-569,66],[-49,31],[263,234],[348,54],[394,-226],[34,-155]],[[24112,93575],[-298,-442],[-317,22],[-173,519],[4,294],[145,251],[276,161],[579,-20],[530,-144],[-415,-526],[-331,-115]],[[16539,92764],[-731,-294],[-147,259],[-641,312],[93,193],[218,489],[241,388],[-272,362],[939,93],[397,-123],[709,-33],[270,-171],[298,-249],[-349,-149],[-681,-415],[-344,-414],[0,-248]],[[91869,94941],[-321,-234],[-444,53],[-516,233],[66,192],[518,-89],[697,-155]],[[23996,94879],[-151,-229],[-403,44],[-337,155],[148,266],[399,159],[243,-208],[101,-187]],[[90301,95224],[-219,-439],[-1023,16],[-461,-139],[-550,384],[149,406],[366,111],[734,-26],[1004,-313]],[[22639,95907],[212,-273],[9,-303],[-127,-440],[-458,-60],[-298,94],[5,345],[-455,-46],[-18,457],[299,-18],[419,201],[390,-34],[22,77]],[[19941,95601],[109,-210],[247,99],[291,-26],[49,-289],[-169,-281],[-940,-91],[-701,-256],[-423,-14],[-35,193],[577,261],[-1255,-70],[-389,106],[379,577],[262,165],[782,-199],[493,-350],[485,-45],[-397,565],[255,215],[286,-68],[94,-282]],[[65981,92363],[-164,-52],[-907,77],[-74,262],[-503,158],[-40,320],[284,126],[-10,323],[551,503],[-255,73],[665,518],[-75,268],[621,312],[917,380],[925,110],[475,220],[541,76],[193,-233],[-187,-184],[-984,-293],[-848,-282],[-863,-562],[-414,-577],[-435,-568],[56,-491],[531,-484]],[[23699,96131],[308,-190],[547,1],[240,-194],[-64,-222],[319,-134],[177,-140],[374,-26],[406,-50],[441,128],[566,51],[451,-42],[298,-223],[62,-244],[-174,-157],[-414,-127],[-355,72],[-797,-91],[-570,-11],[-449,73],[-738,190],[-96,325],[-34,293],[-279,258],[-574,72],[-322,183],[104,242],[573,-37]],[[17722,96454],[-38,-454],[-214,-205],[-259,-29],[-517,-252],[-444,-91],[-377,128],[472,442],[570,383],[426,-9],[381,87]],[[63641,74970],[141,-419],[130,-28],[85,-159],[-228,-47],[-49,-459],[-47,-207],[-102,-138],[7,-293],[88,-436],[263,-123],[193,-296],[395,-102],[434,156],[27,139],[-52,417],[40,618],[-216,200],[71,405],[-184,34],[61,498],[262,-145],[244,189],[-202,355],[-80,338],[-224,-151],[-28,-433],[-87,383],[-15,144],[68,246],[-53,206],[-322,202],[-125,530],[-154,150],[-9,192],[270,-56],[11,432],[236,96],[243,-88],[50,576],[-50,365],[-278,-28],[-236,144],[-321,-260],[-259,-124],[-127,-350],[-269,-97],[-276,-610],[252,-561],[-27,-398],[303,-696],[146,-311]],[[0,88971],[99997,-3],[-357,-260],[-360,44],[250,-315],[166,-487],[128,-159],[32,-244],[-71,-157],[-518,129],[-777,-445],[-247,-69],[-425,-415],[-403,-362],[-102,-269],[-397,409],[-724,-464],[-126,220],[-268,-254],[-371,81],[-90,-388],[-333,-572],[10,-239],[316,-132],[-37,-860],[-258,-22],[-119,-494],[116,-255],[-486,-301],[-96,-675],[-415,-144],[-83,-600],[-400,-551],[-103,407],[-119,862],[-155,1313],[134,819],[234,353],[15,276],[431,132],[496,744],[479,608],[499,471],[223,833],[-337,-50],[-167,-487],[-705,-648],[-227,726],[-717,-201],[-696,-990],[230,-362],[-620,-154],[-430,-61],[20,427],[-431,90],[-344,-291],[-850,102],[-913,-175],[-900,-1153],[-1065,-1394],[438,-74],[136,-370],[270,-132],[178,296],[305,-39],[401,-650],[9,-502],[-217,-591],[-23,-705],[-126,-945],[-418,-855],[-94,-409],[-377,-688],[-374,-682],[-179,-349],[-370,-346],[-175,-8],[-175,287],[-373,-432],[-43,-197],[-106,36],[-120,-201],[-83,-201],[10,-424],[-143,-130],[-50,-105],[-104,-174],[-185,-97],[-121,-159],[-9,-256],[-32,-65],[111,-96],[157,-259],[240,-697],[68,-383],[3,-681],[-105,-325],[-252,-113],[-222,-245],[-250,-51],[-31,322],[52,443],[-123,615],[206,99],[-190,506],[-135,113],[-34,-112],[-81,-49],[-10,112],[-72,54],[-75,94],[77,260],[65,69],[-25,108],[71,319],[-18,97],[-163,64],[-131,158],[-388,-171],[-204,-277],[-300,-161],[148,274],[-58,230],[220,397],[-147,310],[-242,-209],[-314,-411],[-171,-381],[-272,-29],[-142,-275],[147,-400],[227,-97],[9,-265],[220,-172],[311,421],[247,-230],[179,-15],[46,-310],[-394,-165],[-130,-319],[-270,-296],[-142,-414],[299,-324],[109,-582],[169,-541],[189,-454],[-5,-439],[-174,-161],[66,-315],[164,-184],[-43,-481],[-71,-468],[-155,-53],[-203,-640],[-225,-775],[-258,-705],[-382,-545],[-386,-498],[-313,-68],[-170,-262],[-96,192],[-157,-294],[-388,-296],[-294,-90],[-95,-624],[-154,-35],[-73,429],[66,228],[-373,190],[-131,-97],[-371,-505],[-231,-558],[-61,-410],[212,-623],[260,-772],[252,-365],[169,-475],[127,-1093],[-37,-1039],[-232,-389],[-318,-381],[-227,-492],[-346,-550],[-101,378],[78,401],[-206,335],[-233,87],[-112,307],[-141,611],[-249,271],[-238,-11],[41,464],[-245,-3],[-22,-650],[-150,-863],[-90,-522],[19,-428],[181,-18],[113,-539],[50,-512],[155,-338],[168,-69],[144,-306],[64,-56],[164,-356],[116,-396],[16,-398],[-29,-269],[27,-203],[20,-349],[98,-163],[109,-523],[-5,-199],[-197,-40],[-263,438],[-329,469],[-32,301],[-161,395],[-38,489],[-100,322],[30,431],[-61,250],[-110,227],[-47,292],[-148,334],[-135,280],[-45,-347],[-53,328],[30,369],[82,566],[-27,439],[86,452],[-94,350],[23,644],[-113,306],[-90,707],[-50,746],[-121,490],[-183,-297],[-315,-421],[-156,53],[-172,138],[96,732],[-58,554],[-218,681],[34,213],[-163,76],[-197,481],[-79,309],[-16,301],[-53,284],[-116,344],[-256,23],[25,-243],[-87,-329],[-118,120],[-41,-108],[-78,65],[-108,53],[-39,-216],[-189,7],[-343,-122],[16,-445],[-148,-349],[-400,-398],[-311,-695],[-209,-373],[-276,-386],[-1,-272],[-138,-146],[-250,-212],[-130,-31],[-84,-450],[58,-769],[15,-490],[-118,-561],[-1,-1004],[-144,-29],[-126,-450],[84,-195],[-253,-167],[-93,-402],[-112,-170],[-263,552],[-128,827],[-107,596],[-97,279],[-148,568],[-69,739],[-48,369],[-253,811],[-115,1145],[-83,756],[1,716],[-54,553],[-404,-353],[-196,70],[-362,716],[133,214],[-82,232],[-326,501],[-203,150],[-83,425],[-215,449],[-512,-111],[-451,-11],[-391,-83],[-523,179],[-302,136],[-314,76],[-118,725],[-133,105],[-214,-106],[-280,-286],[-339,196],[-281,454],[-267,168],[-186,561],[-205,788],[-149,-96],[-177,196],[-103,-231],[-165,29],[58,-261],[-25,-135],[89,-445],[109,-510],[137,-135],[47,-207],[190,-248],[16,-244],[-27,-197],[35,-199],[80,-165],[37,-194],[41,-145],[-18,430],[75,310],[76,64],[84,-186],[5,-345],[-61,-348],[53,-226],[49,29],[11,-162],[217,93],[230,-15],[168,-18],[190,400],[207,379],[176,364],[80,201],[35,-51],[-26,-244],[-37,-108],[38,-466],[125,-404],[155,-214],[204,-78],[164,-107],[125,-339],[75,-196],[100,-75],[-1,-132],[-101,-352],[-44,-166],[-117,-189],[-104,-404],[-126,31],[-58,-141],[-44,-300],[34,-395],[-26,-72],[-128,2],[-174,-221],[-27,-288],[-63,-125],[-173,5],[-109,-149],[1,-239],[-134,-164],[-153,56],[-186,-199],[-128,-33],[-201,-159],[-54,-263],[-6,-201],[-277,-249],[-444,-276],[-249,-417],[-122,-32],[-83,34],[-163,-245],[-177,-113],[-233,-31],[-70,-34],[-61,-156],[-73,-43],[-42,-150],[-138,13],[-89,-80],[-192,30],[-72,345],[8,323],[-46,174],[-54,437],[-80,243],[56,29],[-29,270],[34,114],[-12,257],[-36,253],[-84,177],[-22,236],[-143,212],[-148,495],[-79,482],[-192,406],[-124,97],[-184,563],[-32,411],[12,350],[-159,655],[-130,231],[-150,122],[-92,339],[15,133],[-77,307],[-81,131],[-108,440],[-170,476],[-141,406],[-139,-3],[44,325],[12,206],[34,236],[-9,86],[-78,-238],[-60,-446],[-75,-308],[-65,-103],[-93,191],[-125,263],[-198,847],[-29,-53],[115,-624],[171,-594],[210,-920],[102,-321],[90,-334],[249,-654],[-55,-103],[9,-384],[323,-530],[49,-121],[90,-580],[-61,-107],[40,-608],[102,-706],[106,-145],[152,-219],[161,-683],[77,-543],[152,-288],[379,-558],[154,-336],[151,-341],[87,-203],[136,-178],[66,-183],[-9,-245],[-158,-142],[119,-161],[91,-109],[54,-244],[125,-248],[138,-2],[262,151],[302,70],[245,184],[138,39],[99,108],[158,20],[89,12],[128,88],[147,59],[132,202],[105,2],[6,-163],[-25,-344],[1,-310],[-59,-214],[-78,-639],[-134,-659],[-172,-755],[-238,-866],[-237,-661],[-327,-806],[-278,-479],[-415,-586],[-259,-450],[-304,-715],[-64,-312],[-63,-140],[-195,-236],[-68,-246],[-104,-44],[-40,-416],[-89,-238],[-54,-393],[-112,-195],[-128,-728],[16,-335],[178,-216],[8,-153],[-76,-357],[16,-180],[-18,-282],[97,-370],[115,-583],[101,-129],[45,-265],[-11,-588],[34,-519],[11,-923],[49,-290],[-83,-422],[-108,-410],[-177,-366],[-254,-225],[-313,-287],[-313,-634],[-107,-108],[-194,-420],[-115,-136],[-23,-421],[132,-448],[54,-346],[4,-177],[49,29],[-8,-579],[-45,-274],[65,-102],[-41,-246],[-116,-210],[-229,-199],[-334,-320],[-122,-219],[24,-248],[71,-40],[-24,-311],[-70,-430],[-32,-491],[-72,-267],[-190,-298],[-54,-86],[-118,-300],[-77,-303],[-158,-424],[-314,-609],[-196,-355],[-209,-269],[-291,-229],[-141,-31],[-36,-164],[-169,88],[-138,-113],[-301,114],[-168,-72],[-115,31],[-286,-233],[-238,-94],[-171,-223],[-127,-14],[-117,210],[-94,11],[-120,264],[-13,-82],[-37,159],[2,346],[-90,396],[89,108],[-7,453],[-182,553],[-139,501],[-1,1],[-199,768],[-207,446],[-108,432],[-62,575],[-68,428],[-93,910],[-7,707],[-35,322],[-108,243],[-144,489],[-146,708],[-60,371],[-226,577],[-17,453],[-26,372],[38,519],[96,541],[15,254],[90,532],[66,243],[159,386],[90,263],[29,438],[-15,335],[-83,211],[-74,358],[-68,355],[15,122],[85,235],[-84,570],[-57,396],[-139,374],[26,115],[-39,183],[-74,444],[-228,626],[-285,596],[-184,488],[-169,610],[9,196],[61,189],[67,430],[56,438],[-52,90],[96,663],[40,467],[-108,390],[-127,100],[-56,265],[-71,85],[3,163],[-289,-213],[-105,32],[-107,-133],[-222,13],[-149,370],[-91,427],[-197,390],[-209,-8],[-245,1],[-229,-69],[-224,-126],[-436,-346],[-154,-203],[-250,-171],[-248,168],[-126,-7],[-194,116],[-178,-7],[-329,-103],[-193,-170],[-275,-217],[-54,15],[-73,-5],[-286,282],[-252,450],[-237,323],[-187,381],[-75,44],[-200,238],[-144,316],[-49,216],[-34,437],[-122,349],[-108,232],[-71,76],[-69,118],[-32,261],[-41,130],[-80,97],[-149,247],[-117,39],[-63,166],[1,90],[-84,125],[-18,127],[-46,453],[36,262],[-115,460],[-138,210],[122,112],[134,415],[66,304],[-24,318],[78,291],[34,557],[-30,583],[-34,294],[28,295],[-72,281],[-146,255],[12,249],[13,274],[106,161],[91,308],[-18,200],[96,417],[155,376],[93,95],[74,344],[6,315],[100,365],[185,216],[177,603],[144,235],[259,66],[219,403],[139,158],[232,493],[-70,735],[106,508],[37,312],[179,399],[278,270],[206,244],[186,612],[87,362],[205,-2],[167,-251],[264,41],[288,-131],[121,-6],[267,323],[300,102],[175,244],[268,180],[471,105],[459,48],[140,-87],[262,232],[297,5],[113,-137],[190,35],[302,239],[195,-71],[-9,-299],[236,217],[20,-113],[-139,-289],[-2,-274],[96,-147],[-36,-511],[-183,-297],[53,-322],[143,-10],[70,-281],[106,-92],[326,-204],[117,51],[232,-98],[368,-264],[130,-526],[250,-114],[391,-248],[296,-293],[136,153],[133,272],[-65,452],[87,288],[200,277],[192,80],[375,-121],[95,-264],[104,-2],[88,-101],[276,-69],[68,-196],[369,10],[268,-156],[275,-175],[129,-92],[214,188],[114,169],[245,49],[198,-75],[75,-293],[65,193],[222,-140],[217,-33],[137,149],[80,194],[-19,34],[74,276],[56,446],[40,149],[8,6],[99,482],[138,416],[5,21],[-26,452],[68,243],[-102,268],[105,222],[-169,-51],[-233,136],[-191,-340],[-421,-66],[-225,317],[-300,20],[-64,-245],[-192,-71],[-268,315],[-303,-10],[-165,587],[-203,328],[135,459],[-176,283],[308,565],[428,23],[117,449],[529,-78],[334,383],[324,167],[459,13],[485,-416],[399,-229],[323,91],[239,-53],[328,309],[42,252],[-70,403],[-160,218],[-154,68],[-102,181],[-354,499],[-317,223],[-240,347],[202,95],[231,494],[-156,234],[410,241],[-8,129],[-249,-95],[-222,-48],[-185,-191],[-260,-31],[-239,-220],[16,-368],[136,-142],[284,35],[-55,-210],[-304,-103],[-377,-342],[-154,121],[61,277],[-304,173],[50,113],[265,197],[-80,135],[-432,149],[-19,221],[-257,-73],[-103,-325],[-215,-437],[6,-152],[-135,-128],[-84,56],[-78,-713],[-144,-245],[-101,-422],[89,-337],[33,-228],[243,-190],[-51,-145],[-330,-33],[-118,-182],[-232,-319],[-87,275],[3,122],[-169,17],[-145,56],[-336,-154],[192,-332],[-141,-96],[-154,0],[-147,304],[-52,-130],[62,-353],[139,-277],[-105,-130],[155,-272],[137,-171],[4,-334],[-257,157],[82,-302],[-176,-62],[105,-521],[-184,-7],[-228,257],[-104,472],[-49,393],[-108,272],[-143,337],[-18,168],[-48,41],[-5,130],[-154,199],[-24,281],[23,403],[38,184],[-46,93],[-59,46],[-78,192],[-120,118],[-261,218],[-161,213],[-254,176],[-233,435],[56,44],[-127,248],[-5,200],[-179,93],[-85,-255],[-82,198],[6,205],[10,9],[62,54],[-221,86],[-226,-210],[15,-293],[-34,-168],[91,-301],[261,-298],[140,-488],[309,-476],[217,3],[68,-130],[-78,-118],[249,-213],[204,-179],[238,-308],[29,-111],[-52,-211],[-154,276],[-242,97],[-116,-382],[200,-219],[-33,-309],[-116,-35],[-148,-506],[-116,-46],[1,181],[57,317],[60,126],[-108,342],[-85,298],[-115,74],[-82,255],[-179,107],[-120,238],[-206,38],[-217,267],[-254,384],[-189,341],[-86,584],[-138,68],[-226,195],[-128,-80],[-161,-274],[-115,-43],[-252,-334],[-548,160],[-404,-192],[-32,-355],[15,-344],[-263,-393],[-356,-125],[-25,-199],[-171,-327],[-107,-481],[108,-338],[-160,-263],[-60,-384],[-210,-118],[-197,-455],[-352,-8],[-265,11],[-174,-209],[-106,-223],[-136,49],[-103,199],[-79,340],[-259,92],[-112,-153],[-146,83],[-143,-65],[42,462],[-26,363],[-124,55],[-67,224],[22,386],[111,215],[20,239],[58,355],[-6,250],[-56,212],[-12,200],[14,420],[-114,257],[393,426],[340,-107],[373,4],[296,-101],[230,31],[449,-19],[144,354],[53,1177],[-287,620],[-205,299],[-424,228],[-28,430],[360,129],[466,-152],[-88,669],[263,-254],[646,461],[84,484],[243,119],[222,117],[143,162],[244,870],[380,247],[231,-17],[54,125],[232,32],[52,-130],[188,291],[-63,222],[-13,335],[-113,328],[-8,604],[46,159],[80,178],[244,36],[98,163],[223,167],[-9,-304],[-82,-192],[33,-166],[151,-89],[-68,-223],[-83,64],[-200,-425],[76,-288],[4,-228],[281,-138],[-3,-210],[283,111],[156,162],[313,-233],[132,-189],[189,174],[434,273],[350,200],[277,-100],[21,-144],[268,-7],[63,260],[383,191],[-59,497],[10,445],[136,371],[262,202],[221,-442],[223,12],[53,453],[32,349],[-102,-75],[-176,210],[-24,340],[351,164],[350,86],[301,-97],[287,17],[316,327],[-291,280],[-504,-47],[-489,-216],[-452,-125],[-161,322],[-269,195],[62,581],[-135,534],[133,344],[252,371],[635,640],[185,124],[-28,250],[-387,279],[-478,-167],[-269,-413],[43,-361],[-441,-475],[-537,-509],[-202,-832],[198,-416],[265,-328],[-255,-666],[-289,-138],[-106,-992],[-157,-554],[-337,57],[-158,-468],[-321,-27],[-89,558],[-232,671],[-211,835],[-187,363],[-548,-684],[-370,-138],[-385,301],[-99,635],[-88,1364],[256,380],[733,496],[549,609],[508,824],[668,1141],[465,444],[763,741],[610,259],[457,-31],[423,489],[506,-26],[499,118],[869,-433],[-358,-158],[305,-371],[286,206],[456,-358],[761,-140],[1050,-668],[213,-281],[18,-393],[-308,-311],[-454,-157],[-1240,449],[-204,-75],[453,-433],[36,-878],[358,-180],[217,-153],[36,286],[-174,263],[183,215],[672,-368],[234,144],[-187,433],[647,578],[256,-34],[260,-206],[161,406],[-231,352],[136,353],[-204,367],[777,-190],[158,-331],[-351,-73],[2,-328],[218,-203],[429,128],[68,377],[581,282],[969,507],[209,-29],[-273,-359],[344,-61],[199,202],[521,16],[412,245],[317,-356],[315,391],[-291,343],[145,195],[820,-179],[385,-185],[1006,-675],[186,309],[-282,313],[-8,125],[-335,58],[92,280],[-149,461],[-8,189],[512,535],[182,537],[207,116],[735,-156],[58,-328],[-263,-479],[173,-189],[89,-413],[-63,-809],[307,-362],[-120,-395],[-544,-839],[318,-87],[110,213],[306,151],[74,293],[240,281],[-162,336],[130,390],[-304,49],[-67,328],[222,594],[-361,481],[497,398],[-64,421],[139,13],[145,-328],[-109,-570],[297,-108],[-127,426],[465,233],[577,31],[513,-337],[-247,492],[-28,630],[484,119],[668,-26],[602,77],[-226,309],[321,388],[319,16],[540,293],[734,79],[93,162],[729,55],[227,-133],[624,314],[510,-10],[77,255],[265,252],[656,242],[476,-191],[-378,-146],[629,-90],[75,-292],[254,143],[812,-7],[626,-289],[223,-221],[-69,-307],[-307,-175],[-730,-328],[-209,-175],[345,-83],[410,-149],[250,112],[142,-379],[122,153],[444,93],[892,-97],[67,-276],[1162,-88],[15,451],[590,-103],[443,3],[449,-312],[128,-378],[-165,-247],[349,-465],[437,-240],[268,620],[446,-266],[473,159],[538,-182],[204,166],[455,-83],[-201,549],[367,256],[2509,-384],[236,-351],[727,-451],[1122,112],[553,-98],[231,-244],[-33,-432],[342,-168],[372,121],[492,15],[525,-116],[526,66],[484,-526],[344,189],[-224,378],[123,263],[886,-166],[578,36],[799,-282],[-99610,-258]],[[23933,96380],[-126,-17],[-521,38],[-74,165],[559,-9],[195,-109],[-33,-68]],[[19392,96485],[-518,-170],[-411,191],[224,188],[406,60],[392,-92],[-93,-177]],[[56867,96577],[-620,-241],[-490,137],[191,152],[-167,189],[575,119],[110,-222],[401,-134]],[[19538,97019],[-339,-115],[-461,1],[5,84],[285,177],[149,-27],[361,-120]],[[23380,96697],[-411,-122],[-226,138],[-119,221],[-22,245],[360,-24],[162,-39],[332,-205],[-76,-214]],[[22205,96856],[108,-247],[-453,66],[-457,192],[-619,21],[268,176],[-335,142],[-21,227],[546,-81],[751,-215],[212,-281]],[[79187,96845],[-1566,-228],[507,776],[229,66],[208,-38],[704,-336],[-82,-240]],[[55069,97669],[915,-440],[-699,-233],[-155,-435],[-243,-111],[-132,-490],[-335,-23],[-598,361],[252,210],[-416,170],[-541,499],[-216,463],[757,212],[152,-207],[396,8],[105,202],[408,20],[350,-206]],[[57068,98086],[545,-207],[-412,-318],[-806,-70],[-819,98],[-50,163],[-398,11],[-304,271],[858,165],[403,-142],[281,177],[702,-148]],[[64204,98169],[-373,-78],[-250,-45],[-39,-97],[-324,-98],[-301,140],[158,185],[-618,18],[542,107],[422,8],[57,-160],[159,142],[262,97],[412,-129],[-107,-90]],[[77760,97184],[-606,-73],[-773,170],[-462,226],[-213,423],[-379,117],[722,404],[600,133],[540,-297],[640,-572],[-69,-531]],[[25828,97644],[334,-190],[-381,-176],[-513,-445],[-492,-42],[-575,76],[-299,240],[4,215],[220,157],[-508,-4],[-306,196],[-176,268],[193,262],[192,180],[285,42],[-122,135],[646,30],[355,-315],[468,-127],[455,-112],[220,-390]],[[30972,99681],[742,-47],[597,-75],[508,-161],[-12,-157],[-678,-257],[-672,-119],[-251,-133],[605,3],[-656,-358],[-452,-167],[-476,-483],[-573,-98],[-177,-120],[-841,-64],[383,-74],[-192,-105],[230,-292],[-264,-202],[-429,-167],[-132,-232],[-388,-176],[39,-134],[475,23],[6,-144],[-742,-355],[-726,163],[-816,-91],[-414,71],[-525,31],[-35,284],[514,133],[-137,427],[170,41],[742,-255],[-379,379],[-450,113],[225,229],[492,141],[79,206],[-392,231],[-118,304],[759,-26],[220,-64],[433,216],[-625,68],[-972,-38],[-491,201],[-232,239],[-324,173],[-61,202],[413,112],[324,19],[545,96],[409,220],[344,-30],[300,-166],[211,319],[367,95],[498,65],[849,24],[148,-63],[802,100],[601,-38],[602,-37]],[[42472,99925],[1737,-469],[-513,-227],[-1062,-26],[-1496,-58],[140,-105],[984,65],[836,-204],[540,181],[231,-212],[-305,-344],[707,220],[1348,229],[833,-114],[156,-253],[-1132,-420],[-157,-136],[-888,-102],[643,-28],[-324,-431],[-224,-383],[9,-658],[333,-386],[-434,-24],[-457,-187],[513,-313],[65,-502],[-297,-55],[360,-508],[-617,-42],[322,-241],[-91,-208],[-391,-91],[-388,-2],[348,-400],[4,-263],[-549,244],[-143,-158],[375,-148],[364,-361],[105,-476],[-495,-114],[-214,228],[-344,340],[95,-401],[-322,-311],[732,-25],[383,-32],[-745,-515],[-755,-466],[-813,-204],[-306,-2],[-288,-228],[-386,-624],[-597,-414],[-192,-24],[-370,-145],[-399,-138],[-238,-365],[-4,-415],[-141,-388],[-453,-472],[112,-462],[-125,-488],[-142,-577],[-391,-36],[-410,482],[-556,3],[-269,324],[-186,577],[-481,735],[-141,385],[-38,530],[-384,546],[100,435],[-186,208],[275,691],[418,220],[110,247],[58,461],[-318,-209],[-151,-88],[-249,-84],[-341,193],[-19,401],[109,314],[258,9],[567,-157],[-478,375],[-249,202],[-276,-83],[-232,147],[310,550],[-169,220],[-220,409],[-335,626],[-353,230],[3,247],[-745,346],[-590,43],[-743,-24],[-677,-44],[-323,188],[-482,372],[729,186],[559,31],[-1188,154],[-627,241],[39,229],[1051,285],[1018,284],[107,214],[-750,213],[243,235],[961,413],[404,63],[-115,265],[658,156],[854,93],[853,5],[303,-184],[737,325],[663,-221],[390,-46],[577,-192],[-660,318],[38,253],[932,353],[975,-27],[354,218],[982,57],[2219,-74]]],"bbox":[-180,-85.60903777459777,180,83.64513000000002],"transform":{"scale":[0.0036000360003600037,0.0016925586033320111],"translate":[-180,-85.60903777459777]}} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css b/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css new file mode 100644 index 0000000..f8d2a01 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/dashboard.css @@ -0,0 +1,184 @@ +/* Fonts — remove this @import if your app already loads IBM Plex */ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +/* Переменные и резеты дашборда оскоплены на .cdash, чтобы не течь в остальное + приложение (см. INTEGRATION.md §6). В частности, --line здесь свой и не должен + переопределять --line из styles/theme.css на уровне всего приложения. */ +.cdash { + --bg0:#0a0c0b; --bg1:#0e1211; --panel:#111514; --ins:#0a0e0d; + --line:rgba(255,255,255,0.06); --line2:rgba(255,255,255,0.11); + --hi:#e7ece9; --mid:#95a19d; --dim:#5b6662; --faint:#3c4644; + --teal:#5fb3a3; --teal-bg:rgba(95,179,163,0.13); --teal-br:rgba(95,179,163,0.35); + --amber:#d99a4e; --blue:#5b9bd5; --red:#d9685f; --green:#5cae7e; --violet:#a78bdb; + color:var(--hi); font-family:'IBM Plex Sans',system-ui,sans-serif; font-size:13px; + -webkit-font-smoothing:antialiased; +} +.cdash *{box-sizing:border-box;} +.mono,.clock-time,.sr-norad,.map-cursor,.tl-tick,.fc-meta{font-family:'IBM Plex Mono',monospace;} +.cdash button{font-family:inherit; cursor:pointer; color:inherit;} +.cdash ::-webkit-scrollbar{width:8px;height:8px;} +.cdash ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.07);border-radius:4px;} +.cdash ::-webkit-scrollbar-track{background:transparent;} + +/* Встроено вкладкой в shell с собственной шапкой: высота от родителя, не 100vh + (см. INTEGRATION.md §4). display:contents у обёртки вкладки делает .app прямым + элементом grid-строки shell (minmax(0,1fr)). */ +.app{height:100%; display:flex; flex-direction:column; overflow:hidden;} + +/* ---------- header ---------- */ +.hdr{ + height:54px; flex:none; display:flex; align-items:center; + border-bottom:1px solid var(--line); background:linear-gradient(var(--bg1),var(--bg0)); + padding:0 16px; gap:18px; position:relative; z-index:5; +} +.hdr-brand{display:flex; align-items:center; gap:11px; min-width:248px;} +.brand-mark{width:20px;height:20px;border-radius:50%;border:1.5px solid var(--teal); + box-shadow:0 0 10px rgba(95,179,163,0.4) inset, 0 0 6px rgba(95,179,163,0.3);} +.brand-1{font-weight:600; font-size:13px; letter-spacing:.04em; white-space:nowrap;} +.brand-2{font-family:'IBM Plex Mono',monospace; font-size:9px; color:var(--dim); letter-spacing:.12em; margin-top:1px;} + +.hdr-transport{flex:1; display:flex; align-items:center; justify-content:center; gap:7px;} +.t-btn{background:var(--bg1); border:1px solid var(--line2); color:var(--mid); + height:30px; min-width:34px; padding:0 11px; border-radius:6px; font-size:11.5px; letter-spacing:.04em; + transition:.12s;} +.t-btn:hover{border-color:var(--teal-br); color:var(--hi);} +.t-play{font-size:12px; color:var(--teal); border-color:var(--teal-br); background:var(--teal-bg); width:42px;} +.t-play.on{background:rgba(95,179,163,0.2);} +.t-now{font-size:10.5px; letter-spacing:.1em;} +.t-now.live{background:rgba(92,174,126,0.16); border-color:rgba(92,174,126,0.5); color:var(--green);} +.t-sep{width:1px; height:20px; background:var(--line2); margin:0 4px;} +.speed-seg{display:flex; border:1px solid var(--line2); border-radius:6px; overflow:hidden; background:var(--bg1);} +.speed-seg .sp{background:transparent; border:0; color:var(--dim); height:28px; padding:0 11px; font-size:11px; + font-family:'IBM Plex Mono',monospace; border-right:1px solid var(--line); transition:.12s;} +.speed-seg .sp:last-child{border-right:0;} +.speed-seg .sp:hover{color:var(--mid);} +.speed-seg .sp.on{background:var(--teal-bg); color:var(--teal);} + +.hdr-clock{display:flex; flex-direction:column; align-items:flex-end; min-width:240px; gap:1px;} +.live-badge{display:flex; align-items:center; gap:5px; font-size:9.5px; letter-spacing:.16em; color:var(--amber); + font-family:'IBM Plex Mono',monospace;} +.live-badge .live-dot{width:6px;height:6px;border-radius:50%;background:var(--amber);} +.live-badge.on{color:var(--green);} +.live-badge.on .live-dot{background:var(--green); box-shadow:0 0 6px var(--green); animation:pulse 1.4s infinite;} +@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.clock-main{display:flex; align-items:baseline; gap:6px;} +.clock-time{font-size:22px; font-weight:500; letter-spacing:.02em; line-height:1;} +.clock-utc{font-size:9px; color:var(--dim); letter-spacing:.1em;} +.clock-date{font-size:10px; color:var(--mid); font-family:'IBM Plex Mono',monospace; letter-spacing:.06em;} + +/* ---------- body ---------- */ +.body{flex:1; display:flex; min-height:0;} +.side{width:328px; flex:none; border-right:1px solid var(--line); background:var(--bg1); + display:flex; flex-direction:column; overflow:hidden;} +.main{flex:1; display:flex; flex-direction:column; min-width:0;} + +.sec-label{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.16em; color:var(--dim); + padding:13px 16px 8px; display:flex; align-items:center; gap:8px;} +.sec-label.tight{padding-top:6px;} +.sec-label .count{background:var(--bg0); border:1px solid var(--line2); border-radius:8px; padding:0 6px; + font-size:9px; color:var(--mid);} + +/* summary */ +.summary{padding:0 14px 6px;} +.summary-tiles{display:grid; grid-template-columns:repeat(4,1fr); gap:6px;} +.s-tile{background:var(--ins); border:1px solid var(--line); border-radius:7px; padding:9px 4px 7px; text-align:center;} +.s-num{font-family:'IBM Plex Mono',monospace; font-size:21px; font-weight:600; line-height:1;} +.s-lbl{font-size:8px; color:var(--dim); letter-spacing:.1em; margin-top:5px;} +.summary-foot{display:flex; align-items:center; gap:13px; padding:9px 2px 2px; font-size:10px; + color:var(--mid); font-family:'IBM Plex Mono',monospace;} +.summary-foot .dot,.r-domain .dot{width:7px;height:7px;border-radius:2px;display:inline-block;margin-right:5px;vertical-align:middle;} +.summary-foot .muted{margin-left:auto; color:var(--dim);} + +/* search */ +.side-search{padding:4px 14px 2px;} +.side-search input{width:100%; background:var(--ins); border:1px solid var(--line); border-radius:6px; + color:var(--hi); padding:7px 10px; font-size:11.5px; outline:none; font-family:'IBM Plex Mono',monospace;} +.side-search input::placeholder{color:var(--faint);} +.side-search input:focus{border-color:var(--teal-br);} + +/* roster */ +.roster{flex:1; min-height:0; overflow-y:auto; padding:0 8px 8px;} +.r-domain{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.14em; color:var(--mid); + padding:11px 8px 5px; display:flex; align-items:center;} +.r-group{font-size:9.5px; color:var(--faint); letter-spacing:.08em; padding:3px 8px 3px 20px; text-transform:uppercase;} +.sat-row{display:flex; gap:9px; padding:7px 8px 7px 10px; border-radius:7px; cursor:pointer; + align-items:center; transition:.1s; border:1px solid transparent;} +.sat-row:hover{background:rgba(255,255,255,0.025);} +.sat-row.focus{background:var(--teal-bg); border-color:var(--teal-br);} +.sr-payload{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.sr-main{flex:1; min-width:0;} +.sr-top{display:flex; justify-content:space-between; align-items:baseline; gap:8px;} +.sr-name{font-size:12.5px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.sr-status{font-size:9px; letter-spacing:.08em; font-family:'IBM Plex Mono',monospace; flex:none; display:flex; align-items:center; gap:3px;} +.sr-status .ecl{opacity:.85;} +.sr-bot{display:flex; align-items:center; gap:8px; margin-top:4px;} +.sr-norad{font-size:9px; color:var(--dim); flex:none;} +.mem-bar{flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden;} +.mem-bar span{display:block; height:100%; border-radius:2px; transition:width .2s;} +.sr-mem{font-size:9.5px; font-family:'IBM Plex Mono',monospace; flex:none; width:30px; text-align:right;} + +/* activity */ +.activity{flex:none; max-height:34%; display:flex; flex-direction:column; border-top:1px solid var(--line); overflow:hidden;} +.act-list{overflow-y:auto; padding:0 8px 8px;} +.act-empty{color:var(--faint); font-size:11px; padding:6px 10px; font-family:'IBM Plex Mono',monospace;} +.act-row{display:flex; gap:8px; padding:6px 8px; border-radius:6px; cursor:pointer; align-items:center;} +.act-row:hover{background:rgba(255,255,255,0.025);} +.act-row.focus{background:var(--teal-bg);} +.act-bar{width:3px; align-self:stretch; border-radius:2px; flex:none;} +.act-body{flex:1; min-width:0;} +.act-top{display:flex; gap:7px; align-items:baseline;} +.act-type{font-size:9.5px; letter-spacing:.1em; font-family:'IBM Plex Mono',monospace;} +.act-tgt{font-size:11px; color:var(--hi); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.act-sub{display:flex; justify-content:space-between; font-size:9.5px; color:var(--dim); margin-top:2px; font-family:'IBM Plex Mono',monospace;} +.act-rem{color:var(--mid);} + +/* ---------- map ---------- */ +.map-area{flex:1; position:relative; min-height:0; overflow:hidden; background:var(--ins);} +.map-wrap{position:absolute; inset:0;} +.map-wrap canvas{display:block;} +.map-caption{position:absolute; top:12px; left:12px; z-index:3;} +.cap-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; letter-spacing:.12em; color:var(--faint); + background:rgba(10,14,13,0.6); border:1px solid var(--line); padding:6px 10px; border-radius:6px; white-space:nowrap;} +.focus-card{display:flex; align-items:stretch; gap:11px; background:rgba(11,16,15,0.92); backdrop-filter:blur(6px); + border:1px solid var(--teal-br); border-radius:8px; padding:10px 13px; min-width:330px;} +.fc-pay{width:3px; border-radius:2px; flex:none;} +.fc-info{flex:1;} +.fc-name{font-size:14px; font-weight:600; display:flex; justify-content:space-between; align-items:center;} +.fc-x{background:none; border:0; color:var(--dim); font-size:12px; padding:0 2px;} +.fc-x:hover{color:var(--hi);} +.fc-meta{font-size:9.5px; color:var(--mid); margin-top:4px; display:flex; gap:6px; align-items:center; letter-spacing:.04em;} +.fc-dot{color:var(--faint);} +.fc-pos{text-align:right; font-family:'IBM Plex Mono',monospace; font-size:10px; color:var(--mid); display:flex; flex-direction:column; justify-content:center; gap:4px;} +.fc-mem b{color:var(--hi);} + +.map-legend{position:absolute; bottom:12px; left:12px; z-index:3; background:rgba(10,14,13,0.55); + border:1px solid var(--line); border-radius:7px; padding:8px 11px; display:flex; flex-direction:column; gap:5px;} +.ml-row{display:flex; align-items:center; gap:8px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--mid); white-space:nowrap;} +.ml-tri{width:0;height:0;border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:7px solid #cdd6d3;} +.ml-sw{width:14px; height:3px; border-radius:2px; background:var(--teal);} +.ml-sw.amber{background:var(--amber);} +.ml-sw.blue{background:var(--blue);} +.ml-sw.blue.dash{background:repeating-linear-gradient(90deg,var(--blue) 0 4px,transparent 4px 7px);} +.ml-sw.night{background:rgba(90,120,170,0.5); height:9px; width:14px; border-radius:2px;} + +.map-cursor{position:absolute; bottom:10px; right:14px; z-index:3; font-family:'IBM Plex Mono',monospace; + font-size:10px; color:var(--mid); background:rgba(10,14,13,0.6); padding:3px 8px; border-radius:5px; border:1px solid var(--line);} +.map-tip{position:absolute; z-index:4; pointer-events:none; font-family:'IBM Plex Mono',monospace; font-size:10px; + background:rgba(11,16,15,0.95); border:1px solid var(--line2); padding:3px 7px; border-radius:5px; color:var(--hi); white-space:nowrap;} + +/* ---------- timeline ---------- */ +.tl-area{height:248px; flex:none; border-top:1px solid var(--line); background:var(--bg1); display:flex; flex-direction:column;} +.tl-head{height:34px; flex:none; display:flex; align-items:center; gap:18px; padding:0 16px; border-bottom:1px solid var(--line);} +.tl-title{font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.16em; color:var(--mid);} +.tl-legend{display:flex; gap:15px; flex:1;} +.leg-item{display:flex; align-items:center; gap:6px; font-family:'IBM Plex Mono',monospace; font-size:9px; + letter-spacing:.08em; color:var(--dim);} +.leg-sw{width:13px; height:8px; border-radius:2px; display:inline-block;} +.leg-sw.hatch{background:repeating-linear-gradient(45deg,rgba(217,104,95,0.7) 0 2px,rgba(217,104,95,0.12) 2px 5px); border:1px solid rgba(217,104,95,0.5);} +.tl-hint{font-family:'IBM Plex Mono',monospace; font-size:9.5px; color:var(--faint); letter-spacing:.06em;} +.tl-wrap{flex:1; overflow-y:auto; overflow-x:hidden; user-select:none; cursor:crosshair;} +.tl-tick{fill:var(--dim); font-size:9px; font-family:'IBM Plex Mono',monospace;} +.tl-group{fill:var(--faint); font-size:9px; letter-spacing:.14em; font-family:'IBM Plex Mono',monospace;} +.tl-name{fill:var(--mid); font-size:10.5px;} +.tl-nowlbl{fill:rgba(95,179,163,0.6); font-size:8px; letter-spacing:.12em; font-family:'IBM Plex Mono',monospace;} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/data.js b/services/pcp-tgu-ops-ui/src/features/constellation/data.js new file mode 100644 index 0000000..d66b900 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/data.js @@ -0,0 +1,73 @@ +/* ============================================================ + data.js — constellation model (ESM) + Группировка: ОПТИКА + ЛОКАЦИЯ, с группами КА. + См. INTEGRATION.md — эти массивы предполагается заменить данными из API. + ============================================================ */ +export const COL = { + optic: "#56c2c8", // оптика — cyan + radar: "#a78bdb", // локация (РСА) — violet + }; + + // --- Satellites ------------------------------------------------- + // orbit: latAmp(deg amplitude of sub-sat latitude), period(min), + // phase(0..1 of period), lon0(deg start longitude) + // mem0: initial onboard memory fill % + const sats = [ + // ============ ОПТИКА ============ + { id: "res3", name: "Ресурс-П №3", norad: 41386, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.2, phase: 0.02, lon0: 12 }, mem0: 38 }, + { id: "res4", name: "Ресурс-П №4", norad: 41387, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.6, phase: 0.34, lon0: 155 }, mem0: 64 }, + { id: "res5", name: "Ресурс-П №5", norad: 41388, payload: "optic", domain: "ОПТИКА", group: "Ресурс-П", + orbit: { latAmp: 81, period: 94.4, phase: 0.67, lon0: -98 }, mem0: 21 }, + { id: "kan6", name: "Канопус-В №6", norad: 43657, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.1, phase: 0.12, lon0: 64 }, mem0: 52 }, + { id: "kan7", name: "Канопус-В №7", norad: 43658, payload: "optic", domain: "ОПТИКА", group: "Канопус-В", + orbit: { latAmp: 78, period: 96.4, phase: 0.55, lon0: -150 }, mem0: 79 }, + + // ============ ЛОКАЦИЯ (РСА) ============ + { id: "kon1", name: "Кондор-ФКА №1", norad: 57350, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.7, phase: 0.20, lon0: 33 }, mem0: 45 }, + { id: "kon2", name: "Кондор-ФКА №2", norad: 62138, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.9, phase: 0.62, lon0: 178 }, mem0: 17 }, + { id: "kon3", name: "Кондор-ФКА №3", norad: 62139, payload: "radar", domain: "ЛОКАЦИЯ", group: "Кондор-ФКА", + orbit: { latAmp: 74, period: 92.5, phase: 0.88, lon0: -70 }, mem0: 70 }, + { id: "obz1", name: "Обзор-Р №1", norad: 60001, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 97.8, phase: 0.30, lon0: 100 }, mem0: 33 }, + { id: "obz2", name: "Обзор-Р №2", norad: 60002, payload: "radar", domain: "ЛОКАЦИЯ", group: "Обзор-Р", + orbit: { latAmp: 67, period: 98.1, phase: 0.74, lon0: -25 }, mem0: 58 }, + ]; + sats.forEach((s) => { s.color = COL[s.payload]; }); + + // --- Ground stations ------------------------------------------- + const stations = [ + { id: "msk", name: "Москва", lon: 37.6, lat: 55.7 }, + { id: "mur", name: "Мурманск", lon: 33.1, lat: 68.9 }, + { id: "ana", name: "Анадырь", lon: 177.5, lat: 64.7 }, + { id: "kha", name: "Хабаровск", lon: 135.1, lat: 48.5 }, + { id: "kgd", name: "Калининград", lon: 20.5, lat: 54.7 }, + { id: "svb", name: "Шпицберген", lon: 15.6, lat: 78.2 }, + { id: "ant", name: "Прогресс", lon: 76.4, lat: -69.4 }, + ]; + + // --- Targets / ROIs (заявки на съёмку) ------------------------- + // rect helper: center + half-width/height in degrees + function rect(lon, lat, hw, hh) { + return [ + [lon - hw, lat - hh], [lon + hw, lat - hh], + [lon + hw, lat + hh], [lon - hw, lat + hh], + ]; + } + const rois = [ + { id: "gib", name: "Гибралтар", payload: "optic", poly: rect(-5.4, 36.0, 3.2, 2.4) }, + { id: "blk", name: "Чёрное море", payload: "optic", poly: rect(34.0, 43.5, 6.0, 3.0) }, + { id: "jpn", name: "Японские о-ва", payload: "radar", poly: rect(140.5, 37.5, 3.4, 6.5) }, + { id: "kam", name: "Камчатка", payload: "radar", poly: rect(159.0, 56.0, 4.5, 5.0) }, + { id: "cal", name: "Калифорния", payload: "optic", poly: rect(-119.5, 36.5, 3.5, 4.5) }, + { id: "syr", name: "Сирия", payload: "optic", poly: rect(38.5, 35.0, 3.0, 2.6) }, + { id: "sah", name: "о.Сахалин", payload: "radar", poly: rect(143.0, 50.5, 2.2, 4.8) }, + { id: "kur", name: "Курилы", payload: "radar", poly: rect(151.0, 46.0, 2.0, 3.6) }, + ]; + +export { sats as SATELLITES, stations as STATIONS, rois as ROIS }; + diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/index.js b/services/pcp-tgu-ops-ui/src/features/constellation/index.js new file mode 100644 index 0000000..ab53214 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/index.js @@ -0,0 +1,10 @@ +/* index.js — public entry for the dashboard module */ +export { default } from "./ConstellationDashboard.jsx"; +export { default as ConstellationDashboard } from "./ConstellationDashboard.jsx"; + +// granular exports (in case you want to recompose the layout) +export { default as MapView } from "./MapView.jsx"; +export { default as Timeline } from "./Timeline.jsx"; +export { Header, SidePanel, STATUS_META, fmtDur } from "./Panels.jsx"; +export * as SIM from "./sim.js"; +export { SATELLITES, STATIONS, ROIS, COL } from "./data.js"; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts new file mode 100644 index 0000000..3558a0e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/realData.ts @@ -0,0 +1,424 @@ +/* ============================================================ + realData.ts — реальный источник данных для дашборда «Группировка». + Заменяет встроенную симуляцию (data.js/sim.js) данными из API по контрактам + INTEGRATION.md §5: + • состав — платформы НСИ (tgu-planning), станции, заявки (ROI); + • положение — реальная трасса flight-line (сэмплер позиции по времени); + • сброс — зоны радиовидимости (ЗРВ) из баллистики; + • съёмка — пролёт подспутниковой точки над полигоном заявки; + • тень — астрономическая модель из sim.js. + Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа, + так что ConstellationDashboard рендерит реальные данные без изменений разметки. + ============================================================ */ +import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchSatelliteRva } from "../../api/ballisticsApi"; +import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi"; +import type { TguPlatform, TguPlan } from "../../model/tguTypes"; +import type { RequestMapItem } from "../../model/requestTypes"; +import { isOperationalPlatform } from "../tgu-planning/tguTimelineMapper"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import { parseLocal } from "../../utils/localTime"; +// sim.js/data.js — JS-модули прототипа (allowJs); типы any, нам достаточно. +import { buildSchedule, wrapLon, pointInPoly } from "./sim.js"; +import { COL } from "./data.js"; + +/** Полный объём бортового накопителя, Мбайт (500 ГБ). Согласовано как модельная ёмкость. */ +const ONBOARD_CAPACITY_MB = 500 * 1024; + +type Payload = "optic" | "radar"; + +export interface ConstellationSat { + id: string; + name: string; + norad: number; + payload: Payload; + domain: "ОПТИКА" | "ЛОКАЦИЯ"; + group: string; + color: string; + mem0: number; +} + +export interface ConstellationStation { + id: string; + name: string; + lon: number; + lat: number; +} + +export interface ConstellationRoi { + id: string; + name: string; + payload: Payload; + poly: [number, number][]; +} + +export interface Interval { + start: number; + end: number; + label?: string; + /** Контур съёмки (для подсветки на карте), [lon,lat][]. Только у интервалов capture. */ + poly?: [number, number][]; + /** Координаты станции приёма (для линии сброса на карте). Только у интервалов downlink. */ + lon?: number; + lat?: number; +} + +export interface SchedTracks { + capture: Interval[]; + downlink: Interval[]; + /** Связь с КА — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». */ + link: Interval[]; + prohibit: Interval[]; + shadow: Interval[]; +} + +export interface ConstellationEnv { + epochMs: number; + sats: ConstellationSat[]; + stations: ConstellationStation[]; + rois: ConstellationRoi[]; + prohibits: Record; + /** Подспутниковая точка по реальной трассе; null — нет данных на это время. */ + subSat: (sat: ConstellationSat, tMs: number) => { lon: number; lat: number } | null; + /** Готовое расписание (съёмка/сброс/связь/тень) — из него statusAt берёт состояние. */ + sched?: Record; + /** Заполнение бортового накопителя на момент tMs, % от ёмкости. Из объёмов маршрутов плана. */ + memAt?: (sat: ConstellationSat, tMs: number) => number; +} + +export interface ConstellationConfig { + env: ConstellationEnv; + domain: { start: number; end: number }; + sched: Record; +} + +// --- классификация полезной нагрузки по имени КА (из НСИ) ------------------- +// Поля payload у платформы нет — определяем по имени/миссии (радиолокация vs оптика). +const RADAR_KEYS = [ + "кондор", "kondor", "обзор", "obzor", "sar", "рса", "radar", "локац", + "аркон", "arkon", "стрела", "strela", "пион", "neitron", "нейтрон" +]; + +function classifyPayload(name: string, mission: string | null | undefined): Payload { + const s = `${name} ${mission ?? ""}`.toLowerCase(); + return RADAR_KEYS.some((k) => s.includes(k)) ? "radar" : "optic"; +} + +// Человеческие названия известных миссий НСИ → подгруппа в списке/таймлайне. +const MISSION_RU: Record = { + RESURS_P: "Ресурс-П", + KANOPUS_V: "Канопус-В", + KONDOR_FKA: "Кондор-ФКА", + OBZOR_R: "Обзор-Р", + TERRA_AQUA: "Terra/Aqua" +}; + +function prettifyMission(mission: string): string { + return mission + .toLowerCase() + .split("_") + .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)) + .join(" "); +} + +function deriveGroup(name: string, mission: string | null | undefined): string { + if (mission) return MISSION_RU[mission] ?? prettifyMission(mission); + // нет миссии — отрезаем хвост «No.3» / «№3» / завершающий номер + const base = name.replace(/\s*(No\.?|№)\s*\d+.*$/i, "").trim(); + return base || name; +} + +// --- сэмплер позиции по трассе flight-line --------------------------------- +type Sample = { t: number; lon: number; lat: number }; + +function buildSampler(points: FlightLinePoint[]): (t: number) => { lon: number; lat: number } | null { + const arr: Sample[] = points + .map((p) => ({ t: parseLocal(p.time), lon: wrapLon(p.long), lat: p.lat })) + .filter((s) => Number.isFinite(s.t) && Number.isFinite(s.lon) && Number.isFinite(s.lat)) + .sort((a, b) => a.t - b.t); + + if (arr.length === 0) return () => null; + const first = arr[0]; + const last = arr[arr.length - 1]; + + return (t: number) => { + if (t <= first.t) return { lon: first.lon, lat: first.lat }; + if (t >= last.t) return { lon: last.lon, lat: last.lat }; + let lo = 0; + let hi = arr.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (arr[mid].t <= t) lo = mid; + else hi = mid; + } + const a = arr[lo]; + const b = arr[hi]; + const f = (t - a.t) / (b.t - a.t || 1); + const lat = a.lat + (b.lat - a.lat) * f; + // долготу интерполируем по кратчайшей дельте, чтобы не прыгать через антимеридиан + let dlon = b.lon - a.lon; + if (dlon > 180) dlon -= 360; + else if (dlon < -180) dlon += 360; + return { lon: wrapLon(a.lon + dlon * f), lat }; + }; +} + +// --- ROI из заявок ---------------------------------------------------------- +const DEAD_REQUEST = new Set(["DELETED", "EXPIRED"]); + +function roiFromRequest(item: RequestMapItem): ConstellationRoi | null { + if (DEAD_REQUEST.has(item.status)) return null; + const rings = parseWktPolygon(item.geometry); + const ring = rings[0]; + if (!ring || ring.length < 3) return null; + const poly: [number, number][] = ring.map((pt) => [wrapLon(pt.lon), pt.lat]); + const payload: Payload = item.surveyType === "RSA" ? "radar" : "optic"; + return { id: item.id, name: item.name || item.id, payload, poly }; +} + +// --- порядок КА: домен (ОПТИКА → ЛОКАЦИЯ) → группа → имя -------------------- +// Список и таймлайн группируют по соседству, поэтому порядок важен. +function compareSats(a: ConstellationSat, b: ConstellationSat): number { + if (a.domain !== b.domain) return a.domain === "ОПТИКА" ? -1 : 1; + if (a.group !== b.group) return a.group.localeCompare(b.group, "ru"); + return a.name.localeCompare(b.name, "ru"); +} + +// --- съёмка/сброс из реального плана ----------------------------------------- +type StationGeo = { name: string; lon: number; lat: number }; + +interface PlanTracks { + capture: Interval[]; + downlink: Interval[]; + /** Точки изменения заполнения борта: +volumeMb на конце съёмки, −volumeMb на конце сброса. */ + memEvents: { t: number; delta: number }[]; +} + +// Историю тянем так, чтобы покрыть начало горизонта (now-6ч): дни назад + запас. +function planHistoryDays(domainStart: number): number { + const back = Math.ceil(Math.max(0, Date.now() - domainStart) / 86400e3); + return Math.max(1, back + 1); +} + +// Имя заявки, над которой идёт съёмка (по центру контура) — для подписи на таймлайне/карте. +function captureLabel(lon: number, lat: number, rois: ConstellationRoi[]): string { + for (const r of rois) if (pointInPoly(lon, lat, r.poly)) return r.name; + return "Съёмка"; +} + +// Режимы плана (SURVEY/DROP) → интервалы съёмки/сброса + дельты заполнения борта. +function buildPlanTracks( + modes: PlanMode[], + rois: ConstellationRoi[], + stationByNumber: Map +): PlanTracks { + const capture: Interval[] = []; + const downlink: Interval[] = []; + const memEvents: { t: number; delta: number }[] = []; + + for (const m of modes) { + const start = m.timeStart ? parseLocal(m.timeStart) : NaN; + if (!Number.isFinite(start)) continue; + const end = start + (m.duration ?? 0) * 1000; + const vol = m.volumeMb ?? 0; + if (m.type === "SURVEY") { + const rings = m.contourWkt ? parseWktPolygon(m.contourWkt) : []; + const poly = rings[0]?.map((pt) => [wrapLon(pt.lon), pt.lat] as [number, number]); + capture.push({ start, end, label: captureLabel(m.longitude, m.lat, rois), poly }); + memEvents.push({ t: end, delta: vol }); + } else if (m.type === "DROP") { + const st = m.station != null ? stationByNumber.get(Number(m.station)) : undefined; + downlink.push({ + start, + end, + label: st?.name ?? (m.station != null ? `ст. ${m.station}` : "Сброс"), + lon: st?.lon, + lat: st?.lat + }); + memEvents.push({ t: end, delta: -vol }); + } + } + + capture.sort((a, b) => a.start - b.start); + downlink.sort((a, b) => a.start - b.start); + memEvents.sort((a, b) => a.t - b.t); + return { capture, downlink, memEvents }; +} + +// Планы из pcp-tgu, пересекающие горизонт, → их режимы по КА (spacecraftId == NORAD). +async function loadPlanModesBySat( + satIds: Set, + domainStart: number, + domainEnd: number +): Promise> { + const byId = new Map(); + let plans: TguPlan[]; + try { + plans = await fetchPlans(planHistoryDays(domainStart)); + } catch { + return byId; // нет планов — съёмка/сброс просто не покажутся + } + const relevant = plans.filter((p) => { + if (!satIds.has(p.spacecraftId)) return false; + const s = parseLocal(p.startTime); + const e = parseLocal(p.endTime); + return Number.isFinite(s) && Number.isFinite(e) && e >= domainStart && s <= domainEnd; + }); + const loaded = await Promise.all( + relevant.map((p) => + fetchPlanModes(p.planId) + .then((m) => ({ sid: p.spacecraftId, m })) + .catch(() => ({ sid: p.spacecraftId, m: [] as PlanMode[] })) + ) + ); + for (const { sid, m } of loaded) { + const arr = byId.get(sid) ?? []; + arr.push(...m); + byId.set(sid, arr); + } + return byId; +} + +// Заполнение борта на момент t: накопленный объём маршрутов (старт 0), % от ёмкости. +function memorySampler(memEvents: { t: number; delta: number }[]): (t: number) => number { + if (memEvents.length === 0) return () => 0; + return (t: number) => { + let mb = 0; + for (const e of memEvents) { + if (e.t > t) break; + mb += e.delta; + } + const pct = (Math.max(0, Math.min(ONBOARD_CAPACITY_MB, mb)) / ONBOARD_CAPACITY_MB) * 100; + return pct; + }; +} + +/** + * Грузит реальный состав и геометрию группировки и собирает конфиг дашборда. + * Горизонт по умолчанию now-6ч … now+18ч (как в прототипе). КА без трассы + * (например, не эксплуатируемые) на карту не попадают. + */ +export async function loadConstellationEnv(): Promise { + const now = Date.now(); + const domain = { start: now - 6 * 3600e3, end: now + 18 * 3600e3 }; + + const [platforms, stationDtos, requestItems] = await Promise.all([ + fetchPlatforms(), + fetchStations(), + fetchRequestsForMap({}) + .then((r) => r.items) + .catch(() => [] as RequestMapItem[]) + ]); + + const stations: ConstellationStation[] = (stationDtos as StationDto[]).map((s) => ({ + id: s.id ?? String(s.number), + name: s.name, + lon: s.position.long, + lat: s.position.lat + })); + const stationByNumber = new Map( + (stationDtos as StationDto[]).map((s) => [ + s.number, + { name: s.name, lon: s.position.long, lat: s.position.lat } + ]) + ); + + const rois: ConstellationRoi[] = requestItems + .map(roiFromRequest) + .filter((r): r is ConstellationRoi => r !== null); + + // Эксплуатируемые платформы с NORAD — кандидаты в группировку. + const candidates = (platforms as TguPlatform[]).filter( + (p) => isOperationalPlatform(p) && p.noradId != null + ); + + // Трассу и ЗРВ тянем по каждому КА параллельно; сбой по одному непрозрачен. + const perSat = await Promise.all( + candidates.map(async (p) => { + const norad = Number(p.noradId); + const [line, rva] = await Promise.all([ + fetchFlightLine(String(norad), domain.start, domain.end).catch(() => [] as FlightLinePoint[]), + fetchSatelliteRva(norad, domain.start, domain.end).catch(() => []) + ]); + return { p, norad, line, rva }; + }) + ); + + const sats: ConstellationSat[] = []; + const samplers: Record { lon: number; lat: number } | null> = {}; + const rvaBySat: Record = {}; + + for (const { p, norad, line, rva } of perSat) { + if (line.length === 0) continue; // нет трассы — на карте не разместить + const id = String(norad); + const name = p.name ?? id; + const payload = classifyPayload(name, p.mission); + sats.push({ + id, + name, + norad, + payload, + domain: payload === "radar" ? "ЛОКАЦИЯ" : "ОПТИКА", + group: deriveGroup(name, p.mission), + color: COL[payload], + mem0: (norad % 60) + 15 // памяти борта нет в телеметрии — модельная стартовая + }); + samplers[id] = buildSampler(line); + rvaBySat[id] = rva; + } + + sats.sort(compareSats); + + // Запреты работы (ВНЗ/питание) — источника в проекте нет: оставляем пустыми (заготовка). + const prohibits: Record = Object.fromEntries(sats.map((s) => [s.id, []])); + + const env: ConstellationEnv = { + epochMs: now, + sats, + stations, + rois, + prohibits, + subSat: (sat, t) => (samplers[sat.id] ? samplers[sat.id](t) : null) + }; + + // Съёмку и сброс берём из реального плана; тень считаем геометрически по трассе. + const satIds = new Set(sats.map((s) => s.id)); + const planModesBySat = await loadPlanModesBySat(satIds, domain.start, domain.end); + + // buildSchedule даёт нам тень (shadow) по реальным позициям; съёмку/сброс из него игнорируем. + const sched = buildSchedule(sats, env, domain.start, domain.end, 0.5) as ConstellationConfig["sched"]; + + const memSamplers: Record number> = {}; + for (const s of sats) { + const tracks = sched[s.id]; + // Съёмка и сброс — из режимов плана (SURVEY/DROP). + const plan = buildPlanTracks(planModesBySat.get(s.id) ?? [], rois, stationByNumber); + tracks.capture = plan.capture; + tracks.downlink = plan.downlink; + memSamplers[s.id] = memorySampler(plan.memEvents); + + // «Связь с КА» — окна радиовидимости (ЗРВ). Раньше показывались как «сброс». + const rva = rvaBySat[s.id]; + tracks.link = (rva ?? []) + .map((z) => { + const st = stationByNumber.get(z.stationId); + return { + start: parseLocal(z.onStart.time), + end: parseLocal(z.onStop.time), + label: st?.name ?? `ст. ${z.stationId}`, + lon: st?.lon, + lat: st?.lat + }; + }) + .filter((iv) => Number.isFinite(iv.start) && Number.isFinite(iv.end) && iv.end > iv.start) + .sort((a, b) => a.start - b.start); + } + + // Расписание и заполнение борта — на env, чтобы statusAt/панель читали авторитетные данные. + env.sched = sched; + env.memAt = (sat, t) => (memSamplers[sat.id] ? memSamplers[sat.id](t) : 0); + + return { env, domain, sched }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/sim.js b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js new file mode 100644 index 0000000..c247507 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/sim.js @@ -0,0 +1,183 @@ +/* ============================================================ + sim.js — geometry + schedule (ESM) + Чистая геометрия подспутниковой точки, тень, расписание событий. + См. INTEGRATION.md — subSat()/statusAt() можно заменить на реальную эфемериду/API. + ============================================================ */ +const D2R = Math.PI / 180, R2D = 180 / Math.PI; + const TWO_PI = Math.PI * 2; + const SIDEREAL_MIN = 1436.07; // earth rotation period (min) + + function wrapLon(lon) { + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return lon; + } + + // sub-satellite point at sim time t(ms), given epoch ms. + function subSat(sat, tMs, epochMs) { + const m = (tMs - epochMs) / 60000; // minutes since epoch + const o = sat.orbit; + const M = o.phase * TWO_PI + TWO_PI * (m / o.period); + const lat = o.latAmp * Math.sin(M); + // one sine wave per orbit across full longitude, drifting west via earth rotation + const lon = wrapLon(o.lon0 + 360 * (m / o.period) - 360 * (m / SIDEREAL_MIN)); + return { lon, lat }; + } + + // subsolar point (approx, declination ignored -> equatorial) + function subSolar(tMs) { + const secOfDay = (tMs / 1000) % 86400; + const lon = wrapLon(180 - (secOfDay / 86400) * 360); + // small seasonal declination wobble for life + const dayFrac = (tMs / 86400000) % 365.25; + const lat = 23.4 * Math.sin((dayFrac / 365.25) * TWO_PI - 1.4); + return { lon, lat }; + } + + // angular (great-circle) distance in degrees + function angDist(lon1, lat1, lon2, lat2) { + const φ1 = lat1 * D2R, φ2 = lat2 * D2R; + const dφ = (lat2 - lat1) * D2R; + const dλ = (lon2 - lon1) * D2R; + const a = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2; + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R2D; + } + + function inShadow(lon, lat, tMs) { + const ss = subSolar(tMs); + return angDist(lon, lat, ss.lon, ss.lat) > 90; + } + + // point in polygon (ray casting), poly = [[lon,lat],...] + function pointInPoly(lon, lat, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i][0], yi = poly[i][1]; + const xj = poly[j][0], yj = poly[j][1]; + const intersect = (yi > lat) !== (yj > lat) && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; + } + + const STATION_RANGE = 22; // deg ground range for downlink visibility + + // which ROI is the sat over (matching payload), null if none + function overROI(sat, p, rois) { + for (const r of rois) { + if (r.payload !== sat.payload) continue; + if (pointInPoly(p.lon, p.lat, r.poly)) return r; + } + return null; + } + // best (nearest in-range) station, null if none + function nearStation(p, stations) { + let best = null, bd = STATION_RANGE; + for (const st of stations) { + const d = angDist(p.lon, p.lat, st.lon, st.lat); + if (d < bd) { bd = d; best = st; } + } + return best; + } + + // --- prohibition windows (интервалы запрета) per sat ---------- + function buildProhibits(sats, epochMs) { + const H = 3600e3; + const out = {}; + sats.forEach((s, i) => { + const list = []; + // deterministic-ish spread + const a0 = (i * 1.9) % 22 - 4; // hours from epoch + list.push({ start: epochMs + a0 * H, end: epochMs + (a0 + 0.7 + (i % 3) * 0.15) * H, reason: "ВНЗ" }); + if (i % 2 === 0) { + const a1 = a0 + 8 + (i % 4); + list.push({ start: epochMs + a1 * H, end: epochMs + (a1 + 0.9) * H, reason: "ОГРАН. ПИТАНИЕ" }); + } + out[s.id] = list; + }); + return out; + } + function inProhibit(list, tMs) { + if (!list) return null; + for (const w of list) if (tMs >= w.start && tMs < w.end) return w; + return null; + } + + // sub-satellite point: реальный сэмплер из env.subSat (эфемерида/flight-line), + // иначе встроенная синусоидальная симуляция. См. INTEGRATION.md §5.2. + function subSatOf(sat, tMs, env) { + return env.subSat ? env.subSat(sat, tMs) : subSat(sat, tMs, env.epochMs); + } + + // interval containing t in a sorted/unsorted list, else null + function intervalAt(list, tMs) { + if (!list) return null; + for (const iv of list) if (tMs >= iv.start && tMs < iv.end) return iv; + return null; + } + + // live status of a sat at time t (priority order) + // returns {state, roi, station, eclipse, prohibit} + // С реальным расписанием (env.sched) состояние берём из готовых интервалов плана + // (съёмка/сброс/запрет); иначе — встроенная геометрическая симуляция прототипа. + function statusAt(sat, tMs, env) { + const p = subSatOf(sat, tMs, env); + const eclipse = inShadow(p.lon, p.lat, tMs); + if (env.sched) { + const tr = env.sched[sat.id] || {}; + const prohibit = intervalAt(tr.prohibit, tMs); + if (prohibit) return { state: "prohibit", p, prohibit: { reason: prohibit.label }, eclipse }; + const cap = intervalAt(tr.capture, tMs); + if (cap) return { state: "capture", p, roi: { name: cap.label, poly: cap.poly }, eclipse }; + const dl = intervalAt(tr.downlink, tMs); + if (dl) return { state: "downlink", p, station: { name: dl.label, lon: dl.lon, lat: dl.lat }, eclipse }; + return { state: "operational", p, eclipse }; + } + const prohibit = inProhibit(env.prohibits[sat.id], tMs); + if (prohibit) return { state: "prohibit", p, prohibit, eclipse }; + const roi = overROI(sat, p, env.rois); + if (roi) return { state: "capture", p, roi, eclipse }; + const st = nearStation(p, env.stations); + if (st) return { state: "downlink", p, station: st, eclipse }; + return { state: "operational", p, eclipse }; + } + + // --- precompute event intervals over a domain for the timeline - + function buildSchedule(sats, env, domainStart, domainEnd, stepMin) { + const step = stepMin * 60000; + const sched = {}; + for (const s of sats) { + const tracks = { capture: [], downlink: [], prohibit: [], shadow: [] }; + // prohibits are explicit + (env.prohibits[s.id] || []).forEach((w) => + tracks.prohibit.push({ start: w.start, end: w.end, label: w.reason })); + let cur = { capture: null, downlink: null, shadow: null }; + for (let t = domainStart; t <= domainEnd; t += step) { + const p = subSatOf(s, t, env); + const proh = inProhibit(env.prohibits[s.id], t); + const shadow = inShadow(p.lon, p.lat, t); + const roi = !proh ? overROI(s, p, env.rois) : null; + const st = !proh && !roi ? nearStation(p, env.stations) : null; + // shadow band + if (shadow) { if (!cur.shadow) cur.shadow = { start: t, end: t }; else cur.shadow.end = t; } + else if (cur.shadow) { tracks.shadow.push(cur.shadow); cur.shadow = null; } + // capture band + if (roi) { if (!cur.capture) cur.capture = { start: t, end: t, label: roi.name }; else cur.capture.end = t; } + else if (cur.capture) { tracks.capture.push(cur.capture); cur.capture = null; } + // downlink band + if (st) { if (!cur.downlink) cur.downlink = { start: t, end: t, label: st.name }; else cur.downlink.end = t; } + else if (cur.downlink) { tracks.downlink.push(cur.downlink); cur.downlink = null; } + } + if (cur.shadow) tracks.shadow.push(cur.shadow); + if (cur.capture) tracks.capture.push(cur.capture); + if (cur.downlink) tracks.downlink.push(cur.downlink); + sched[s.id] = tracks; + } + return sched; + } + +export { + D2R, R2D, wrapLon, subSat, subSolar, angDist, inShadow, + pointInPoly, overROI, nearStation, buildProhibits, inProhibit, + statusAt, intervalAt, buildSchedule, STATION_RANGE, +}; diff --git a/services/pcp-tgu-ops-ui/src/features/constellation/util.js b/services/pcp-tgu-ops-ui/src/features/constellation/util.js new file mode 100644 index 0000000..0f91d08 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/constellation/util.js @@ -0,0 +1,9 @@ +/* util.js — small shared helpers (ESM) */ + +// "#rrggbb" + alpha -> "rgba(...)"; passes through non-hex strings unchanged +export function hexA(hex, a) { + if (typeof hex !== "string" || hex[0] !== "#") return hex; + const n = parseInt(hex.slice(1), 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${a})`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx new file mode 100644 index 0000000..0183bba --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + fetchComplexPlanRunModes, + fetchComplexPlanRuns, + runComplexPlan, + MAX_COMPLEX_PLAN_RANGE_DAYS, + MAX_COMPLEX_PLAN_RANGE_MS, + type ComplexPlanMode, + type ComplexPlanRunSummary +} from "../../api/complexPlanApi"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { DateTime24Field } from "../../components/timeFields"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; +import { formatVolume } from "../../utils/dataVolume"; + +type TguComplexPlanTabProps = { + range: TimelineRange; + /** Все эксплуатируемые КА вкладки «Планы» — по ним считаем комплексный план. */ + platforms: TguPlatformUi[]; +}; + +export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) { + const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); + const [running, setRunning] = useState(false); + const [modes, setModes] = useState([]); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + // История прогонов и выбранный для просмотра прогон. + const [runs, setRuns] = useState([]); + const [runsError, setRunsError] = useState(); + const [selectedRunId, setSelectedRunId] = useState(); + const [loadingRun, setLoadingRun] = useState(false); + + // Наложение заявок поверх контуров комплексного плана. + const [showRequests, setShowRequests] = useState(false); + const [requestItems, setRequestItems] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(false); + const [requestsError, setRequestsError] = useState(); + + // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). + const satelliteIds = useMemo( + () => + Array.from( + new Set( + platforms + .map((platform) => Number(platform.spacecraftId)) + .filter((id) => Number.isFinite(id)) + ) + ), + [platforms] + ); + + const parsedRange = useMemo( + () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }), + [fromValue, toValue] + ); + const rangeInvalid = + !Number.isFinite(parsedRange.fromMs) || + !Number.isFinite(parsedRange.toMs) || + parsedRange.fromMs >= parsedRange.toMs; + const rangeTooLong = + !rangeInvalid && parsedRange.toMs - parsedRange.fromMs > MAX_COMPLEX_PLAN_RANGE_MS; + + const selectedRun = useMemo( + () => runs.find((run) => run.runId === selectedRunId), + [runs, selectedRunId] + ); + + // Интервал, по которому грузим заявки: показанный прогон (если выбран) либо поля «С/По». + const activeInterval = useMemo(() => { + if (selectedRun) { + return { fromMs: parseLocal(selectedRun.intervalStart), toMs: parseLocal(selectedRun.intervalEnd) }; + } + return parsedRange; + }, [selectedRun, parsedRange]); + + const modePolygons = useMemo(() => buildModePolygons(modes), [modes]); + const requestPolygons = useMemo( + () => (showRequests ? buildTgu2DMapScene({ range, platforms, requestItems }).polygons : []), + [showRequests, requestItems, range, platforms] + ); + + const scene = useMemo( + () => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), + [modePolygons, requestPolygons] + ); + + const layers = useMemo( + () => ({ + tracks: false, + swath: false, + planWorks: false, + complexPlan: true, + stations: false, + spacecraftMarkers: false, + requests: showRequests + }), + [showRequests] + ); + + const loadRuns = useCallback(async (): Promise => { + setRunsError(undefined); + try { + const items = await fetchComplexPlanRuns(); + setRuns(items); + return items; + } catch (caught) { + setRunsError(caught instanceof Error ? caught.message : "Не удалось загрузить историю расчётов."); + return []; + } + }, []); + + const loadRunModes = useCallback(async (runId: number) => { + setSelectedRunId(runId); + setLoadingRun(true); + setError(undefined); + try { + const runModes = await fetchComplexPlanRunModes(runId); + setModes(runModes); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось загрузить результат расчёта."); + } finally { + setLoadingRun(false); + } + }, []); + + // На входе во вкладку подтягиваем историю и сразу показываем последний построенный план. + useEffect(() => { + void (async () => { + const items = await loadRuns(); + const latest = items.find((run) => run.status === "COMPLETED" && run.calculatedModesCount > 0) ?? items[0]; + if (latest) void loadRunModes(latest.runId); + })(); + }, [loadRuns, loadRunModes]); + + // Заявки за активный интервал (по тумблеру) — для сопоставления контуров плана с заявками. + useEffect(() => { + if (!showRequests) return; + const { fromMs, toMs } = activeInterval; + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + let cancelled = false; + setRequestsLoading(true); + setRequestsError(undefined); + fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) }) + .then((result) => { + if (!cancelled) setRequestItems(result.items); + }) + .catch((caught) => { + if (!cancelled) setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки."); + }) + .finally(() => { + if (!cancelled) setRequestsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [showRequests, activeInterval]); + + const runCalculation = async () => { + if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return; + setRunning(true); + setError(undefined); + setNotice(undefined); + try { + const runResult = await runComplexPlan({ + fromMs: parsedRange.fromMs, + toMs: parsedRange.toMs, + satelliteIds + }); + await loadRuns(); + await loadRunModes(runResult.runId); + setNotice( + runResult.calculatedModesCount > 0 + ? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}.` + : "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками." + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось рассчитать комплексный план."); + } finally { + setRunning(false); + } + }; + + const canRun = !running && !rangeInvalid && !rangeTooLong && satelliteIds.length > 0; + const busy = running || loadingRun; + const overlayMessage = busy + ? running + ? "Идёт расчёт…" + : "Загрузка плана…" + : rangeInvalid + ? "Некорректный интервал: «С» должно быть раньше «По»." + : modes.length === 0 + ? "Задайте интервал и нажмите «Рассчитать» либо выберите построенный план из истории." + : undefined; + + const displayRun = selectedRun; + const contoursCount = modePolygons.length; + // Суммарный объём режимов run’а (МБ) — съёмка по скорости накопления, сброс по скорости канала. + const totalVolumeMb = modes.reduce((sum, mode) => sum + (mode.volumeMb ?? 0), 0); + + return ( +
+
+
+
Комплексный план
+
+ Расчёт по {satelliteIds.length} КА вкладки «Планы» +
+
+ + + + + +
+ {displayRun && ( +
+ run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} + {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров + {showRequests ? ` · заявок ${requestItems.length}` : ""} +
+ )} +
+ +
+ {overlayMessage &&
{overlayMessage}
} + undefined} + /> +
+ +
+ {satelliteIds.length === 0 && ( +
+ Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен. +
+ )} + {rangeTooLong && ( +
+ Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток — сократите диапазон «С/По». +
+ )} + {requestsLoading &&
Загрузка заявок…
} + {requestsError &&
{requestsError}
} + {runsError &&
{runsError}
} + {notice &&
{notice}
} + {error &&
{error}
} +
+
+ ); +} + +/** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */ +function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { + const polygons: MapPolygon[] = []; + let zIndex = 0; + for (const mode of modes) { + if (!mode.contourWkt) continue; + const rings = parseWktPolygon(mode.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `complex-mode:${mode.id}:${zIndex}`, + name: `КА ${mode.satelliteId} · виток ${mode.revolution}`, + kind: "complexPlan", + layer: "complexPlan", + points: ring, + spacecraftId: String(mode.satelliteId), + modeId: mode.id, + zIndex: zIndex++ + }); + } + } + return polygons; +} + +/** Подпись прогона в выпадающем списке истории: интервал, статус и счётчик режимов. */ +function formatRunOption(run: ComplexPlanRunSummary): string { + const interval = `${formatLocalDateTime(parseLocal(run.intervalStart))} — ${formatLocalDateTime(parseLocal(run.intervalEnd))}`; + const status = run.status === "COMPLETED" ? "" : ` · ${run.status}`; + return `#${run.runId} · ${interval} · режимов ${run.calculatedModesCount}${status}`; +} + +function formatPercent(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "—"; + return `${value.toFixed(1)}%`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 218fb1f..e0794dd 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,4 +1,12 @@ -import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; +import type { ReactNode } from "react"; +import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, formatVolume, kindColor, kindLabel } from "./editorPresentation"; +import { statusStyle } from "../tgu-planning/tguStatus"; +import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; +import { Time24Field } from "../../components/timeFields"; +import type { RvaZone } from "../../api/ballisticsApi"; +import type { SurveyRoute } from "../../api/missionPlanningApi"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { RequestMapItem } from "../../model/requestTypes"; import type { EditorGroundStation, EditorMode, @@ -22,26 +30,69 @@ type WorkInspectorProps = { work: EditorWork; modes: EditorMode[]; stations: EditorGroundStation[]; + /** Название аппарата редактора — для блока параметров включения. */ + spacecraftLabel?: string; + /** Метка съёмки по id режима — для перечня сбрасываемых маршрутов у сброса. */ + surveyModeLabel?: (surveyModeId: number) => string; conflicts: string[]; - onUpdate: (workId: string, patch: EditorWorkPatch) => void; + /** Включение существующего плана — показываем только информацию, без правок. */ + readOnly?: boolean; + /** Разрешить удаление даже у read-only включения плана (есть черновик — режим живёт в нём). */ + canDelete?: boolean; + /** План редактируемый — показываем кнопку «Редактировать» (открывает окно построения). */ + canEdit?: boolean; + /** Открыть включение в окне построения для правки параметров. */ + onEdit: (workId: string) => void; onDelete: (workId: string) => void; - onDuplicate: (workId: string) => void; onClose: () => void; }; type ShootPanelProps = { target?: EditorTarget; passes: EditorVisibilityWindow[]; + loading?: boolean; + error?: string; selectedPass?: EditorVisibilityWindow; modeId?: string; modes: EditorMode[]; + /** Окно строящегося маршрута (локальное, мс). undefined пока не выбран проход. */ + routeWindow?: { startMs: number; endMs: number }; + /** Текущий крен (gamma) маршрута, градусы. */ + routeRoll?: number; + /** Окно видимости выбранного прохода — для подписи длительности. */ + passStartMs?: number; + passEndMs?: number; + /** Допустимый диапазон времени окна маршрута (±30 мин от окна видимости), мс. */ + routeMinMs?: number; + routeMaxMs?: number; + routeLoading?: boolean; + routeError?: string; + route?: SurveyRoute; + /** Режим правки существующей съёмки — меняет заголовок и подпись кнопки. */ + editing?: boolean; + /** Активен режим прицеливания: карта в режиме клика по точке (перекрестие), иначе — «рука». */ + picking?: boolean; + /** Включить режим прицеливания (указать/сменить опорную точку одним кликом по карте). */ + onPickTarget: () => void; + /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ + addBlockedReason?: string; + onRouteStartChange: (startMs: number) => void; + onRouteEndChange: (endMs: number) => void; + /** Сдвиг окна целиком вперёд/назад (мс), длительность сохраняется. */ + onRouteShift: (deltaMs: number) => void; + /** Изменение крена влево/вправо (градусы, дельта от кнопок). */ + onRouteRollChange: (deltaDeg: number) => void; + /** Установка крена напрямую (ручной ввод), градусы. */ + onRouteRollSet: (deg: number) => void; onSelectPass: (pass?: EditorVisibilityWindow) => void; onModeChange: (modeId: string) => void; onAdd: () => void; onClose: () => void; - onClearTarget: () => void; }; +/** Минимальная длительность маршрута, мс (нижняя граница окна). */ +const MIN_ROUTE_DURATION_MS = 5_000; + type DownlinkPanelProps = { stationOptions: DownlinkStationOption[]; selected?: DownlinkSelection; @@ -50,14 +101,51 @@ type DownlinkPanelProps = { onClose: () => void; }; +/** Маршрут съёмки — кандидат на сброс в выбранной зоне (заканчивается до начала зоны). */ +export type DropCandidateSurvey = { + id: number; + label: string; + startMs: number; + endMs: number; +}; + +type DropPanelProps = { + zones: RvaZone[]; + loading?: boolean; + error?: string; + selectedZone?: RvaZone; + onSelectZone: (zone?: RvaZone) => void; + /** Название станции по её номеру (из каталога станций). */ + stationName: (stationId: number) => string; + /** Время начала зоны в мс (локальное) — для подписи. */ + zoneStartMs: (zone: RvaZone) => number; + zoneStopMs: (zone: RvaZone) => number; + /** Съёмки-кандидаты для выбранной зоны (end < начало зоны). */ + candidates: DropCandidateSurvey[]; + selectedSurveyIds: Set; + onToggleSurvey: (id: number) => void; + saving?: boolean; + saveError?: string; + /** Режим правки существующего сброса — меняет заголовок и подпись кнопки. */ + editing?: boolean; + /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ + addBlockedReason?: string; + onAdd: () => void; + onClose: () => void; +}; + export function WorkInspector({ work, modes, stations, + spacecraftLabel, + surveyModeLabel, conflicts, - onUpdate, + readOnly, + canDelete, + canEdit, + onEdit, onDelete, - onDuplicate, onClose }: WorkInspectorProps) { const color = kindColor(work.kind); @@ -77,51 +165,92 @@ export function WorkInspector({
Время
- onUpdate(work.id, { startMs: value })} /> - onUpdate(work.id, { endMs: value })} /> -
длительность: {formatDuration(work.endMs - work.startMs)}
+
+
Начало
+
{formatDateTimeSeconds(work.startMs)}
+
Конец
+
{formatDateTimeSeconds(work.endMs)}
+
Длительность
+
{formatDuration(work.endMs - work.startMs)}
+
Объём
+
{formatVolume(work.volumeMb)}
+
+
+ +
+
Параметры включения
+
+
Аппарат
+
{spacecraftLabel ?? "—"}
+
Виток
+
{work.revolution ?? "—"}
+ {work.kind === "shoot" && ( + <> +
Крен
+
{work.rollDeg != null ? `${work.rollDeg.toFixed(1)}°` : "—"}
+
Угол захвата
+
{work.captureAngleDeg != null ? `${work.captureAngleDeg.toFixed(1)}°` : "—"}
+
Центр контура
+
+ {work.centerLat != null && work.centerLon != null + ? `${work.centerLat.toFixed(2)}, ${work.centerLon.toFixed(2)}` + : "—"} +
+ + )} + {work.kind === "downlink" && ( + <> +
Станция
+
{stations.find((station) => station.id === work.stationId)?.name ?? work.stationId ?? "—"}
+ + )} +
{work.kind === "shoot" && (
-
Режим аппаратуры
-
- {modes.map((mode) => ( - - ))} -
- {work.targetName &&
цель: {work.targetName}
} +
Задания под маршрутом
+
Привязка заданий к маршруту появится позже.
)} {work.kind === "downlink" && (
-
Станция приёма
- +
Сбрасываемые съёмки
+ {work.surveyIds && work.surveyIds.length > 0 ? ( +
    + {work.surveyIds.map((surveyId) => ( +
  • {surveyModeLabel ? surveyModeLabel(surveyId) : `Маршрут #${surveyId}`}
  • + ))} +
+ ) : ( +
Маршруты съёмки к сбросу не привязаны.
+ )}
)} -
- - -
+ {work.kind === "shoot" && ( +
+
Режим аппаратуры
+
{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}
+ {work.targetName &&
цель: {work.targetName}
} +
+ )} + + {(canEdit || !readOnly || canDelete) && ( +
+ {canEdit && ( + + )} + {(!readOnly || canDelete) && ( + + )} +
+ )}
); } +function visibilityWindowFromParam(param: PointVisibilityParam): EditorVisibilityWindow { + const timeMs = parseLocal(param.time); + const half = SHOOT_WINDOW_MS / 2; + return { + startMs: timeMs - half, + endMs: timeMs + half, + quality: 0, + revolution: param.revolution, + rollDeg: param.gamma, + sunAngleDeg: param.sunAngle, + range: param.range, + revSign: param.revSign + }; +} + function defaultEditorWindow( selectedPlan: TguEditorTabProps["selectedPlan"], appliedRange: TguEditorTabProps["appliedRange"] ): EditorTimelineWindow { if (selectedPlan) { return { - fromMs: selectedPlan.startMs - 3 * HOUR_MS, - toMs: selectedPlan.endMs + 3 * HOUR_MS + fromMs: selectedPlan.startMs - EDITOR_WINDOW_PAD_MS, + toMs: selectedPlan.endMs + EDITOR_WINDOW_PAD_MS }; } @@ -542,30 +1501,13 @@ function buildStations(selectedPlan: TguEditorTabProps["selectedPlan"]): EditorG return [{ id: selectedPlan.kppId, name: selectedPlan.kppId }]; } -function buildStationOptions( - stations: EditorGroundStation[], - spacecraftId: string | undefined, - window: EditorTimelineWindow, - selectedPlan: TguEditorTabProps["selectedPlan"] -): DownlinkStationOption[] { - return stations.map((station) => { - const computedPasses = - station.lat !== undefined && station.lon !== undefined - ? stationPasses({ spacecraftId }, { lat: station.lat, lon: station.lon }, { fromMs: window.fromMs, toMs: window.toMs }) - : []; - const fallbackPass = selectedPlan && selectedPlan.kppId === station.id - ? [{ - startMs: Math.max(window.fromMs, selectedPlan.startMs), - endMs: Math.min(window.toMs, selectedPlan.endMs), - quality: 0 - }] - : []; - - return { - station, - passes: computedPasses.length > 0 ? computedPasses : fallbackPass.filter((pass) => pass.endMs > pass.startMs) - }; - }); +/** + * LocalDateTime без зоны от бэка трактуем как локальное настенное время (как и поле `time` + * в ballisticsApi): зоны ЗРВ приходят без таймзоны, времена съёмок — с offset (его учитываем); + * приводим к одной шкале в локальной зоне браузера. + */ +function localDateTimeMs(s: string): number { + return parseLocal(s); } function buildVisibilityTracks( @@ -609,11 +1551,12 @@ function buildEditorMapScene( spacecraftId: string | undefined, flightLineData: FlightLinePoint[], orbitPasses: OrbitPassInfo[], - stations: EditorGroundStation[], + stationDtos: StationDto[], requestItems: RequestMapItem[], planModes: PlanMode[], fromIndex: number, - toIndex: number + toIndex: number, + surveyRoute?: SurveyRoute ): Tgu2DMapScene { const visibleRevolutions = new Set( orbitPasses.filter((p) => p.index >= fromIndex && p.index <= toIndex).map((p) => p.revolution!) @@ -667,11 +1610,35 @@ function buildEditorMapScene( layer: "planWorks", points: ring, status: "PLANNED", + modeId: mode.id, zIndex: zIndex++ }); } } + // Сбросы (DROP) — яркая толстая линия на трассе: берём точки трассы за интервал сеанса + // [timeStart, timeStart + duration]. Это и есть участок витка, над которым идёт сброс. + for (const mode of planModes) { + if (mode.type !== "DROP" || mode.timeStart == null) continue; + const startMs = localDateTimeMs(mode.timeStart); + const endMs = startMs + mode.duration * 1000; + const pts = flightLineData + .map((p) => ({ t: parseLocal(p.time), lat: p.lat, lon: p.long })) + .filter((p) => p.t >= startMs && p.t <= endMs) + .sort((a, b) => a.t - b.t) + .map((p) => ({ lat: p.lat, lon: p.lon })); + if (pts.length < 2) continue; + lines.push({ + id: `drop:${mode.id}`, + name: `Сброс · виток ${mode.revolution}`, + layer: "tracks", + kind: "drop", + modeId: mode.id, + points: pts, + zIndex: zIndex++ + }); + } + for (const item of requestItems) { const rings = parseWktPolygon(item.geometry); for (const ring of rings) { @@ -689,16 +1656,26 @@ function buildEditorMapScene( } } - const markers = stations.flatMap((station) => { - if (station.lat === undefined || station.lon === undefined) return []; - return [{ - id: station.id, - layer: "stations" as const, - point: { lat: station.lat, lon: station.lon }, - label: station.name, - zIndex: 10 - }]; - }); + // Построенный (но ещё не сохранённый) маршрут съёмки — показываем поверх работ плана + // отдельным цветом (статус ISSUING). Рендерится в слое planWorks, поэтому виден без + // дополнительной настройки слоёв. + if (surveyRoute?.contourWkt) { + const rings = parseWktPolygon(surveyRoute.contourWkt); + for (const ring of rings) { + if (ring.length < 3) continue; + polygons.push({ + id: `surveyRoute:${zIndex}`, + name: `Маршрут съёмки (виток ${surveyRoute.revolution})`, + kind: "planWork", + layer: "planWorks", + points: ring, + status: "ISSUING", + zIndex: zIndex++ + }); + } + } + + const markers = buildStationMarkers(stationDtos); return { polygons, lines, markers }; } @@ -715,3 +1692,43 @@ function nextWorkId(ref: MutableRefObject): string { ref.current += 1; return `NW-${String(ref.current).padStart(4, "0")}`; } + +/** + * Объект карты, соответствующий включению, и точка для центрирования: + * - съёмка → контур (planWorks) по modeId либо только что построенный маршрут; точка — центр контура; + * - сброс → яркая линия сброса (drop) по modeId; точка — середина линии. + */ +function workMapFocus(work: EditorWork | undefined, scene: Tgu2DMapScene): { point?: GeoPoint; objectId?: string } { + if (!work) return {}; + if (work.kind === "shoot") { + const polygon = + work.planModeId != null + ? scene.polygons.find((p) => p.modeId === work.planModeId) + : scene.polygons.find((p) => p.id.startsWith("surveyRoute:")); + const point: GeoPoint | undefined = + work.centerLat != null && work.centerLon != null + ? { lat: work.centerLat, lon: work.centerLon } + : polygon + ? polygonCentroid(polygon.points) + : undefined; + return { point, objectId: polygon?.id }; + } + + const line = + work.planModeId != null + ? scene.lines.find((l) => l.kind === "drop" && l.modeId === work.planModeId) + : undefined; + const point = line && line.points.length > 0 ? line.points[Math.floor(line.points.length / 2)] : undefined; + return { point, objectId: line?.id }; +} + +function polygonCentroid(points: GeoPoint[]): GeoPoint | undefined { + if (points.length === 0) return undefined; + let lat = 0; + let lon = 0; + for (const p of points) { + lat += p.lat; + lon += p.lon; + } + return { lat: lat / points.length, lon: lon / points.length }; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts index bf184f5..ceab45e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorApi.ts @@ -1,23 +1,3 @@ -import type { Draft } from "./model/editorTypes"; - -export type SaveEditedPlanRequest = { - planId: string; - draft: Draft; -}; - -export type SaveEditedPlanResult = { - saved: false; - message: string; -}; - -export async function saveEditedPlan(_request: SaveEditedPlanRequest): Promise { - // TODO(backend): эндпоинт сохранения правок плана отсутствует в pcp-tgu-service. - return { - saved: false, - message: "Сохранение правок плана во внешний сервис ещё не реализовано в backend." - }; -} - export type PlanSurveyMode = { id: number; planId: number; @@ -30,6 +10,8 @@ export type PlanSurveyMode = { duration: number; contourWkt: string | null; roll: number; + /** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; }; export type PlanDropMode = { @@ -41,6 +23,8 @@ export type PlanDropMode = { station: string | null; duration: number; surveys: number[]; + /** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */ + volumeMb?: number; }; export type PlanMode = PlanSurveyMode | PlanDropMode; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index f6ec4ec..041f186 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -4,45 +4,112 @@ import { addDownlink, addShoot, deleteWork, - duplicateWork, mutate, + planModesToWorks, redo, seedDraft, undo, updateWork } from "./editorDraft"; +import type { PlanMode } from "./editorApi"; import type { DraftHistory } from "./model/editorTypes"; +import { parseLocal } from "../../utils/localTime"; const plan: TguPlanUi = { planId: "plan-1", spacecraftId: "56756", - startTime: "2026-05-29T01:00:00Z", - endTime: "2026-05-29T02:00:00Z", + startTime: "2026-05-29T01:00:00", + endTime: "2026-05-29T02:00:00", kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse("2026-05-29T01:00:00Z"), - endMs: Date.parse("2026-05-29T02:00:00Z") + startMs: parseLocal("2026-05-29T01:00:00"), + endMs: parseLocal("2026-05-29T02:00:00") }; describe("editorDraft", () => { - it("seeds a draft from the current API plan envelope when embedded works are absent", () => { + it("seeds an empty draft when the plan has no embedded works", () => { + // Существующие включения грузятся отдельно (planModesToWorks); черновик хранит только правки. expect(seedDraft(plan)).toEqual({ planId: "plan-1", spacecraftId: "56756", - works: [ - { - id: "plan-1:plan-window", - kind: "downlink", - label: "План ТГУ · KPP-1", - startMs: plan.startMs, - endMs: plan.endMs, - stationId: "KPP-1", - origin: "plan" - } - ] + works: [] }); }); + it("maps plan modes to read-only timeline works", () => { + const modes: PlanMode[] = [ + { + id: 11, + planId: 1, + timeStart: "2026-05-29T01:10:00", + revolution: 4321, + type: "SURVEY", + status: "PLANNED", + lat: 55.7, + longitude: 37.6, + duration: 180, + contourWkt: null, + roll: 0 + }, + { + id: 22, + planId: 1, + timeStart: "2026-05-29T01:40:00", + revolution: 4322, + type: "DROP", + station: "102", + duration: 120, + surveys: [11] + }, + { + id: 33, + planId: 1, + timeStart: null, + revolution: 4323, + type: "SURVEY", + status: "PLANNED", + lat: 0, + longitude: 0, + duration: 60, + contourWkt: null, + roll: 0 + } + ]; + + const works = planModesToWorks(modes); + + // Включение без времени старта пропускается, оба валидных — read-only (origin: "plan"). + expect(works).toEqual([ + { + id: "survey-11", + kind: "shoot", + label: "Съёмка · виток 4321", + startMs: parseLocal("2026-05-29T01:10:00"), + endMs: parseLocal("2026-05-29T01:10:00") + 180_000, + origin: "plan", + planModeId: 11, + revolution: 4321, + rollDeg: 0, + centerLat: 55.7, + centerLon: 37.6, + targetLat: 55.7, + targetLon: 37.6 + }, + { + id: "drop-22", + kind: "downlink", + label: "Сброс · 102", + startMs: parseLocal("2026-05-29T01:40:00"), + endMs: parseLocal("2026-05-29T01:40:00") + 120_000, + origin: "plan", + planModeId: 22, + revolution: 4322, + stationId: "102", + surveyIds: [11] + } + ]); + }); + it("adds shoot and downlink works with deterministic ids", () => { const draft = seedDraft(undefined, "56756"); const withShoot = addShoot(draft, { @@ -87,20 +154,18 @@ describe("editorDraft", () => { expect(redone.future).toHaveLength(0); }); - it("updates, duplicates and deletes works without generating ids internally", () => { - const draft = seedDraft(plan); - const updated = updateWork(draft, "plan-1:plan-window", { label: "Сеанс", startMs: plan.startMs + 60_000 }); - const duplicated = duplicateWork(updated, "plan-1:plan-window", "copy-1", 120_000); - const deleted = deleteWork(duplicated, "plan-1:plan-window"); + it("updates and deletes works without generating ids internally", () => { + const draft = addDownlink(seedDraft(plan), { + id: "downlink-1", + label: "Сброс на НКПОР", + startMs: plan.startMs, + endMs: plan.startMs + 120_000, + station: { id: "KPP-1", name: "KPP-1" } + }); + const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 }); + const deleted = deleteWork(updated, "downlink-1"); expect(updated.works[0].label).toBe("Сеанс"); - expect(duplicated.works[1]).toMatchObject({ - id: "copy-1", - startMs: updated.works[0].startMs + 120_000, - endMs: updated.works[0].endMs + 120_000, - origin: "added", - isNew: true - }); - expect(deleted.works.map((work) => work.id)).toEqual(["copy-1"]); + expect(deleted.works.map((work) => work.id)).toEqual([]); }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 0153d3e..1665d11 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -1,4 +1,6 @@ import type { TguPlanUi } from "../../model/timelineTypes"; +import { parseLocal } from "../../utils/localTime"; +import type { PlanMode } from "./editorApi"; import type { Draft, DraftHistory, @@ -10,7 +12,6 @@ import type { EditorWorkPatch } from "./model/editorTypes"; -const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000; const DEFAULT_HISTORY_LIMIT = 50; export type AddShootInput = { @@ -20,6 +21,16 @@ export type AddShootInput = { label: string; modeId?: string; target: EditorTarget; + /** id сохранённого режима съёмки в БД черновика (если съёмка персистнута). */ + planModeId?: number; + /** Параметры построенного маршрута (если съёмка строилась с превью). */ + revolution?: number; + rollDeg?: number; + captureAngleDeg?: number; + centerLat?: number; + centerLon?: number; + /** Объём маршрута, МБ (из ответа бэка). */ + volumeMb?: number; }; export type AddDownlinkInput = { @@ -28,6 +39,10 @@ export type AddDownlinkInput = { endMs: number; label: string; station: EditorGroundStation; + /** id сохранённого режима сброса в БД черновика (если сброс персистнут). */ + planModeId?: number; + /** Объём сброса, МБ (из ответа бэка). */ + volumeMb?: number; }; export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { @@ -35,14 +50,64 @@ export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { return { spacecraftId, works: [] }; } - const embeddedWorks = readEmbeddedWorks(plan); return { planId: plan.planId, spacecraftId: plan.spacecraftId, - works: embeddedWorks.length > 0 ? embeddedWorks : [fallbackPlanWork(plan)] + // Черновик содержит только правки, внесённые в редакторе. Существующие включения + // плана грузятся отдельно (fetchPlanModes) и показываются как read-only. + works: readEmbeddedWorks(plan) }; } +/** + * Преобразует включения плана (plan modes из pcp-tgu-service) в работы для отображения + * на таймлайне редактора. Это read-only представление фактического состава плана: + * SURVEY → съёмка, DROP → сброс на НКПОР. + */ +export function planModesToWorks(modes: PlanMode[]): EditorWork[] { + return modes.flatMap((mode): EditorWork[] => { + if (mode.timeStart == null) return []; + const startMs = parseLocal(mode.timeStart); + if (!Number.isFinite(startMs)) return []; + const endMs = startMs + Math.max(1, mode.duration) * 1000; + + if (mode.type === "SURVEY") { + return [{ + id: `survey-${mode.id}`, + kind: "shoot", + label: `Съёмка · виток ${mode.revolution}`, + startMs, + endMs, + origin: "plan", + planModeId: mode.id, + revolution: mode.revolution, + rollDeg: mode.roll, + centerLat: mode.lat, + centerLon: mode.longitude, + volumeMb: mode.volumeMb, + // Центр контура — он же опорная точка для проверки видимости. + targetLat: mode.lat, + targetLon: mode.longitude + }]; + } + + return [{ + id: `drop-${mode.id}`, + kind: "downlink", + label: mode.station ? `Сброс · ${mode.station}` : "Сброс на НКПОР", + startMs, + endMs, + origin: "plan", + planModeId: mode.id, + revolution: mode.revolution, + stationId: mode.station ?? undefined, + surveyIds: mode.surveys, + volumeMb: mode.volumeMb + }]; + }); +} + + export function mutate( history: DraftHistory, transform: (draft: Draft) => Draft, @@ -95,6 +160,13 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft { targetName: input.target.name, targetLat: input.target.lat, targetLon: input.target.lon, + planModeId: input.planModeId, + revolution: input.revolution, + rollDeg: input.rollDeg, + captureAngleDeg: input.captureAngleDeg, + centerLat: input.centerLat, + centerLon: input.centerLon, + volumeMb: input.volumeMb, origin: "added", isNew: true }; @@ -110,6 +182,8 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft { startMs: input.startMs, endMs: input.endMs, stationId: input.station.id, + planModeId: input.planModeId, + volumeMb: input.volumeMb, origin: "added", isNew: true }; @@ -131,41 +205,6 @@ export function deleteWork(draft: Draft, workId: string): Draft { }; } -export function duplicateWork( - draft: Draft, - workId: string, - newId: string, - offsetMs = DEFAULT_DUPLICATE_OFFSET_MS -): Draft { - const source = draft.works.find((work) => work.id === workId); - if (!source) { - return draft; - } - - const duplicated: EditorWork = { - ...source, - id: newId, - startMs: source.startMs + offsetMs, - endMs: source.endMs + offsetMs, - origin: "added", - isNew: true - }; - - return { ...draft, works: [...draft.works, duplicated] }; -} - -function fallbackPlanWork(plan: TguPlanUi): EditorDownlinkWork { - return { - id: `${plan.planId}:plan-window`, - kind: "downlink", - label: `План ТГУ · ${plan.kppId}`, - startMs: plan.startMs, - endMs: plan.endMs, - stationId: plan.kppId, - origin: "plan" - }; -} - function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] { const rawWorks = (plan as TguPlanUi & { works?: unknown }).works; if (!Array.isArray(rawWorks)) { @@ -217,7 +256,7 @@ function readTime(record: Record, key: string): number | undefi return value; } if (typeof value === "string") { - const parsed = new Date(value).getTime(); + const parsed = parseLocal(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index 17a93f2..629e22d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -1,5 +1,8 @@ import type { EditorWorkKind } from "./model/editorTypes"; +// Объём данных — общий форматтер (используется и в комплексном плане). +export { formatVolume } from "../../utils/dataVolume"; + export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]; export const DEFAULT_EDITOR_MODES = [ @@ -24,19 +27,53 @@ export function kindColor(kind: EditorWorkKind): string { export function formatTime(ms: number): string { const date = new Date(ms); - return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** + * Подпись деления оси с учётом шага: при шаге меньше минуты показываем секунды + * (ЧЧ:ММ:СС), иначе — ЧЧ:ММ. Так при сильном зуме видно более мелкий масштаб времени. + */ +export function formatTick(ms: number, stepMs: number): string { + const date = new Date(ms); + if (stepMs < 60_000) { + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + } + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } export function formatDate(ms: number): string { const date = new Date(ms); - return `${pad(date.getUTCDate())} ${EDITOR_MONTHS[date.getUTCMonth()]}`; + return `${pad(date.getDate())} ${EDITOR_MONTHS[date.getMonth()]}`; } export function formatDateTime(ms: number): string { return `${formatDate(ms)} ${formatTime(ms)}`; } +/** Время с секундами и одной десятой долей: ЧЧ:ММ:СС.с (локальное). */ +export function formatTimeSeconds(ms: number): string { + const date = new Date(ms); + const tenths = Math.floor((((ms % 1000) + 1000) % 1000) / 100); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${tenths}`; +} + +/** Дата и время с десятыми долями секунды: «дд мес ЧЧ:ММ:СС.с» (локальное). */ +export function formatDateTimeSeconds(ms: number): string { + return `${formatDate(ms)} ${formatTimeSeconds(ms)}`; +} + export function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + if (totalSeconds < 60) { + return `${totalSeconds} с`; + } + if (totalSeconds < 3600) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`; + } + const hours = ms / 3_600_000; if (hours < 24) { return `${Number.isInteger(hours) ? hours : hours.toFixed(1)} ч`; @@ -47,10 +84,19 @@ export function formatDuration(ms: number): string { return restHours > 0 ? `${days} сут ${restHours} ч` : `${days} сут`; } -export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): number[] { +/** Деления оси времени: {@link ticks} — отметки, {@link step} — выбранный шаг (мс). */ +export type TimelineTicks = { ticks: number[]; step: number }; + +export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: number): TimelineTicks { + const minuteMs = 60_000; const hourMs = 3_600_000; const dayMs = 24 * hourMs; - const steps = [hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs]; + // От секунд до недели — чем сильнее зум, тем мельче шаг, который влезает по minPx. + const steps = [ + 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, + minuteMs, 2 * minuteMs, 5 * minuteMs, 10 * minuteMs, 15 * minuteMs, 30 * minuteMs, + hourMs, 2 * hourMs, 3 * hourMs, 6 * hourMs, 12 * hourMs, dayMs, 2 * dayMs, 7 * dayMs + ]; const step = steps.find((item) => item * pxPerMs >= minPx) ?? steps[steps.length - 1]; const ticks: number[] = []; const firstTick = Math.ceil(fromMs / step) * step; @@ -59,7 +105,7 @@ export function niceTicks(fromMs: number, toMs: number, pxPerMs: number, minPx: ticks.push(timeMs); } - return ticks; + return { ticks, step }; } function pad(value: number): string { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts new file mode 100644 index 0000000..be34e76 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { MissionValidation } from "../../api/missionPlanningApi"; +import { validationToConflicts } from "./editorValidation"; +import type { EditorWork } from "./model/editorTypes"; + +function shoot(id: string, planModeId?: number): EditorWork { + return { + id, + kind: "shoot", + label: "Съёмка", + startMs: 0, + endMs: 1000, + origin: "plan", + planModeId + }; +} + +describe("validationToConflicts", () => { + it("maps backend conflicts onto editor work ids by planModeId", () => { + const works = [shoot("survey-7", 7), shoot("survey-9", 9)]; + const validation: MissionValidation = { + missionId: "m1", + conflicts: [ + { modeId: 7, rule: "TIME_OVERLAP", severity: "error", message: "Пересечение по времени", relatedModeId: 9 }, + { modeId: 7, rule: "TARGET_VISIBILITY", severity: "warning", message: "Вне зоны видимости" } + ] + }; + + const map = validationToConflicts(validation, works); + + expect(map["survey-7"]).toEqual(["Пересечение по времени", "Вне зоны видимости"]); + expect(map["survey-9"]).toBeUndefined(); + }); + + it("drops conflicts whose modeId has no matching work", () => { + const works = [shoot("survey-7", 7)]; + const validation: MissionValidation = { + missionId: "m1", + conflicts: [{ modeId: 42, rule: "TIME_OVERLAP", severity: "error", message: "x" }] + }; + + expect(validationToConflicts(validation, works)).toEqual({}); + }); + + it("returns an empty map for the stub (no conflicts)", () => { + const works = [shoot("survey-7", 7)]; + expect(validationToConflicts({ missionId: "m1", conflicts: [] }, works)).toEqual({}); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts new file mode 100644 index 0000000..a5e7e5e --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorValidation.ts @@ -0,0 +1,29 @@ +import type { MissionValidation } from "../../api/missionPlanningApi"; +import type { EditorConflictMap } from "./editorConflicts"; +import type { EditorWork } from "./model/editorTypes"; + +/** + * Проецирует авторитетные конфликты бэкенда (валидация миссии-черновика, keyed по numeric modeId) + * на карту конфликтов редактора (keyed по workId). Включения редактора несут `planModeId` — + * идентификатор сохранённого режима в БД; по нему и сопоставляем. Конфликты на режимах, которых + * нет среди работ редактора (например, ещё не подгружены), молча отбрасываются. + * + * MVP: бэк-валидатор — заглушка и возвращает пустой список, поэтому сейчас результат пуст. Маппинг + * написан вперёд: когда правила появятся, UI начнёт подсвечивать конкретные включения без доработок. + */ +export function validationToConflicts(validation: MissionValidation, works: EditorWork[]): EditorConflictMap { + const workIdByModeId = new Map(); + for (const work of works) { + if (work.planModeId != null) { + workIdByModeId.set(work.planModeId, work.id); + } + } + + const result: EditorConflictMap = {}; + for (const conflict of validation.conflicts) { + const workId = workIdByModeId.get(conflict.modeId); + if (!workId) continue; + result[workId] = [...(result[workId] ?? []), conflict.message]; + } + return result; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts index af6c0a5..59fc57c 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/model/editorTypes.ts @@ -12,6 +12,8 @@ export type EditorWorkBase = { endMs: number; origin: EditorWorkOrigin; isNew?: boolean; + /** Объём включения, МБ (считает бэк: скорость × длительность). Есть у включений плана и сохранённых правок. */ + volumeMb?: number; }; export type EditorShootWork = EditorWorkBase & { @@ -20,11 +22,28 @@ export type EditorShootWork = EditorWorkBase & { targetName?: string; targetLat?: number; targetLon?: number; + /** Идентификатор сохранённого режима съёмки в БД (есть у включений плана). */ + planModeId?: number; + /** Номер витка. */ + revolution?: number; + /** Крен (gamma), град. */ + rollDeg?: number; + /** Угол захвата (полуширина полосы), град. Есть только у свежепостроенного маршрута. */ + captureAngleDeg?: number; + /** Центр контура съёмки, град. */ + centerLat?: number; + centerLon?: number; }; export type EditorDownlinkWork = EditorWorkBase & { kind: "downlink"; stationId?: string; + /** Идентификатор сохранённого режима сброса в БД (есть у включений плана). */ + planModeId?: number; + /** Номер витка зоны радиовидимости. */ + revolution?: number; + /** Идентификаторы режимов съёмки (survey_modes), сбрасываемых в этом сеансе. */ + surveyIds?: number[]; }; export type EditorWork = EditorShootWork | EditorDownlinkWork; @@ -64,6 +83,16 @@ export type EditorVisibilityWindow = { startMs: number; endMs: number; quality: number; + /** Номер витка (если окно получено из баллистики). */ + revolution?: number; + /** Крен, град. */ + rollDeg?: number; + /** Угол Солнца, град. */ + sunAngleDeg?: number; + /** Наклонная дальность. */ + range?: number; + /** Направление витка. */ + revSign?: "ASC" | "DESC"; }; export type EditorVisibilityTrack = { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts index 55634a2..53eec8a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapLayers.ts @@ -1,8 +1,9 @@ +import type { StationDto } from "../../api/stationsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { parseWktPolygon } from "./geometry/wktParser"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; +import type { MapMarker, MapPolygon, Tgu2DMapScene } from "./model/mapTypes"; export type Tgu2DMapLayerAvailability = Partial>; @@ -12,17 +13,19 @@ export type Tgu2DMapSceneInput = { platforms: TguPlatformUi[]; hiddenSpacecraftIds?: ReadonlySet; requestItems?: RequestMapItem[]; + stationItems?: StationDto[]; }; const UNAVAILABLE = "Данные слоя недоступны в текущем API."; export function buildTgu2DMapScene(input: Tgu2DMapSceneInput): Tgu2DMapScene { const requestPolygons = buildRequestPolygons(input.requestItems ?? []); + const stationMarkers = buildStationMarkers(input.stationItems ?? []); return { polygons: requestPolygons, lines: [], - markers: [] + markers: stationMarkers }; } @@ -50,7 +53,25 @@ function buildRequestPolygons(items: RequestMapItem[]): MapPolygon[] { return polygons; } -export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMapScene): Tgu2DMapLayerAvailability { +export function buildStationMarkers(items: StationDto[]): MapMarker[] { + return items.map((item, index) => ({ + id: item.id ?? `station:${index}`, + layer: "stations" as const, + point: { lon: item.position.long, lat: item.position.lat }, + label: item.name, + zIndex: index, + stationNumber: item.number, + altitude: item.position.height, + elevationMin: item.elevationMin, + elevationMax: item.elevationMax + })); +} + +export function getLayerAvailability( + layers: Tgu2DMapLayersState, + scene: Tgu2DMapScene, + stationsFetchFailed?: boolean +): Tgu2DMapLayerAvailability { const availability: Tgu2DMapLayerAvailability = {}; if (layers.tracks && scene.lines.filter((line) => line.layer === "tracks").length === 0) { availability.tracks = UNAVAILABLE; @@ -61,7 +82,7 @@ export function getLayerAvailability(layers: Tgu2DMapLayersState, scene: Tgu2DMa if (layers.planWorks && scene.polygons.filter((polygon) => polygon.layer === "planWorks").length === 0) { availability.planWorks = UNAVAILABLE; } - if (layers.stations && scene.markers.filter((marker) => marker.layer === "stations").length === 0) { + if (layers.stations && stationsFetchFailed) { availability.stations = UNAVAILABLE; } if (layers.spacecraftMarkers && scene.markers.filter((marker) => marker.layer === "spacecraftMarkers").length === 0) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx new file mode 100644 index 0000000..29cac64 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx @@ -0,0 +1,116 @@ +import type { RequestMapItem } from "../../model/requestTypes"; +import type { MapPolygon } from "./model/mapTypes"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; + +export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem }; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onClose: () => void; +}; + +export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) { + const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined; + + return ( + + ); +} + +function formatDateTime(value: string): string { + return formatLocalDateTime(parseLocal(value)); +} + +function formatDateRange(begin: string, end: string): string { + const b = new Date(parseLocal(begin)); + const e = new Date(parseLocal(end)); + const sameDay = + b.getFullYear() === e.getFullYear() && + b.getMonth() === e.getMonth() && + b.getDate() === e.getDate(); + const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" }; + const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" }; + const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`; + const endStr = sameDay + ? b.toLocaleTimeString("ru-RU", timeOpts) + : `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`; + return `${beginStr} – ${endStr}`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx index 9d85e1a..b957970 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapSidebar.tsx @@ -3,6 +3,8 @@ import { filterPlatforms, platformLabel } from "../tgu-planning/tguTimelineMappe import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers"; import type { RequestsState } from "./Tgu2DMapTab"; +import { parseLocal } from "../../utils/localTime"; +import { DateTime24Field } from "../../components/timeFields"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -60,8 +62,8 @@ export function Tgu2DMapSidebar({ onRequestsQuickRange(fromMs, fromMs + 30 * DAY_MS); }; - const requestsFromMs = new Date(requestsState.fromValue).getTime(); - const requestsToMs = new Date(requestsState.toValue).getTime(); + const requestsFromMs = parseLocal(requestsState.fromValue); + const requestsToMs = parseLocal(requestsState.toValue); const requestsRangeInvalid = !Number.isFinite(requestsFromMs) || !Number.isFinite(requestsToMs) || @@ -70,7 +72,7 @@ export function Tgu2DMapSidebar({ return ( ); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx new file mode 100644 index 0000000..fab4765 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx @@ -0,0 +1,43 @@ +import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; + +type StationSelection = Extract & { layer: "stations" }; + +type Props = { + selection: StationSelection; + onClose: () => void; +}; + +export function Tgu2DMapStationPanel({ selection, onClose }: Props) { + return ( + + ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx index 8082522..b02477a 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -1,16 +1,21 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; +import type { MapPolygon } from "./model/mapTypes"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; +import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; +import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; +import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; type Tgu2DMapTabProps = { range: TimelineRange; @@ -33,8 +38,8 @@ export type RequestsState = { function buildInitialRequestsState(range: TimelineRange): RequestsState { return { - fromValue: formatDateTimeLocal(range.fromMs), - toValue: formatDateTimeLocal(range.toMs), + fromValue: toLocalInputValue(range.fromMs), + toValue: toLocalInputValue(range.toMs), items: [], loading: false, loadedCount: 0, @@ -56,21 +61,33 @@ export function Tgu2DMapTab({ const [selectedObject, setSelectedObject] = useState(); const [hiddenIds, setHiddenIds] = useState>(new Set()); const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); + const [stations, setStations] = useState([]); + const [stationsFetchFailed, setStationsFetchFailed] = useState(false); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>(); + + useEffect(() => { + fetchStations() + .then((items) => { setStations(items); setStationsFetchFailed(false); }) + .catch(() => setStationsFetchFailed(true)); + }, []); const mapInterval = getTgu2DMapIntervalState(range, selectedPlan); const mapInvalidRange = invalidRange || mapInterval.tooLarge; const loadRequests = useCallback(async (fromValue: string, toValue: string) => { - const fromMs = new Date(fromValue).getTime(); - const toMs = new Date(toValue).getTime(); + const fromMs = parseLocal(fromValue); + const toMs = parseLocal(toValue); if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); try { const result = await fetchRequestsForMap( { - endFrom: new Date(fromMs).toISOString(), - beginTo: new Date(toMs).toISOString() + endFrom: toLocalIso(fromMs), + beginTo: toLocalIso(toMs) }, (loaded, total) => { setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })); @@ -107,8 +124,8 @@ export function Tgu2DMapTab({ }, []); const setRequestsQuickRange = useCallback((fromMs: number, toMs: number) => { - const fromValue = formatDateTimeLocal(fromMs); - const toValue = formatDateTimeLocal(toMs); + const fromValue = toLocalInputValue(fromMs); + const toValue = toLocalInputValue(toMs); setRequestsState((current) => ({ ...current, fromValue, toValue })); void loadRequests(fromValue, toValue); }, [loadRequests]); @@ -146,11 +163,15 @@ export function Tgu2DMapTab({ selectedPlan, platforms, hiddenSpacecraftIds: hiddenIds, - requestItems: layers.requests ? requestsState.items : [] + requestItems: layers.requests ? requestsState.items : [], + stationItems: layers.stations ? stations : [] }), - [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests] + [mapInterval.range, platforms, selectedPlan, hiddenIds, requestsState.items, layers.requests, stations, layers.stations] + ); + const layerAvailability = useMemo( + () => getLayerAvailability(layers, scene, stationsFetchFailed), + [layers, scene, stationsFetchFailed] ); - const layerAvailability = useMemo(() => getLayerAvailability(layers, scene), [layers, scene]); const overlayMessage = invalidRange ? "Некорректный интервал: from должен быть меньше to." @@ -162,6 +183,49 @@ export function Tgu2DMapTab({ setLayers((current) => ({ ...current, [key]: !current[key] })); }; + const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { + if (selection?.type === "marker" && selection.layer === "stations") { + setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" }); + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setSelectedObject(undefined); + } else { + setStationPanel(undefined); + setSelectedObject(selection); + } + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (polygons.length === 0) { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setStationPanel(undefined); + }, + [requestsState.items] + ); + + const closeRequestsPanel = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + }, []); + + const closeStationPanel = useCallback(() => { + setStationPanel(undefined); + }, []); + return (
-
+
0 || stationPanel != null) ? " has-requests-panel" : ""}`}> + {requestsAtPoint.length > 0 && ( + + )} + {stationPanel != null && ( + + )}
@@ -225,16 +304,6 @@ function formatDate(ms: number): string { }); } -function formatDateTimeLocal(ms: number): string { - const date = new Date(ms); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; -} - function layerLabel(layer: Tgu2DMapLayerKey): string { switch (layer) { case "tracks": @@ -243,6 +312,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string { return "Полоса обзора"; case "planWorks": return "Работы плана"; + case "complexPlan": + return "Комплексный план"; case "stations": return "Станции"; case "spacecraftMarkers": diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx index d56fae7..9057610 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapToolbar.tsx @@ -18,7 +18,7 @@ export function Tgu2DMapToolbar({ return (
-
Карта 2D
+
Карта
{satelliteLabel} · {intervalLabel}
diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx index 37096a0..ac200bb 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,15 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; +import { drawComplexPlan } from "./canvas/drawComplexPlan"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; import { drawStations } from "./canvas/drawStations"; import { drawSwath } from "./canvas/drawSwath"; import { drawTiles } from "./canvas/drawTiles"; import { drawTracks } from "./canvas/drawTracks"; -import { createMapProjection } from "./geometry/mapProjection"; -import { createPolygonSpatialIndex, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; +import { createMapProjection, normalizeLon } from "./geometry/mapProjection"; +import { createPolygonSpatialIndex, hitTestAllPolygons, hitTestPolygons, queryPolygonsByBBox } from "./geometry/spatialIndex"; import type { Tgu2DMapLayersState } from "./model/mapLayerTypes"; -import type { MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; +import type { GeoPoint, MapLine, MapMarker, MapPolygon, MapSize, MapViewState, ScreenPoint, Tgu2DMapScene } from "./model/mapTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; type Tgu2DMapViewProps = { @@ -17,12 +18,31 @@ type Tgu2DMapViewProps = { layers: Tgu2DMapLayersState; redrawToken: number; onObjectSelect: (selection?: Tgu2DMapSelection) => void; + onRequestsAtPoint?: (polygons: MapPolygon[]) => void; + hoveredRequestPolygonId?: string; + selectedRequestPolygonId?: string; + /** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */ + selectedObjectId?: string; + /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ + focusPoint?: GeoPoint; + focusToken?: number; + /** + * Включает выбор объекта под курсором: если в точке клика лежит несколько объектов + * (например, заявка и маршрут съёмки), их список отдаётся через onObjectsAtPoint вместо + * «молчаливого» выбора верхнего по z-индексу. Также делает кликабельными линии сброса. + */ + enableObjectChooser?: boolean; + /** Несколько объектов под точкой клика — список передаётся наверх (выбор делает оператор справа). */ + onObjectsAtPoint?: (selections: Tgu2DMapSelection[]) => void; pickMode?: boolean; onPickPoint?: (point: { lat: number; lon: number }) => void; targets?: Tgu2DMapTarget[]; pickTarget?: Tgu2DMapTarget; selectedTargetId?: string; onTargetSelect?: (id: string) => void; + drawMode?: boolean; + draftPolygon?: GeoPoint[]; + onDrawAddPoint?: (point: GeoPoint) => void; }; export type Tgu2DMapTarget = { @@ -59,28 +79,49 @@ export function Tgu2DMapView({ layers, redrawToken, onObjectSelect, + onRequestsAtPoint, + hoveredRequestPolygonId, + selectedRequestPolygonId, + selectedObjectId, + focusPoint, + focusToken, + enableObjectChooser = false, + onObjectsAtPoint, pickMode = false, onPickPoint, targets = [], pickTarget, selectedTargetId, - onTargetSelect + onTargetSelect, + drawMode = false, + draftPolygon = [], + onDrawAddPoint }: Tgu2DMapViewProps) { const wrapperRef = useRef(null); const canvasRef = useRef(null); const dragRef = useRef(undefined); + const hasDraggedRef = useRef(false); const hoverRef = useRef(undefined); + const hoverMarkerRef = useRef(undefined); + const selectedMarkerRef = useRef(undefined); const [size, setSize] = useState({ width: 1000, height: 560 }); const [view, setView] = useState(INITIAL_VIEW); const [tileVersion, setTileVersion] = useState(0); const handleTileLoad = useCallback(() => setTileVersion((v) => v + 1), []); const [tooltip, setTooltip] = useState(); + // Гео-координаты под курсором — показываем по центру внизу карты. + const [cursorGeo, setCursorGeo] = useState(); const selectedPolygonRef = useRef(undefined); + const selectedLineRef = useRef(undefined); const activePolygons = useMemo( () => scene.polygons.filter((polygon) => layers[polygon.layer]), [scene.polygons, layers] ); const polygonIndex = useMemo(() => createPolygonSpatialIndex(activePolygons), [activePolygons]); + const activeMarkers = useMemo( + () => scene.markers.filter((marker) => layers[marker.layer]), + [scene.markers, layers] + ); const projection = useMemo(() => createMapProjection(view, size), [view, size]); const visiblePolygons = useMemo(() => { // When the canvas is wider than the world tile, the entire globe is visible — @@ -120,6 +161,19 @@ export function Tgu2DMapView({ return () => resizeObserver.disconnect(); }, []); + // Перецентрирование по внешней команде (клик по включению в редакторе): центрируем карту + // на объекте и подтягиваем зум, чтобы он был хорошо виден. Триггер — смена focusToken, + // поэтому повторный клик по тому же включению снова возвращает карту к объекту. + useEffect(() => { + if (!focusPoint || focusToken === undefined) return; + setView((current) => ({ + centerLon: focusPoint.lon, + centerLat: Math.max(-82, Math.min(82, focusPoint.lat)), + zoom: Math.max(current.zoom, 4.5) + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusToken]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -145,6 +199,9 @@ export function Tgu2DMapView({ if (layers.swath) { drawSwath(context, visiblePolygons.filter((polygon) => polygon.layer === "swath"), projection, size); } + if (layers.complexPlan) { + drawComplexPlan(context, visiblePolygons.filter((polygon) => polygon.layer === "complexPlan"), projection, size); + } if (layers.planWorks) { drawPlanWorks(context, visiblePolygons.filter((polygon) => polygon.layer === "planWorks"), projection, size); } @@ -170,11 +227,16 @@ export function Tgu2DMapView({ const updateHover = useCallback( (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); - const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); - if (polygon?.id === hoverRef.current?.id) return; + setCursorGeo(projection.unproject(screenPoint)); + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + const polygon = marker ? undefined : hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); + if (marker?.id === hoverMarkerRef.current?.id && polygon?.id === hoverRef.current?.id) return; + + hoverMarkerRef.current = marker; hoverRef.current = polygon; - if (!polygon) { + + if (!marker && !polygon) { setTooltip(undefined); return; } @@ -182,15 +244,16 @@ export function Tgu2DMapView({ setTooltip({ x: screenPoint.x + 12, y: screenPoint.y + 12, - title: polygon.name, - subtitle: layerTitle(polygon.layer) + title: marker ? marker.label : polygon!.name, + subtitle: marker ? "Станция" : layerTitle(polygon!.layer) }); }, - [polygonIndex, projection, screenPointFromEvent] + [activeMarkers, polygonIndex, projection, screenPointFromEvent] ); const onPointerDown = (event: React.PointerEvent) => { const screenPoint = screenPointFromEvent(event); + hasDraggedRef.current = false; dragRef.current = { startX: screenPoint.x, startY: screenPoint.y, @@ -199,6 +262,8 @@ export function Tgu2DMapView({ wrapperRef.current?.setPointerCapture(event.pointerId); }; + const DRAG_THRESHOLD = 4; + const onPointerMove = (event: React.PointerEvent) => { const drag = dragRef.current; if (!drag) { @@ -207,8 +272,12 @@ export function Tgu2DMapView({ } const screenPoint = screenPointFromEvent(event); + setCursorGeo(projection.unproject(screenPoint)); const dx = screenPoint.x - drag.startX; const dy = screenPoint.y - drag.startY; + if (!hasDraggedRef.current && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) { + hasDraggedRef.current = true; + } const dragProjection = createMapProjection( { centerLon: drag.centerAtStart.lon, @@ -230,16 +299,109 @@ export function Tgu2DMapView({ wrapperRef.current?.releasePointerCapture(event.pointerId); }; + // Применить выбор конкретного объекта (из меню выбора либо при единственном попадании): + // выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect. + const applyEditorHit = useCallback((hit: EditorHit) => { + selectedPolygonRef.current = hit.kind === "polygon" ? hit.polygon : undefined; + selectedMarkerRef.current = hit.kind === "marker" ? hit.marker : undefined; + selectedLineRef.current = hit.kind === "line" ? hit.line : undefined; + onObjectSelect(hit.selection); + }, [onObjectSelect]); + + // Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки, + // сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу; + // ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели). + const handleEditorClick = useCallback((screenPoint: ScreenPoint) => { + const geoPoint = projection.unproject(screenPoint); + const hits: EditorHit[] = []; + + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); + + for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { + if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue; + hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); + } + + const dropLines = scene.lines.filter((line) => line.kind === "drop" && layers[line.layer]); + for (const line of hitTestLines(dropLines, screenPoint, projection)) { + hits.push({ kind: "line", line, selection: lineToSelection(line) }); + } + + if (hits.length >= 2 && onObjectsAtPoint) { + // Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectsAtPoint(hits.map((hit) => hit.selection)); + return; + } + if (hits.length >= 1) { + applyEditorHit(hits[0]); + return; + } + + // Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор. + const fallback = hitTestPolygons(polygonIndex, geoPoint); + selectedPolygonRef.current = fallback; + selectedMarkerRef.current = undefined; + selectedLineRef.current = undefined; + onObjectSelect(fallback ? polygonToSelection(fallback) : undefined); + }, [activeMarkers, applyEditorHit, layers, onObjectSelect, onObjectsAtPoint, polygonIndex, projection, scene.lines]); + const onClick = (event: React.MouseEvent) => { + if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); + if (drawMode) { + onDrawAddPoint?.(projection.unproject(screenPoint)); + return; + } if (pickMode) { onPickPoint?.(projection.unproject(screenPoint)); onObjectSelect(undefined); return; } - const polygon = hitTestPolygons(polygonIndex, projection.unproject(screenPoint)); + if (enableObjectChooser) { + handleEditorClick(screenPoint); + return; + } + + const marker = hitTestMarkers(activeMarkers, screenPoint, projection); + if (marker) { + selectedMarkerRef.current = marker; + selectedPolygonRef.current = undefined; + onRequestsAtPoint?.([]); + onObjectSelect({ + type: "marker", + id: marker.id, + name: marker.label, + layer: marker.layer, + stationNumber: marker.stationNumber, + lat: marker.point.lat, + lon: marker.point.lon, + altitude: marker.altitude, + elevationMin: marker.elevationMin, + elevationMax: marker.elevationMax + }); + return; + } + + const geoPoint = projection.unproject(screenPoint); + const polygon = hitTestPolygons(polygonIndex, geoPoint); + + if (polygon?.layer === "requests" && onRequestsAtPoint) { + const allRequests = hitTestAllPolygons(polygonIndex, geoPoint).filter((p) => p.layer === "requests"); + onRequestsAtPoint(allRequests); + selectedPolygonRef.current = undefined; + selectedMarkerRef.current = undefined; + onObjectSelect(undefined); + return; + } + + onRequestsAtPoint?.([]); selectedPolygonRef.current = polygon; + selectedMarkerRef.current = undefined; if (!polygon) { onObjectSelect(undefined); @@ -255,7 +417,8 @@ export function Tgu2DMapView({ spacecraftId: polygon.spacecraftId, requestId: polygon.requestId, surveyType: polygon.surveyType, - status: polygon.status + status: polygon.status, + modeId: polygon.modeId }); }; @@ -276,11 +439,28 @@ export function Tgu2DMapView({ }; const selectedPolygon = selectedPolygonRef.current; - const overlayPolygons = uniquePolygons([selectedPolygon, hoverRef.current]); + const selectedMarker = selectedMarkerRef.current; + const externalSelectedPolygon = useMemo(() => { + const id = selectedRequestPolygonId ?? selectedObjectId; + return id ? activePolygons.find((p) => p.id === id) : undefined; + }, [selectedRequestPolygonId, selectedObjectId, activePolygons]); + // Подсветка линии (сброс/трасса) по тому же selectedObjectId — линии живут вне activePolygons. + const externalSelectedLine = useMemo( + () => (selectedObjectId ? scene.lines.find((line) => line.id === selectedObjectId) : undefined), + [selectedObjectId, scene.lines] + ); + // Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId). + const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]); + const externalHoveredPolygon = useMemo( + () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, + [hoveredRequestPolygonId, activePolygons] + ); + const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]); + const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); return (
{ dragRef.current = undefined; hoverRef.current = undefined; + hoverMarkerRef.current = undefined; setTooltip(undefined); + setCursorGeo(undefined); }} onWheel={onWheel} > {tooltip.subtitle}
)} + {cursorGeo &&
{formatLatLon(cursorGeo)}
}
© OpenStreetMap contributors · © CARTO
@@ -352,12 +586,23 @@ export function Tgu2DMapView({ ); } +/** Подпись координат под курсором: широта/долгота с полушариями. */ +function formatLatLon(point: GeoPoint): string { + const lat = point.lat; + const lon = normalizeLon(point.lon); + const latStr = `${Math.abs(lat).toFixed(4)}° ${lat >= 0 ? "с.ш." : "ю.ш."}`; + const lonStr = `${Math.abs(lon).toFixed(4)}° ${lon >= 0 ? "в.д." : "з.д."}`; + return `${latStr}, ${lonStr}`; +} + function layerTitle(layer: MapPolygon["layer"]): string { switch (layer) { case "planWorks": return "Работы плана"; case "swath": return "Полоса обзора"; + case "complexPlan": + return "Комплексный план"; case "tracks": return "Трасса КА"; case "requests": @@ -365,6 +610,50 @@ function layerTitle(layer: MapPolygon["layer"]): string { } } +function unwrapX(points: { x: number; y: number }[], worldSize: number): { x: number; y: number }[] { + if (points.length === 0) return []; + const out: { x: number; y: number }[] = [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 hitTestMarkers( + markers: MapMarker[], + screenPoint: ScreenPoint, + projection: ReturnType, + hitRadius = 10 +): MapMarker | undefined { + let best: MapMarker | undefined; + let bestDist = hitRadius; + for (const marker of markers) { + const pt = projection.project(marker.point); + const dist = Math.sqrt((pt.x - screenPoint.x) ** 2 + (pt.y - screenPoint.y) ** 2); + if (dist <= bestDist) { + bestDist = dist; + best = marker; + } + } + return best; +} + +function uniqueMarkers(markers: Array): MapMarker[] { + const result: MapMarker[] = []; + const ids = new Set(); + for (const marker of markers) { + if (!marker || ids.has(marker.id)) continue; + ids.add(marker.id); + result.push(marker); + } + return result; +} + function uniquePolygons(polygons: Array): MapPolygon[] { const result: MapPolygon[] = []; const ids = new Set(); @@ -375,3 +664,99 @@ function uniquePolygons(polygons: Array): MapPolygon[] { } return result; } + +function uniqueLines(lines: Array): MapLine[] { + const result: MapLine[] = []; + const ids = new Set(); + for (const line of lines) { + if (!line || ids.has(line.id)) continue; + ids.add(line.id); + result.push(line); + } + return result; +} + +/** Один объект-кандидат под курсором: несёт исходный объект (для подсветки) и его выделение. */ +type EditorHit = + | { kind: "marker"; marker: MapMarker; selection: Tgu2DMapSelection } + | { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection } + | { kind: "line"; line: MapLine; selection: Tgu2DMapSelection }; + +function markerToSelection(marker: MapMarker): Tgu2DMapSelection { + return { + type: "marker", + id: marker.id, + name: marker.label, + layer: marker.layer, + stationNumber: marker.stationNumber, + lat: marker.point.lat, + lon: marker.point.lon, + altitude: marker.altitude, + elevationMin: marker.elevationMin, + elevationMax: marker.elevationMax + }; +} + +function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { + return { + type: "polygon", + id: polygon.id, + name: polygon.name, + layer: polygon.layer, + planId: polygon.planId, + spacecraftId: polygon.spacecraftId, + requestId: polygon.requestId, + surveyType: polygon.surveyType, + status: polygon.status, + modeId: polygon.modeId + }; +} + +function lineToSelection(line: MapLine): Tgu2DMapSelection { + return { + type: "line", + id: line.id, + name: line.name ?? "Сброс", + layer: line.layer, + kind: line.kind, + modeId: line.modeId + }; +} + +/** Линии, проходящие в пределах допуска (px) от точки клика, отсортированные по близости. */ +function hitTestLines( + lines: MapLine[], + screenPoint: ScreenPoint, + projection: ReturnType, + tolerance = 7 +): MapLine[] { + const worldSize = projection.worldSize; + const hits: Array<{ line: MapLine; dist: number }> = []; + for (const line of lines) { + if (line.points.length < 2) continue; + const pts = unwrapX(line.points.map((point) => projection.project(point)), worldSize); + let best = Number.POSITIVE_INFINITY; + for (const dx of [0, worldSize, -worldSize]) { + for (let i = 1; i < pts.length; i++) { + const d = distToSegment( + screenPoint, + { x: pts[i - 1].x + dx, y: pts[i - 1].y }, + { x: pts[i].x + dx, y: pts[i].y } + ); + if (d < best) best = d; + } + } + if (best <= tolerance) hits.push({ line, dist: best }); + } + return hits.sort((a, b) => a.dist - b.dist).map((h) => h.line); +} + +function distToSegment(p: ScreenPoint, a: ScreenPoint, b: ScreenPoint): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts new file mode 100644 index 0000000..367baf2 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawComplexPlan.ts @@ -0,0 +1,61 @@ +import type { MapProjection } from "../geometry/mapProjection"; +import type { MapPolygon } from "../model/mapTypes"; + +type Pt = { x: number; y: number }; + +/** + * «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на + * полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту. + * Та же логика, что в drawSwath/drawPlanWorks — фикс антимеридиана. + */ +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; +} + +/** + * Контуры комплексного плана (этап 4). Отдельный слой со своим (янтарным) цветом, чтобы + * визуально не путать с «полосой обзора» (swath) и «работами плана» (planWorks). Рисуется + * в трёх копиях мира с отсечением по видимой области — как остальные полигональные слои. + */ +export function drawComplexPlan( + ctx: CanvasRenderingContext2D, + polygons: MapPolygon[], + projection: MapProjection, + viewport: { width: number; height: number } +) { + ctx.save(); + ctx.fillStyle = "rgba(240, 170, 70, 0.14)"; + ctx.strokeStyle = "rgba(240, 170, 70, 0.62)"; + ctx.lineWidth = 1.2; + for (const polygon of polygons) { + if (polygon.points.length < 3) continue; + const raw = polygon.points.map((point) => projection.project(point)); + const pts = unwrapX(raw, projection.worldSize); + for (const dx of [0, projection.worldSize, -projection.worldSize]) { + const visible = pts.some((point) => { + const x = point.x + dx; + return x >= -64 && x <= viewport.width + 64 && point.y >= -64 && point.y <= viewport.height + 64; + }); + if (!visible) continue; + ctx.beginPath(); + 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(); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts index b1bbc57..e42ca6f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawPlanWorks.ts @@ -2,6 +2,26 @@ import { statusStyle } from "../../tgu-planning/tguStatus"; import type { MapProjection } from "../geometry/mapProjection"; import type { MapPolygon } from "../model/mapTypes"; +type Pt = { x: number; y: number }; + +/** + * «Разворачивает» X-координаты так, чтобы соседние точки не расходились больше чем на + * полмира — иначе полигон, пересекающий антимеридиан (180°), рисуется через всю карту. + */ +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; +} + export function drawPlanWorks( ctx: CanvasRenderingContext2D, polygons: MapPolygon[], @@ -11,26 +31,31 @@ export function drawPlanWorks( ctx.save(); for (const polygon of polygons) { if (polygon.points.length < 3) continue; - const screenPoints = polygon.points.map((point) => projection.project(point)); - if (!screenPoints.some((point) => point.x >= -32 && point.x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32)) { - continue; - } + const raw = polygon.points.map((point) => projection.project(point)); + const pts = unwrapX(raw, projection.worldSize); const color = statusStyle(polygon.status || "PLANNED").color; - ctx.beginPath(); - screenPoints.forEach((point, index) => { - if (index === 0) { - ctx.moveTo(point.x, point.y); - } else { - ctx.lineTo(point.x, point.y); - } - }); - ctx.closePath(); ctx.fillStyle = hexToRgba(color, 0.2); ctx.strokeStyle = hexToRgba(color, 0.72); ctx.lineWidth = 1.2; - ctx.fill(); - ctx.stroke(); + + // Рисуем полигон в трёх копиях мира (текущей и соседних), чтобы он был виден + // независимо от того, какая копия попала в видимую область при зуме/панораме. + for (const dx of [0, projection.worldSize, -projection.worldSize]) { + const visible = pts.some((point) => { + const x = point.x + dx; + return x >= -32 && x <= viewport.width + 32 && point.y >= -32 && point.y <= viewport.height + 32; + }); + if (!visible) continue; + ctx.beginPath(); + 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(); + ctx.fill(); + ctx.stroke(); + } } ctx.restore(); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts index c8084f4..07a58f0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/canvas/drawTracks.ts @@ -1,16 +1,26 @@ import type { MapProjection } from "../geometry/mapProjection"; import type { MapLine } from "../model/mapTypes"; +const TRACK_STYLE = { stroke: "rgba(104, 195, 189, 0.76)", width: 1.2 }; +// Сброс — толстая линия поверх трассы, на отрезке от начала до конца сеанса. +// Цвет приглушённо-малиновый: отдельный тёплый оттенок, контрастный к бирюзовым +// виткам трассы (не теряется среди них) и не совпадающий с цветами заявок/маршрутов, +// но без кричащей яркости, чтобы не мешать просмотру контуров съёмки. +const DROP_STYLE = { stroke: "rgba(214, 96, 138, 0.82)", width: 5 }; + export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], projection: MapProjection) { ctx.save(); ctx.lineCap = "round"; ctx.lineJoin = "round"; - ctx.strokeStyle = "rgba(104, 195, 189, 0.76)"; - ctx.lineWidth = 1.2; const wrapThreshold = projection.worldSize / 2; - for (const line of lines) { + // Сбросы рисуем после трасс, чтобы их яркая линия легла поверх. + const ordered = [...lines].sort((a, b) => Number(a.kind === "drop") - Number(b.kind === "drop")); + for (const line of ordered) { if (line.points.length < 2) continue; + const style = line.kind === "drop" ? DROP_STYLE : TRACK_STYLE; + ctx.strokeStyle = style.stroke; + ctx.lineWidth = style.width; ctx.beginPath(); let prevX: number | undefined; for (const point of line.points) { @@ -27,4 +37,3 @@ export function drawTracks(ctx: CanvasRenderingContext2D, lines: MapLine[], proj ctx.restore(); } - diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts index 6f2d1b2..4c2b25e 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.test.ts @@ -45,5 +45,28 @@ describe("polygon spatial index", () => { expect(queryPolygonsByBBox(index, { minLon: 11, minLat: 11, maxLon: 12, maxLat: 12 }).map((polygon) => polygon.id)) .toEqual(["top"]); }); + + it("matches polygons whose longitudes are in a different 360° window (western hemisphere)", () => { + // Контур в долготах 0..360: западное полушарие ~234° вместо −126°. + const western: MapPolygon = { + id: "western", + name: "Western survey route", + kind: "planWork", + layer: "planWorks", + zIndex: 1, + points: [ + { lon: 233, lat: 48 }, + { lon: 235, lat: 48 }, + { lon: 235, lat: 50 }, + { lon: 233, lat: 50 } + ] + }; + const index = createPolygonSpatialIndex([western]); + + // Видовой bbox в нормализованных долготах [−128, −124] — те же координаты, что и контур, со сдвигом −360°. + expect( + queryPolygonsByBBox(index, { minLon: -128, minLat: 47, maxLon: -124, maxLat: 51 }).map((polygon) => polygon.id) + ).toEqual(["western"]); + }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts index e971931..31463fb 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -20,14 +20,47 @@ export function createPolygonSpatialIndex(polygons: MapPolygon[]): PolygonSpatia }; } +export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon[] { + const hits: MapPolygon[] = []; + + for (const entry of index.entries) { + const inBbox = + bboxContainsPoint(entry.bbox, point) || + bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) || + bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat }); + if (!inBbox) continue; + + const normalized = unwrapLon(entry.polygon.points); + const hit = + pointInPolygon(point, normalized) || + pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) || + pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized); + + if (hit) hits.push(entry.polygon); + } + + return hits.sort((a, b) => b.zIndex - a.zIndex); +} + export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): MapPolygon | undefined { let selected: MapPolygon | undefined; for (const entry of index.entries) { - if (!bboxContainsPoint(entry.bbox, point)) continue; - if (!pointInPolygon(point, entry.polygon.points)) continue; + // Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped. + const inBbox = + bboxContainsPoint(entry.bbox, point) || + bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) || + bboxContainsPoint(entry.bbox, { lon: point.lon - 360, lat: point.lat }); + if (!inBbox) continue; - if (!selected || entry.polygon.zIndex >= selected.zIndex) { + // Normalize polygon lons to be continuous (same idea as unwrapX for screen coords). + const normalized = unwrapLon(entry.polygon.points); + const hit = + pointInPolygon(point, normalized) || + pointInPolygon({ lon: point.lon + 360, lat: point.lat }, normalized) || + pointInPolygon({ lon: point.lon - 360, lat: point.lat }, normalized); + + if (hit && (!selected || entry.polygon.zIndex >= selected.zIndex)) { selected = entry.polygon; } } @@ -37,8 +70,29 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma export function queryPolygonsByBBox(index: PolygonSpatialIndex, bbox: BBox): MapPolygon[] { return index.entries - .filter((entry) => entry.bbox.minLon <= bbox.maxLon && entry.bbox.maxLon >= bbox.minLon) .filter((entry) => entry.bbox.minLat <= bbox.maxLat && entry.bbox.maxLat >= bbox.minLat) + // Долготу сверяем с учётом ±360°: контуры из бэкенда могут приходить в долготах 0..360 + // (западное полушарие как ~234° вместо −126°). Без этого они выпадают из набора отрисовки, + // хотя остаются кликабельными (hit-test уже учитывает сдвиги), — контур «пропадает» на карте. + .filter((entry) => + [0, 360, -360].some( + (shift) => entry.bbox.minLon + shift <= bbox.maxLon && entry.bbox.maxLon + shift >= bbox.minLon + ) + ) .map((entry) => entry.polygon); } +function unwrapLon(points: GeoPoint[]): GeoPoint[] { + if (points.length === 0) return points; + const out: GeoPoint[] = [points[0]]; + let prevLon = points[0].lon; + for (let i = 1; i < points.length; i++) { + let lon = points[i].lon; + while (lon - prevLon > 180) lon -= 360; + while (prevLon - lon > 180) lon += 360; + out.push({ ...points[i], lon }); + prevLon = lon; + } + return out; +} + diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts index 54ba229..6dff8ab 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -1,4 +1,11 @@ -export type Tgu2DMapLayerKey = "tracks" | "swath" | "planWorks" | "stations" | "spacecraftMarkers" | "requests"; +export type Tgu2DMapLayerKey = + | "tracks" + | "swath" + | "planWorks" + | "complexPlan" + | "stations" + | "spacecraftMarkers" + | "requests"; export type Tgu2DMapLayersState = Record; @@ -9,31 +16,16 @@ export type Tgu2DMapLayerDefinition = { }; export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ - { - key: "tracks", - label: "Трасса КА", - description: "Проекция движения КА на поверхность." - }, - { - key: "swath", - label: "Полоса обзора", - description: "Массовые полосы обзора через canvas." - }, { key: "planWorks", label: "Работы плана", - description: "Полигоны работ выбранного плана." + description: "Маршруты съёмки из выбранного плана." }, { key: "stations", label: "Станции", description: "Наземные станции приёма." }, - { - key: "spacecraftMarkers", - label: "Маркеры КА", - description: "Текущие или расчётные позиции КА." - }, { key: "requests", label: "Заявки на съёмку", @@ -42,11 +34,12 @@ export const TGU_2D_MAP_LAYERS: Tgu2DMapLayerDefinition[] = [ ]; export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { - tracks: true, - swath: true, + tracks: false, + swath: false, planWorks: true, + complexPlan: false, stations: true, - spacecraftMarkers: true, + spacecraftMarkers: false, requests: true }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts index 98829d1..e2af403 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -5,12 +5,24 @@ export type Tgu2DMapSelection = type: "polygon"; id: string; name: string; - layer: "planWorks" | "swath" | "tracks" | "requests"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; planId?: string; spacecraftId?: string; requestId?: string; surveyType?: string; status?: TguPlanStatus; + /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ + modeId?: number; + } + | { + type: "line"; + id: string; + name: string; + layer: "tracks"; + /** Вид линии: трасса КА или сеанс сброса. */ + kind?: "track" | "drop"; + /** id режима плана (drop) — связь с работой таймлайна. */ + modeId?: number; } | { type: "marker"; @@ -18,5 +30,11 @@ export type Tgu2DMapSelection = name: string; layer: "stations" | "spacecraftMarkers"; spacecraftId?: string; + stationNumber?: number; + lat?: number; + lon?: number; + altitude?: number; + elevationMin?: number; + elevationMax?: number; }; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts index a4c6c85..227bf17 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/model/mapTypes.ts @@ -24,24 +24,32 @@ export type MapViewState = { export type MapPolygon = { id: string; name: string; - kind: "planWork" | "swath" | "trackZone" | "request"; - layer: "planWorks" | "swath" | "tracks" | "requests"; + kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; points: GeoPoint[]; status?: TguPlanStatus; planId?: string; spacecraftId?: string; requestId?: string; surveyType?: string; + /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ + modeId?: number; zIndex: number; }; export type MapLine = { id: string; + /** Человекочитаемое имя (для подсказок и выбора объекта под курсором), напр. «Сброс · виток N». */ + name?: string; layer: "tracks"; points: GeoPoint[]; spacecraftId?: string; passIndex?: number; zIndex: number; + /** Вид линии: трасса КА (по умолчанию) или сеанс сброса — яркая толстая линия на трассе. */ + kind?: "track" | "drop"; + /** id режима плана (survey/drop), к которому относится объект — для связи «включение ↔ объект на карте». */ + modeId?: number; }; export type MapMarker = { @@ -50,6 +58,10 @@ export type MapMarker = { point: GeoPoint; label: string; zIndex: number; + stationNumber?: number; + altitude?: number; + elevationMin?: number; + elevationMax?: number; }; export type Tgu2DMapScene = { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 5c676ae..1d5f154 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { TguPlanUi } from "../../model/timelineTypes"; import { DEFAULT_TGU_2D_MAP_LAYERS } from "./model/mapLayerTypes"; import { getTgu2DMapIntervalState } from "./tgu2DMapInterval"; +import { parseLocal } from "../../utils/localTime"; function plan(startTime: string, endTime: string): TguPlanUi { return { @@ -11,16 +12,16 @@ function plan(startTime: string, endTime: string): TguPlanUi { endTime, kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse(startTime), - endMs: Date.parse(endTime) + startMs: parseLocal(startTime), + endMs: parseLocal(endTime) }; } describe("tgu2DMapInterval", () => { it("marks common map interval longer than 7 days as too large", () => { const state = getTgu2DMapIntervalState({ - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-06-05T00:00:01") + fromMs: parseLocal("2026-05-29T00:00:00"), + toMs: parseLocal("2026-06-05T00:00:01") }); expect(state.tooLarge).toBe(true); @@ -30,8 +31,8 @@ describe("tgu2DMapInterval", () => { const selectedPlan = plan("2026-05-29T01:00:00", "2026-06-07T02:00:00"); const state = getTgu2DMapIntervalState( { - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-06-10T00:00:00") + fromMs: parseLocal("2026-05-29T00:00:00"), + toMs: parseLocal("2026-06-10T00:00:00") }, selectedPlan ); @@ -45,13 +46,14 @@ describe("tgu2DMapInterval", () => { }); }); - it("enables all requested 2D map layers by default", () => { + it("enables plan, stations and requests layers by default", () => { expect(DEFAULT_TGU_2D_MAP_LAYERS).toEqual({ - tracks: true, - swath: true, + tracks: false, + swath: false, planWorks: true, + complexPlan: false, stations: true, - spacecraftMarkers: true, + spacecraftMarkers: false, requests: true }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx new file mode 100644 index 0000000..19f88fc --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/CreateChainPlanDialog.tsx @@ -0,0 +1,208 @@ +import { useEffect, useMemo, useState } from "react"; +import { + createChainPlan, + fetchZrvWindows, + type CreateChainPlanResponse +} from "../../api/tguPlanApi"; +import type { TguPlanUi } from "../../model/timelineTypes"; +import { TguApiError } from "../../model/tguTypes"; +import { formatLocalDateTime } from "../../utils/localTime"; +import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; + +type CreateChainPlanDialogProps = { + spacecraftId: string; + /** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */ + plans: TguPlanUi[]; + /** Выбранный (в фокусе) план — предзаполняет базу-предысторию, если он годится в базу. */ + initialBasePlanId?: string; + onClose: () => void; + /** Вызвать после создания черновика — чтобы он сразу появился на таймлайне (режим «Черновики»). */ + onCreated?: () => void; + /** Открыть созданный черновик в редакторе (переход на страницу редактирования). */ + onEdit: (draftPlanId: string) => void; +}; + +/** + * Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает + * цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт черновик плана. После + * создания сразу предлагается перейти к его редактированию. + */ +export function CreateChainPlanDialog({ + spacecraftId, + plans, + initialBasePlanId, + onClose, + onCreated, + onEdit +}: CreateChainPlanDialogProps) { + const [zrvOptions, setZrvOptions] = useState([]); + const [zrvLoading, setZrvLoading] = useState(true); + const [zrvError, setZrvError] = useState(); + + // "" — без истории; при открытии берём план в фокусе, если он годится в базу (этот КА, не + // проекция/черновик), иначе остаёмся без истории. + const [basePlanId, setBasePlanId] = useState(() => + initialBasePlanId && + plans.some( + (plan) => + plan.planId === initialBasePlanId && + plan.spacecraftId === spacecraftId && + plan.status !== "PLANNED" && + plan.status !== "DRAFT" + ) + ? initialBasePlanId + : "" + ); + const [startId, setStartId] = useState(""); + const [endId, setEndId] = useState(""); + + const [creating, setCreating] = useState(false); + const [error, setError] = useState(); + const [result, setResult] = useState(); + + // Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков). + const baseOptions = useMemo( + () => + plans + .filter((plan) => plan.spacecraftId === spacecraftId && plan.status !== "PLANNED" && plan.status !== "DRAFT") + .sort((a, b) => b.startMs - a.startMs), + [plans, spacecraftId] + ); + + useEffect(() => { + let cancelled = false; + setZrvLoading(true); + setZrvError(undefined); + void fetchZrvWindows(spacecraftId) + .then((windows) => { + if (!cancelled) setZrvOptions(futureZrvOptions(windows, Date.now())); + }) + .catch((err) => { + if (!cancelled) setZrvError(err instanceof TguApiError ? err.message : "Не удалось получить ЗРВ КА."); + }) + .finally(() => { + if (!cancelled) setZrvLoading(false); + }); + return () => { + cancelled = true; + }; + }, [spacecraftId]); + + const start = zrvOptions.find((option) => option.id === startId); + const end = zrvOptions.find((option) => option.id === endId); + const canCreate = Boolean(start && end) && !creating; + + const create = async () => { + if (!start || !end) return; + setCreating(true); + setError(undefined); + try { + const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end); + setResult(await createChainPlan(request)); + onCreated?.(); + } catch (err) { + setError( + err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план." + ); + } finally { + setCreating(false); + } + }; + + return ( +
+
+
+

Создать план

+ +
+ + {!result ? ( + <> + + + {zrvError &&
{zrvError}
} + {zrvLoading ? ( +
Загрузка ЗРВ…
+ ) : zrvOptions.length === 0 ? ( +
Нет будущих ЗРВ для этого КА.
+ ) : ( + <> + + + + + )} + + {error &&
{error}
} + +
+ + +
+ + ) : ( + <> +
+ Черновик плана создан ({result.draftPlanId}). +
+ +
+ + +
+
+ Откроется страница редактирования черновика. +
+ + )} +
+
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx index c4b6fac..b3a9b97 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguLegend.tsx @@ -4,12 +4,11 @@ export function TguLegend() { return (
{STATUS_STYLES.map((item) => ( - + {item.label} ))} - Стрелки показывают следующий план того же КА по времени.
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index 7712ec7..5de6831 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -2,17 +2,71 @@ import type { TguPlanDecision } from "../../model/tguTypes"; import type { TguPlanUi } from "../../model/timelineTypes"; import { platformLabel } from "./tguTimelineMapper"; import { statusStyle } from "./tguStatus"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; + +// Бэкенд (pcp-tgu-service) ждёт решения по плану только waiting-decision-grace-minutes +// минут после его startTime; затем IssueDuePlanWorker.tryAdvancePastUnconfirmedPlan +// «молчаливо» продолжает цепочку, оставляя план в WAITING_DECISION, но решение по нему +// больше не принимается (endpoint вернёт 409). Держим значение синхронным с +// config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes. +const DECISION_GRACE_MINUTES = 30; + +/** Терминально-негативные статусы, которые можно «снять с учёта» (синхронно с бэком DismissPlanUseCase). */ +const DISMISSIBLE_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS", "LAYIN_FAILED", "NOT_ACCEPTED", "SUPERSEDED"]); + +/** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */ +export type PlanModeCounts = { + survey: number; + drop: number; +}; type TguPlanDetailsProps = { plan?: TguPlanUi; + /** Состав включений плана для блока «Включения» (съёмки/сбросы). */ + modeCounts?: PlanModeCounts; + /** Цепочка КА на паузе после REJECTED — показываем подсказку про ручной исправленный план. */ + chainPaused?: boolean; decisionInFlight: boolean; notice?: string; error?: string; onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void; + /** Действия над черновиком (показываются только для status === "DRAFT"). */ + draftActionInFlight?: boolean; + onEditDraft?: (planId: string) => void; + onPromoteDraft?: (planId: string) => void; + onDeleteDraft?: (planId: string) => void; + /** Создать черновик-копию на основе этого плана (replace, окно 1:1, все включения). */ + onCreateDraftFromPlan?: (planId: string) => void; + /** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */ + onConfirmLayin?: (planId: string) => void; + /** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */ + layinConflict?: boolean; + /** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */ + onDismissPlan?: (planId: string) => void; + /** «Вернуть на учёт» ранее снятый план. */ + onRestorePlan?: (planId: string) => void; onClose: () => void; }; -export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) { +export function TguPlanDetails({ + plan, + modeCounts, + chainPaused, + decisionInFlight, + notice, + error, + onDecision, + draftActionInFlight, + onEditDraft, + onPromoteDraft, + onDeleteDraft, + onCreateDraftFromPlan, + onConfirmLayin, + layinConflict, + onDismissPlan, + onRestorePlan, + onClose +}: TguPlanDetailsProps) { if (!plan) { return (
+ +
+ +
); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx index e23ef5e..ef44fbf 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -1,38 +1,82 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { TimelineRange, TimelineRow } from "../../model/timelineTypes"; import { - buildSequentialLinks, buildTimeTicks, - buildTimelineSegments, + buildTimelineLayout, TIMELINE_AXIS_HEIGHT, TIMELINE_BOTTOM_PADDING, TIMELINE_LABEL_WIDTH, - TIMELINE_ROW_HEIGHT, - timeToX, - timelineHeight + TIMELINE_ZRV_BAND_HEIGHT, + timeToX } from "./tguTimelineLayout"; import { platformLabel } from "./tguTimelineMapper"; import { statusStyle } from "./tguStatus"; +import { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } from "../../utils/localTime"; + +export type ZrvWindow = { startMs: number; endMs: number; kppId: string }; + +// Терминально-«мёртвые» статусы: план выбыл из живой цепочки (вытеснен закладкой, отклонён, +// не принят, сбой закладки, неоднозначный старт). Применяется ТОЛЬКО к «следующему» (обратная +// ссылка basePlanId → выбранный, она неоднозначна): мёртвый план — это, как правило, коррекция, +// проигравшая гонку закладки и оставшаяся со ссылкой base_plan_id на победителя (особенно старые +// строки до наследования предшественника), и без фильтра она ложно показывалась бы «следующей». +// На «предыдущий» НЕ распространяется: это явная база самого выбранного плана (forward-ссылка из +// его поля basePlanId) — её показываем всегда, даже если база REJECTED/SUPERSEDED. +const DEAD_LINK_STATUSES: ReadonlySet = new Set([ + "SUPERSEDED", + "REJECTED", + "LAYIN_FAILED", + "NOT_ACCEPTED", + "START_AMBIGUOUS" +]); + +type ZrvBands = { + spacecraftId: string; + windows: ZrvWindow[]; +}; type TguTimelineProps = { rows: TimelineRow[]; range: TimelineRange; selectedPlanId?: string; invalidRange?: boolean; + /** ЗРВ выбранного КА — подсветка доступных зон на его строке. */ + zrvBands?: ZrvBands; + /** id выбранной ЗРВ (`${kppId}|${startMs}`) — подсветка кликнутой зоны. */ + selectedZrvId?: string; + /** КА с цепочкой на паузе после REJECTED — рисуем пометку у имени КА. */ + pausedSpacecraftIds?: ReadonlySet; onSelectPlan: (planId: string) => void; + /** Клик по ЗРВ: передаём пересекающиеся в этой точке зоны и индекс кликнутой. */ + onSelectZrv?: (windows: ZrvWindow[], selectedIndex: number) => void; }; -export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) { +function zrvId(zrv: ZrvWindow): string { + return `${zrv.kppId}|${zrv.startMs}`; +} + +export function TguTimeline({ + rows, + range, + selectedPlanId, + invalidRange, + zrvBands, + selectedZrvId, + pausedSpacecraftIds, + onSelectPlan, + onSelectZrv +}: TguTimelineProps) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(1200); const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28); const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth; - const height = Math.max(260, timelineHeight(rows.length)); const nowMs = Date.now(); - useEffect(() => { + useLayoutEffect(() => { if (!containerRef.current) return; + setContainerWidth(Math.floor(containerRef.current.getBoundingClientRect().width)); + const observer = new ResizeObserver(([entry]) => { setContainerWidth(Math.floor(entry.contentRect.width)); }); @@ -40,38 +84,31 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec return () => observer.disconnect(); }, []); - const segments = useMemo( - () => buildTimelineSegments(rows, range, timelineWidth), + const layout = useMemo( + () => buildTimelineLayout(rows, range, timelineWidth), [rows, range, timelineWidth] ); - const segmentByPlanId = useMemo( - () => new Map(segments.map((segment) => [segment.plan.planId, segment])), - [segments] - ); - const links = useMemo(() => buildSequentialLinks(rows), [rows]); + const segments = layout.segments; + const height = Math.max(260, layout.height); + // basePlanId выбранного плана — чтобы подсветить его прошлое/будущее звено по РЕАЛЬНОЙ связи + // (а не по времени): предыдущий = план с этим planId, следующий = план с basePlanId == выбранный. + const selectedBasePlanId = selectedPlanId + ? segments.find((segment) => segment.plan.planId === selectedPlanId)?.plan.basePlanId ?? undefined + : undefined; const ticks = useMemo(() => buildTimeTicks(range), [range]); const nowX = nowMs >= range.fromMs && nowMs <= range.toMs ? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth) : undefined; - if (invalidRange) { - return
Некорректный интервал: from должен быть меньше to.
; - } - - if (rows.length === 0 || segments.length === 0) { - return
Нет планов в выбранном интервале.
; - } - return (
- - - - - - - + {invalidRange ? ( +
Некорректный интервал: from должен быть меньше to.
+ ) : rows.length === 0 || segments.length === 0 ? ( +
Нет планов в выбранном интервале.
+ ) : ( + @@ -107,56 +144,125 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec )} - {rows.map((row, rowIndex) => { - const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT; + {layout.rowLayouts.map(({ row, rowIndex, y, height: rowHeight, zrvBandY }) => { return ( - + {platformLabel(row.platform, row.spacecraftId)} - + NORAD {row.platform?.noradId || row.spacecraftId} + {pausedSpacecraftIds?.has(row.spacecraftId) && ( + + ⏸ на паузе + Цепочка на паузе: план отклонён — создайте исправленный план («Создать план»). + + )} + {zrvBands?.spacecraftId === row.spacecraftId && ( + <> + + ЗРВ + + {zrvBands.windows.map((zrv, zrvIndex) => { + const from = Math.max(zrv.startMs, range.fromMs); + const to = Math.min(zrv.endMs, range.toMs); + if (to <= from) return null; + const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth); + const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth)); + const id = zrvId(zrv); + return ( + { + if (!onSelectZrv) return; + event.stopPropagation(); + // Зоны «в этой точке» — все ЗРВ, пересекающие кликнутую по времени + // (разные КПП могут перекрываться), как «заявки в точке» на карте. + const overlapping = zrvBands.windows.filter( + (w) => w.startMs < zrv.endMs && w.endMs > zrv.startMs + ); + const idx = overlapping.findIndex((w) => zrvId(w) === id); + onSelectZrv(overlapping, Math.max(0, idx)); + }} + > + ЗРВ {zrv.kppId} · {formatLocalDateTime(zrv.startMs)} – {formatLocalDateTime(zrv.endMs)} + + ); + })} + + )} ); })} - {links.map((link) => { - const from = segmentByPlanId.get(link.fromPlanId); - const to = segmentByPlanId.get(link.toPlanId); - if (!from || !to) return null; - return ; - })} - {segments.map((segment) => { - const y = - TIMELINE_AXIS_HEIGHT + - segment.rowIndex * TIMELINE_ROW_HEIGHT + - TIMELINE_ROW_HEIGHT / 2 + - segment.laneOffset; + const y = segment.centerY; const x1 = TIMELINE_LABEL_WIDTH + segment.x; const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width; const style = statusStyle(segment.plan.status); const selected = selectedPlanId === segment.plan.planId; + // Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного; + // следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени. + // prev — явная база выбранного плана (forward-ссылка), показываем всегда, даже мёртвую. + // next — обратная ссылка; мёртвый план (вытесненный/отклонённый конкурент того же слота) + // живым продолжением не подсвечиваем. + const liveLink = !DEAD_LINK_STATUSES.has(segment.plan.status); + const neighbor = selected + ? undefined + : selectedBasePlanId && segment.plan.planId === selectedBasePlanId + ? "prev" + : selectedPlanId && liveLink && segment.plan.basePlanId === selectedPlanId + ? "next" + : undefined; const labelVisible = segment.width > 92; + // При активном выборе все «непричастные» планы гасим до еле заметных, а текущий / + // прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана. + const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined; + // Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение). + const dismissed = Boolean(segment.plan.dismissedAt); return ( onSelectPlan(segment.plan.planId)} > - {segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "} - {formatDateTime(segment.plan.endTime)} + {segment.plan.planId} · {segment.plan.status} + {dismissed ? " · снят с учёта" : ""} + {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "} + {formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)} + {neighbor && ( + + )} {selected && } + )}
); } -function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) { - const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + from.laneOffset; - const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2 + to.laneOffset; - const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4; - const x2 = TIMELINE_LABEL_WIDTH + to.x - 6; - const mid = x1 + Math.max(18, (x2 - x1) / 2); - const path = x2 > x1 - ? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}` - : `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`; - - return ; -} - function formatTickDate(ms: number): string { - return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); + return formatLocalDate(ms); } function formatTickTime(ms: number): string { - return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + return formatLocalTime(ms); } function formatDateTime(value: string): string { - return new Date(value).toLocaleString("ru-RU"); + return formatLocalDateTime(parseLocal(value)); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx index 111cf31..56cd096 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguToolbar.tsx @@ -1,5 +1,5 @@ -import { TguLegend } from "./TguLegend"; import type { TguActiveTab } from "../../model/timelineTypes"; +import { DateTime24Field } from "../../components/timeFields"; type TguToolbarProps = { fromValue: string; @@ -12,7 +12,6 @@ type TguToolbarProps = { onToChange: (value: string) => void; onApply: () => void; onQuickRange: (hours: number) => void; - onRefresh: () => void; }; export function TguToolbar({ @@ -25,8 +24,7 @@ export function TguToolbar({ onFromChange, onToChange, onApply, - onQuickRange, - onRefresh + onQuickRange }: TguToolbarProps) { return (
@@ -56,7 +54,16 @@ export function TguToolbar({ aria-selected={activeTab === "map"} onClick={() => onTabChange("map")} > - Карта 2D + Карта + + + +
-
- {error &&
{error}
} ); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx new file mode 100644 index 0000000..6385cab --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguZrvPanel.tsx @@ -0,0 +1,74 @@ +import type { ZrvWindow } from "./TguTimeline"; +import { formatLocalDateTime } from "../../utils/localTime"; + +type Props = { + windows: ZrvWindow[]; + selectedIndex: number; + onSelect: (index: number) => void; + onClose: () => void; +}; + +/** + * Панель по выбранной ЗРВ в окне «Планы». Если в точке пересекается несколько зон + * (разные КПП), показываем список — по примеру панели заявок на вкладке «Карта». + */ +export function TguZrvPanel({ windows, selectedIndex, onSelect, onClose }: Props) { + const selected = windows[selectedIndex]; + + return ( + + ); +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return minutes > 0 ? `${minutes} мин ${seconds} с` : `${seconds} с`; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts new file mode 100644 index 0000000..f35c17d --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows"; +import type { ZrvWindowResponse } from "../../api/tguPlanApi"; + +const NOW = new Date(2026, 5, 3, 12, 0, 0).getTime(); + +function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse { + return { startTime: startIso, endTime: endIso, kppId }; +} + +describe("futureZrvOptions", () => { + it("keeps only future zones and sorts by start", () => { + const windows = [ + zrv("2026-06-03T15:00:00", "2026-06-03T15:10:00"), + zrv("2026-06-03T11:00:00", "2026-06-03T11:10:00"), // в прошлом → отбросить + zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00") + ]; + const options = futureZrvOptions(windows, NOW); + expect(options.map((o) => o.startMs)).toEqual([ + new Date(2026, 5, 3, 13, 0, 0).getTime(), + new Date(2026, 5, 3, 15, 0, 0).getTime() + ]); + }); + + it("produces naive (no zone) start/stop strings", () => { + const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00", "2026-06-03T13:10:00")], NOW); + expect(option.start).toBe("2026-06-03T13:00:00"); + expect(option.stop).toBe("2026-06-03T13:10:00"); + expect(option.kppId).toBe("KPP-1"); + }); +}); + +describe("buildChainPlanRequest", () => { + const start: ZrvOption = { + id: "a", startMs: Date.UTC(2026, 5, 3, 13, 0, 0), endMs: Date.UTC(2026, 5, 3, 13, 10, 0), + start: "2026-06-03T13:00:00", stop: "2026-06-03T13:10:00", kppId: "KPP-1" + }; + const end: ZrvOption = { + id: "b", startMs: Date.UTC(2026, 5, 3, 15, 0, 0), endMs: Date.UTC(2026, 5, 3, 15, 10, 0), + start: "2026-06-03T15:00:00", stop: "2026-06-03T15:10:00", kppId: "KPP-2" + }; + + it("spans from start zone begin to end zone finish and keeps the start kpp", () => { + const req = buildChainPlanRequest("25544", "base-1", start, end); + expect(req).toEqual({ + spacecraftId: "25544", + basePlanId: "base-1", + windowStart: "2026-06-03T13:00:00", + windowStop: "2026-06-03T15:10:00", + windowKppId: "KPP-1" + }); + }); + + it("allows a single-zone window (start == end zone)", () => { + const req = buildChainPlanRequest("25544", null, start, start); + expect(req.windowStart).toBe("2026-06-03T13:00:00"); + expect(req.windowStop).toBe("2026-06-03T13:10:00"); + expect(req.basePlanId).toBeNull(); + }); + + it("throws when the end zone finishes before the start begins", () => { + expect(() => buildChainPlanRequest("25544", null, end, start)).toThrow(); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts new file mode 100644 index 0000000..88bc613 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/createChainWindows.ts @@ -0,0 +1,65 @@ +import { parseLocal, toLocalIso } from "../../utils/localTime"; +import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi"; + +/** + * Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из + * конечной — конец окна. `start`/`stop` — naive (ISO без зоны, локальное), как ждёт `LocalDateTime` бэка; + * `startMs`/`endMs` — для сортировки/сравнения и подсветки на таймлайне. + */ +export type ZrvOption = { + /** Детерминированный id зоны (для value в select и key). */ + id: string; + startMs: number; + endMs: number; + /** Naive (локальное) начала зоны. */ + start: string; + /** Naive (локальное) конца зоны. */ + stop: string; + kppId: string; +}; + +/** Будущие ЗРВ-зоны (строго позже nowMs), по возрастанию начала. */ +export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] { + return windows + .map((zrv) => { + const startMs = parseLocal(zrv.startTime); + const endMs = parseLocal(zrv.endTime); + return { + id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`, + startMs, + endMs, + start: toNaive(startMs), + stop: toNaive(endMs), + kppId: zrv.kppId + }; + }) + .filter((option) => option.startMs > nowMs) + .sort((a, b) => a.startMs - b.startMs); +} + +/** + * Собирает тело запроса создания плана новой цепочки из выбора оператора. Окно — от начала стартовой + * зоны до конца конечной; `basePlanId` — предыстория (или null для цепочки без истории). Бросает, + * если конец окна не позже начала (конечная зона раньше стартовой). + */ +export function buildChainPlanRequest( + spacecraftId: string, + basePlanId: string | null, + start: ZrvOption, + end: ZrvOption +): CreateChainPlanRequest { + if (end.endMs <= start.startMs) { + throw new Error("Конец окна должен быть позже начала: выберите конечную ЗРВ не раньше стартовой."); + } + return { + spacecraftId, + basePlanId, + windowStart: start.start, + windowStop: end.stop, + windowKppId: start.kppId + }; +} + +function toNaive(ms: number): string { + return toLocalIso(ms); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts index f288b6e..c71c2f9 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts @@ -1,15 +1,55 @@ import type { TguPlanStatus } from "../../model/tguTypes"; import type { TimelineStatusStyle } from "../../model/timelineTypes"; +// Порядок — по жизненному циклу плана (см. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md). +// ACCEPTED из enum бэка здесь намеренно отсутствует: приёмка переводит план сразу в AWAITING_LAYIN. +// LAID_IN возникает по событию подтверждения закладки (AWAITING_LAYIN → LAID_IN) и делает план базой +// цепочки; конкуренты на то же окно при этом уходят в SUPERSEDED. export const STATUS_STYLES: TimelineStatusStyle[] = [ - { status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" }, - { status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" }, - { status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" }, - { status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" }, - { status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" }, - { status: "EXPIRED", label: "EXPIRED", color: "#495057", className: "status-expired" }, - { status: "SUPERSEDED", label: "SUPERSEDED", color: "#c7ced6", className: "status-superseded" }, - { status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" } + { + status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft", + description: "Черновик: план в стадии правки, в выдачу пока не идёт." + }, + { + status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned", + description: "Возможный будущий план (проекция цепочки)." + }, + { + status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing", + description: "Идёт выдача плана." + }, + { + status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting", + description: "План выдан, ожидается решение (принять / отклонить)." + }, + { + status: "AWAITING_LAYIN", label: "AWAITING_LAYIN", color: "#2f9e44", className: "status-awaiting-layin", + description: "План принят, ожидается подтверждение закладки." + }, + { + status: "LAID_IN", label: "LAID_IN", color: "#1b6b34", className: "status-laid-in", + description: "Получено подтверждение закладки плана." + }, + { + status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous", + description: "Ошибка после старта процесса выдачи плана. Требуется оператор." + }, + { + status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected", + description: "План отклонён, цепочка поставлена на паузу." + }, + { + status: "NOT_ACCEPTED", label: "NOT_ACCEPTED", color: "#e8590c", className: "status-not-accepted", + description: "План не принят, идёт срочная перевыдача на ближайшую ЗРВ." + }, + { + status: "LAYIN_FAILED", label: "LAYIN_FAILED", color: "#a61e4d", className: "status-layin-failed", + description: "План не заложен, идёт срочная перевыдача на ближайшую ЗРВ." + }, + { + status: "SUPERSEDED", label: "SUPERSEDED", color: "#6b7280", className: "status-superseded", + description: "Вытеснён: по тому же окну заложен другой план (коррекция). Терминальный." + } ]; const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style])); @@ -20,7 +60,8 @@ export function statusStyle(status: TguPlanStatus): TimelineStatusStyle { status, label: status || "UNKNOWN", color: "#79828d", - className: "status-unknown" + className: "status-unknown", + description: "Неизвестный статус." } ); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts index ede46ee..675a51d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it } from "vitest"; import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes"; -import { buildSequentialLinks, buildTimelineSegments, clipPlanToRange, timeToX } from "./tguTimelineLayout"; +import { buildTimelineLayout, clipPlanToRange, timeToX } from "./tguTimelineLayout"; +import { parseLocal } from "../../utils/localTime"; const range = { - fromMs: Date.parse("2026-05-29T00:00:00"), - toMs: Date.parse("2026-05-30T00:00:00") + fromMs: parseLocal("2026-05-29T00:00:00"), + toMs: parseLocal("2026-05-30T00:00:00") }; -function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi { +function plan( + id: string, + startTime: string, + endTime: string, + spacecraftId = "56756", + extra: Partial = {} +): TguPlanUi { return { planId: id, spacecraftId, @@ -15,14 +22,15 @@ function plan(id: string, startTime: string, endTime: string, spacecraftId = "56 endTime, kppId: "KPP-1", status: "PLANNED", - startMs: Date.parse(startTime), - endMs: Date.parse(endTime) + startMs: parseLocal(startTime), + endMs: parseLocal(endTime), + ...extra }; } describe("tguTimelineLayout", () => { it("maps time to x inside the selected interval", () => { - expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600); + expect(timeToX(parseLocal("2026-05-29T12:00:00"), range, 1200)).toBe(600); }); it("clips partially visible plans to the selected interval", () => { @@ -30,54 +38,63 @@ describe("tguTimelineLayout", () => { expect(clipped).toEqual({ startMs: range.fromMs, - endMs: Date.parse("2026-05-29T02:00:00") + endMs: parseLocal("2026-05-29T02:00:00") }); }); - it("builds links only between sequential plans of the same spacecraft", () => { - const rows: TimelineRow[] = [ - { - spacecraftId: "56756", - plans: [ - plan("p2", "2026-05-29T04:00:00", "2026-05-29T05:00:00"), - plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00") - ] - }, - { - spacecraftId: "777", - plans: [ - plan("p3", "2026-05-29T01:00:00", "2026-05-29T02:00:00", "777") - ] - } - ]; - - expect(buildSequentialLinks(rows)).toEqual([ - { spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" } - ]); - }); - - it("places sequential plans on alternating timeline lanes", () => { + it("keeps non-overlapping plans on a single lane", () => { const rows: TimelineRow[] = [ { spacecraftId: "56756", plans: [ plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00"), plan("p2", "2026-05-29T03:00:00", "2026-05-29T04:00:00"), - plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00"), - plan("p4", "2026-05-29T07:00:00", "2026-05-29T08:00:00"), - plan("p5", "2026-05-29T09:00:00", "2026-05-29T10:00:00"), - plan("p6", "2026-05-29T11:00:00", "2026-05-29T12:00:00") + plan("p3", "2026-05-29T05:00:00", "2026-05-29T06:00:00") ] } ]; - expect(buildTimelineSegments(rows, range, 1200).map((segment) => segment.laneOffset)).toEqual([ - 0, - -12, - 0, - 12, - 0, - -12 - ]); + const lanes = buildTimelineLayout(rows, range, 1200).segments.map((segment) => segment.lane); + expect(lanes).toEqual([0, 0, 0]); + }); + + it("stacks overlapping plans onto separate lanes", () => { + const rows: TimelineRow[] = [ + { + spacecraftId: "56756", + plans: [ + plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"), + plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00"), + plan("d3", "2026-05-29T02:30:00", "2026-05-29T04:30:00") + ] + } + ]; + + const layout = buildTimelineLayout(rows, range, 1200); + // Все три пересекаются — каждый на своей дорожке. + expect(new Set(layout.segments.map((segment) => segment.lane))).toEqual(new Set([0, 1, 2])); + // Разные дорожки — разные вертикальные координаты. + expect(new Set(layout.segments.map((segment) => segment.centerY)).size).toBe(3); + // Строка с тремя дорожками выше строки с одной. + expect(layout.rowLayouts[0].laneCount).toBe(3); + }); + + it("grows row height with the deepest lane stack", () => { + const single: TimelineRow[] = [ + { spacecraftId: "56756", plans: [plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")] } + ]; + const stacked: TimelineRow[] = [ + { + spacecraftId: "56756", + plans: [ + plan("d1", "2026-05-29T02:00:00", "2026-05-29T04:00:00"), + plan("d2", "2026-05-29T02:00:00", "2026-05-29T04:00:00") + ] + } + ]; + + expect(buildTimelineLayout(stacked, range, 1200).rowLayouts[0].height).toBeGreaterThan( + buildTimelineLayout(single, range, 1200).rowLayouts[0].height + ); }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts index 8e9b337..812e60d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineLayout.ts @@ -1,10 +1,28 @@ -import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes"; +import type { + TguPlanUi, + TimelinePlanSegment, + TimelineRange, + TimelineRow, + TimelineRowLayout +} from "../../model/timelineTypes"; export const TIMELINE_LABEL_WIDTH = 220; -export const TIMELINE_ROW_HEIGHT = 58; export const TIMELINE_AXIS_HEIGHT = 46; export const TIMELINE_BOTTOM_PADDING = 18; -export const TIMELINE_LANE_STEP = 12; + +/** + * Вертикальная раскладка строки КА. Планы, которые не пересекаются по горизонтали, лежат на одной + * дорожке (чистая цепочка — прямая линия). Если план пересёкся бы с уже размещённым, он уходит на + * следующую дорожку (выше/ниже), чтобы планы не сливались. Высота строки растёт под самый глубокий + * стек дорожек, а страйп ЗРВ опускается ниже планов. + */ +const PLAN_TOP_Y = 28; // центр верхней дорожки (lane 0) относительно верха строки +export const TIMELINE_LANE_STEP = 16; // шаг между дорожками пересекающихся планов +const ZRV_GAP = 16; // отступ от нижней дорожки планов до страйпа ЗРВ +const ZRV_BAND_MIN_Y = 52; // минимум: строка с одной дорожкой выглядит как раньше (высота 74) +export const TIMELINE_ZRV_BAND_HEIGHT = 14; +const ROW_BOTTOM_PAD = 8; +const SEGMENT_GAP_PX = 6; // минимальный горизонтальный зазор, иначе считаем планы пересекающимися export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number { return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth; @@ -21,55 +39,82 @@ export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startM }; } -export function buildTimelineSegments( +type PlacedPlan = { plan: TguPlanUi; x: number; x2: number; width: number }; + +/** + * Жадная раскладка по дорожкам (interval graph): для каждого плана берём первую дорожку, чья правая + * граница уже освободилась (с зазором SEGMENT_GAP_PX). Так пересекающиеся по горизонтали планы + * гарантированно оказываются на разных дорожках. Планы должны быть отсортированы по x. + */ +function packLanes(items: PlacedPlan[]): number[] { + const laneRightEdge: number[] = []; + return items.map((item) => { + let lane = laneRightEdge.findIndex((edge) => item.x >= edge + SEGMENT_GAP_PX); + if (lane === -1) { + lane = laneRightEdge.length; + } + laneRightEdge[lane] = item.x + item.width; + return lane; + }); +} + +function zrvBandYFor(rowTop: number, laneCount: number): number { + return rowTop + Math.max(ZRV_BAND_MIN_Y, PLAN_TOP_Y + (laneCount - 1) * TIMELINE_LANE_STEP + ZRV_GAP); +} + +export type TimelineLayout = { + rowLayouts: TimelineRowLayout[]; + segments: TimelinePlanSegment[]; + height: number; +}; + +/** + * Строит полную раскладку таймлайна: переменная высота строк (по числу дорожек), абсолютные + * y-координаты планов и страйпов ЗРВ. Единственный источник правды по вертикали — компонент только + * рисует по готовым координатам. + */ +export function buildTimelineLayout( rows: TimelineRow[], range: TimelineRange, timelineWidth: number -): TimelinePlanSegment[] { +): TimelineLayout { + const rowLayouts: TimelineRowLayout[] = []; const segments: TimelinePlanSegment[] = []; + let cursorY = TIMELINE_AXIS_HEIGHT; rows.forEach((row, rowIndex) => { - const visiblePlans = row.plans + const placed: PlacedPlan[] = row.plans .map((plan) => ({ plan, clipped: clipPlanToRange(plan, range) })) .filter((item): item is { plan: TguPlanUi; clipped: { startMs: number; endMs: number } } => item.clipped !== null) - .sort((a, b) => a.plan.startMs - b.plan.startMs || a.plan.endMs - b.plan.endMs); + .map(({ plan, clipped }) => { + const x = timeToX(clipped.startMs, range, timelineWidth); + const x2 = timeToX(clipped.endMs, range, timelineWidth); + return { plan, x, x2, width: Math.max(3, x2 - x) }; + }) + .sort((a, b) => a.x - b.x || a.x2 - b.x2); - visiblePlans.forEach((item, planIndex) => { - const x = timeToX(item.clipped.startMs, range, timelineWidth); - const x2 = timeToX(item.clipped.endMs, range, timelineWidth); + const lanes = packLanes(placed); + const laneCount = lanes.length === 0 ? 1 : Math.max(...lanes) + 1; + const zrvBandY = zrvBandYFor(cursorY, laneCount); + const height = zrvBandY - cursorY + TIMELINE_ZRV_BAND_HEIGHT + ROW_BOTTOM_PAD; + + placed.forEach((item, planIndex) => { segments.push({ plan: item.plan, rowIndex, - laneOffset: timelineLaneOffset(planIndex), - x, - x2, - width: Math.max(3, x2 - x) + lane: lanes[planIndex], + centerY: cursorY + PLAN_TOP_Y + lanes[planIndex] * TIMELINE_LANE_STEP, + x: item.x, + x2: item.x2, + width: item.width }); }); + + rowLayouts.push({ row, rowIndex, y: cursorY, height, laneCount, zrvBandY }); + cursorY += height; }); - return segments; -} - -export function timelineLaneOffset(index: number): number { - return [0, -1, 0, 1][index % 4] * TIMELINE_LANE_STEP; -} - -export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] { - const links: TimelineLink[] = []; - - for (const row of rows) { - const sorted = [...row.plans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs); - for (let index = 0; index < sorted.length - 1; index += 1) { - links.push({ - spacecraftId: row.spacecraftId, - fromPlanId: sorted[index].planId, - toPlanId: sorted[index + 1].planId - }); - } - } - - return links; + return { rowLayouts, segments, height: cursorY + TIMELINE_BOTTOM_PADDING }; } export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] { @@ -95,7 +140,3 @@ export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] return ticks; } - -export function timelineHeight(rowCount: number): number { - return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING; -} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts new file mode 100644 index 0000000..f48f9ed --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes"; +import type { TguPlanUi } from "../../model/timelineTypes"; +import { + isOperationalPlatform, + operationalSpacecraftIds, + pausedSpacecraftIds, + unresolvedProblemSpacecraftIds +} from "./tguTimelineMapper"; +import { toLocalIso } from "../../utils/localTime"; + +function platform(overrides: Partial): TguPlatform { + return { id: "x", noradId: 1, status: "OPERATIONAL", ...overrides }; +} + +function uiPlan(spacecraftId: string, status: TguPlanStatus, startMs: number): TguPlanUi { + return { + planId: `${spacecraftId}-${status}-${startMs}`, + spacecraftId, + startTime: toLocalIso(startMs), + endTime: toLocalIso(startMs + 3_600_000), + kppId: "KPP-1", + status, + startMs, + endMs: startMs + 3_600_000 + }; +} + +describe("isOperationalPlatform", () => { + it("принимает только статус OPERATIONAL без учёта регистра и пробелов", () => { + expect(isOperationalPlatform(platform({ status: "OPERATIONAL" }))).toBe(true); + expect(isOperationalPlatform(platform({ status: " operational " }))).toBe(true); + expect(isOperationalPlatform(platform({ status: "STANDBY" }))).toBe(false); + expect(isOperationalPlatform(platform({ status: "LOST" }))).toBe(false); + expect(isOperationalPlatform(platform({ status: null }))).toBe(false); + expect(isOperationalPlatform(platform({ status: undefined }))).toBe(false); + }); +}); + +describe("operationalSpacecraftIds", () => { + it("собирает norad-id в виде строк и пропускает платформы без norad-id", () => { + const ids = operationalSpacecraftIds([ + platform({ noradId: 2 }), + platform({ noradId: 30 }), + platform({ noradId: null }), + platform({ noradId: undefined }) + ]); + expect(ids).toEqual(new Set(["2", "30"])); + }); +}); + +describe("pausedSpacecraftIds", () => { + const t0 = Date.UTC(2026, 5, 4, 0, 0, 0); + const hour = 3_600_000; + + it("помечает КА паузой, если есть REJECTED и нет продвинувшего цепочку плана после него", () => { + const paused = pausedSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + // проекция и черновик паузу не снимают + uiPlan("100", "PLANNED", t0 + 2 * hour), + uiPlan("100", "DRAFT", t0) + ]); + expect(paused).toEqual(new Set(["100"])); + }); + + it("снимает паузу, если оператор отправил исправленный план (в полёте) на том же/более позднем окне", () => { + const paused = pausedSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "WAITING_DECISION", t0) + ]); + expect(paused.has("100")).toBe(false); + }); + + it("не помечает паузой, если цепочка ушла вперёд (ACCEPTED после отказа)", () => { + const paused = pausedSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "ACCEPTED", t0 + 5 * hour) + ]); + expect(paused.has("100")).toBe(false); + }); + + it("без REJECTED никого не помечает", () => { + expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set()); + }); +}); + +describe("unresolvedProblemSpacecraftIds (красный индикатор КА)", () => { + const t0 = Date.UTC(2026, 5, 4, 0, 0, 0); + const hour = 3_600_000; + + function dismissed(plan: TguPlanUi): TguPlanUi { + return { ...plan, dismissedAt: toLocalIso(t0) }; + } + + it("красит КА, если есть нерешённый REJECTED", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "REJECTED", t0)])).toEqual(new Set(["100"])); + }); + + it("красит КА на START_AMBIGUOUS", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "START_AMBIGUOUS", t0)])).toEqual(new Set(["100"])); + }); + + it("НЕ красит, если проблемный план снят с учёта", () => { + expect(unresolvedProblemSpacecraftIds([dismissed(uiPlan("100", "REJECTED", t0))])).toEqual(new Set()); + }); + + it("НЕ красит, если цепочка продвинулась за отказ (исправленный план в полёте)", () => { + const ids = unresolvedProblemSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "WAITING_DECISION", t0 + hour) + ]); + expect(ids.has("100")).toBe(false); + }); + + it("красит, если продвинувший план раньше отказа (отказ всё ещё актуален)", () => { + const ids = unresolvedProblemSpacecraftIds([ + uiPlan("100", "REJECTED", t0 + 5 * hour), + uiPlan("100", "AWAITING_LAYIN", t0) + ]); + expect(ids).toEqual(new Set(["100"])); + }); + + it("успешные/живые статусы сами по себе не красят", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "LAID_IN", t0)])).toEqual(new Set()); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts index f5d5b1c..52bf02d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts @@ -1,15 +1,34 @@ import type { TguPlan, TguPlatform } from "../../model/tguTypes"; import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes"; +import { parseLocal } from "../../utils/localTime"; -const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]); +const PROBLEM_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS"]); + +const OPERATIONAL_STATUS = "OPERATIONAL"; + +/** На морде показываем только эксплуатируемые КА — с остальными мы не работаем. */ +export function isOperationalPlatform(platform: TguPlatform): boolean { + return (platform.status ?? "").trim().toUpperCase() === OPERATIONAL_STATUS; +} + +/** norad-id эксплуатируемых КА; planId.spacecraftId сопоставляется именно с norad-id. */ +export function operationalSpacecraftIds(platforms: TguPlatform[]): Set { + const ids = new Set(); + for (const platform of platforms) { + if (platform.noradId !== null && platform.noradId !== undefined) { + ids.add(String(platform.noradId)); + } + } + return ids; +} export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] { const platformByNorad = buildPlatformByNorad(platforms); return plans .map((plan) => { - const startMs = new Date(plan.startTime).getTime(); - const endMs = new Date(plan.endTime).getTime(); + const startMs = parseLocal(plan.startTime); + const endMs = parseLocal(plan.endTime); const platform = platformByNorad.get(plan.spacecraftId); return { ...plan, @@ -28,28 +47,28 @@ export function buildPlatformsForInterval( ): TguPlatformUi[] { const intervalPlans = filterPlansByRange(plans, range); const platformByNorad = buildPlatformByNorad(platforms); - const planStats = new Map(); + // Красный индикатор КА = есть НЕразрешённый проблемный план (см. unresolvedProblemSpacecraftIds): + // отказ, который оператор ещё не обработал (цепочка не продвинулась) и не снял с учёта. Прежняя + // логика «есть хоть один REJECTED» красила КА вечно — даже после исправления или снятия с учёта. + const problemSpacecraft = unresolvedProblemSpacecraftIds(intervalPlans); + const planCount = new Map(); for (const plan of intervalPlans) { - const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false }; - stats.count += 1; - stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status); - planStats.set(plan.spacecraftId, stats); + planCount.set(plan.spacecraftId, (planCount.get(plan.spacecraftId) ?? 0) + 1); } - const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => { - const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false }; - return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus); - }); + const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => + toPlatformUi(spacecraftId, platform, planCount.get(spacecraftId) ?? 0, problemSpacecraft.has(spacecraftId)) + ); const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId)); - for (const [spacecraftId, stats] of planStats.entries()) { + for (const [spacecraftId, count] of planCount.entries()) { if (!knownSpacecraft.has(spacecraftId)) { platformItems.push({ spacecraftId, noradId: spacecraftId, - planCount: stats.count, - hasProblemStatus: stats.hasProblemStatus + planCount: count, + hasProblemStatus: problemSpacecraft.has(spacecraftId) }); } } @@ -57,6 +76,73 @@ export function buildPlatformsForInterval( return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru")); } +/** + * Статусы, означающие, что цепочка после отказа уже сдвинулась с места: оператор создал и отправил + * исправленный план (он в полёте) либо план уже принят/заложен. Проекция PLANNED и черновики DRAFT + * сюда НЕ входят — это ещё не действие оператора, пауза не снимается. + */ +const CHAIN_ADVANCED_STATUSES = new Set([ + "ISSUING", + "WAITING_DECISION", + "AWAITING_LAYIN", + "LAID_IN", + "ACCEPTED" +]); + +/** + * КА, у которых цепочка стоит на паузе после REJECTED: есть отклонённый план и НЕТ ни одного + * «продвинувшего цепочку» плана (см. [CHAIN_ADVANCED_STATUSES]) со стартом не раньше последнего + * отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) — + * оператор должен вручную создать исправленный план. + */ +/** + * КА с НЕразрешённой проблемой — для красного индикатора. Проблемный план (REJECTED/START_AMBIGUOUS) + * считается разрешённым и НЕ красит КА, если он либо снят с учёта (`dismissedAt`), либо цепочка уже + * продвинулась за него (есть [CHAIN_ADVANCED_STATUSES]-план со стартом не раньше). Так исправленные + * и снятые с учёта отказы гаснут сами, а реальные нерешённые — остаются красными. + */ +export function unresolvedProblemSpacecraftIds(plans: TguPlanUi[]): Set { + const latestAdvancedStart = new Map(); + for (const plan of plans) { + if (CHAIN_ADVANCED_STATUSES.has(plan.status)) { + const prev = latestAdvancedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY; + if (plan.startMs > prev) latestAdvancedStart.set(plan.spacecraftId, plan.startMs); + } + } + + const unresolved = new Set(); + for (const plan of plans) { + if (!PROBLEM_STATUSES.has(plan.status)) continue; + if (plan.dismissedAt) continue; // снят с учёта оператором + const advancedStart = latestAdvancedStart.get(plan.spacecraftId); + if (advancedStart !== undefined && advancedStart >= plan.startMs) continue; // цепочка ушла дальше + unresolved.add(plan.spacecraftId); + } + return unresolved; +} + +export function pausedSpacecraftIds(plans: TguPlanUi[]): Set { + const latestRejectedStart = new Map(); + for (const plan of plans) { + if (plan.status === "REJECTED") { + const prev = latestRejectedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY; + if (plan.startMs > prev) latestRejectedStart.set(plan.spacecraftId, plan.startMs); + } + } + + const paused = new Set(); + for (const [spacecraftId, rejectedStart] of latestRejectedStart) { + const advanced = plans.some( + (plan) => + plan.spacecraftId === spacecraftId && + CHAIN_ADVANCED_STATUSES.has(plan.status) && + plan.startMs >= rejectedStart + ); + if (!advanced) paused.add(spacecraftId); + } + return paused; +} + export function buildTimelineRows( plans: TguPlanUi[], platforms: TguPlatformUi[], diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx new file mode 100644 index 0000000..c6d7097 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx @@ -0,0 +1,357 @@ +import { useState } from "react"; +import type { + OpticsParams, + OpticsResultType, + Polarisation, + RsaParams, + RsaResultType +} from "../../model/requestTypes"; +import { parseLocal } from "../../utils/localTime"; +import { DateTime24Field } from "../../components/timeFields"; + +export type RequestFormSubmit = { + name: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +type Props = { + vertexCount: number; + drawing: boolean; + defaultBegin: string; + defaultEnd: string; + submitting: boolean; + error?: string; + onUndoPoint: () => void; + onClear: () => void; + onFinishDrawing: () => void; + onRestartDrawing: () => void; + onSubmit: (form: RequestFormSubmit) => void; + onCancel: () => void; +}; + +const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [ + { value: "PANCHROMATIC", label: "Панхроматическая" }, + { value: "MULTISPECTRAL", label: "Мультиспектральная" }, + { value: "PANSHARPENING", label: "Паншарпенинг" } +]; + +const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [ + { value: "SPOTLIGHT", label: "Spotlight" }, + { value: "STRIPMAP", label: "Stripmap" }, + { value: "SCANSAR", label: "ScanSAR" } +]; + +const POLARISATIONS: { value: Polarisation; label: string }[] = [ + { value: "HH", label: "HH" }, + { value: "VV", label: "VV" } +]; + +export function RequestCreatePanel({ + vertexCount, + drawing, + defaultBegin, + defaultEnd, + submitting, + error, + onUndoPoint, + onClear, + onFinishDrawing, + onRestartDrawing, + onSubmit, + onCancel +}: Props) { + const [name, setName] = useState(""); + const [importance, setImportance] = useState("1"); + const [begin, setBegin] = useState(defaultBegin); + const [end, setEnd] = useState(defaultEnd); + const [kpp, setKpp] = useState(""); + const [highPriority, setHighPriority] = useState(false); + + const [opticsEnabled, setOpticsEnabled] = useState(true); + const [opticsResultType, setOpticsResultType] = useState("PANCHROMATIC"); + const [opticsResolution, setOpticsResolution] = useState("1"); + const [sunAngleMin, setSunAngleMin] = useState("10"); + const [sunAngleMax, setSunAngleMax] = useState("90"); + const [clouds, setClouds] = useState("100"); + + const [rsaEnabled, setRsaEnabled] = useState(false); + const [rsaResultType, setRsaResultType] = useState("STRIPMAP"); + const [interferometry, setInterferometry] = useState(false); + const [polarisation, setPolarisation] = useState("HH"); + const [rsaResolution, setRsaResolution] = useState("1"); + + const importanceNum = Number(importance); + const beginMs = parseLocal(begin); + const endMs = parseLocal(end); + + const kppList = parseKpp(kpp); + const kppValid = kppList !== null; + + const geometryReady = vertexCount >= 3 && !drawing; + + const validationError = buildValidationError({ + geometryReady, + drawing, + vertexCount, + name, + importance, + importanceNum, + beginMs, + endMs, + kppValid, + opticsEnabled, + opticsResolution, + rsaEnabled, + rsaResolution + }); + + const handleSubmit = () => { + if (validationError || !kppList) return; + const optics: OpticsParams | undefined = opticsEnabled + ? { + resultType: opticsResultType, + resolution: Number(opticsResolution), + sunAngleMin: Number(sunAngleMin), + sunAngleMax: Number(sunAngleMax), + clouds: Number(clouds) + } + : undefined; + const rsa: RsaParams | undefined = rsaEnabled + ? { + resultType: rsaResultType, + interferometry, + polarisation, + resolution: Number(rsaResolution) + } + : undefined; + + onSubmit({ + name: name.trim(), + importance: importanceNum, + beginDateTime: begin, + endDateTime: end, + kpp: kppList, + highPriorityTransmit: highPriority, + optics, + rsa + }); + }; + + return ( + + ); +} + +function parseKpp(raw: string): number[] | null { + const trimmed = raw.trim(); + if (trimmed === "") return []; + const parts = trimmed.split(/[,\s]+/).filter(Boolean); + const result: number[] = []; + for (const part of parts) { + const n = Number(part); + if (!Number.isInteger(n) || n < 0) return null; + result.push(n); + } + return result; +} + +function buildValidationError(input: { + geometryReady: boolean; + drawing: boolean; + vertexCount: number; + name: string; + importance: string; + importanceNum: number; + beginMs: number; + endMs: number; + kppValid: boolean; + opticsEnabled: boolean; + opticsResolution: string; + rsaEnabled: boolean; + rsaResolution: string; +}): string | null { + if (input.drawing) return "Завершите рисование полигона (≥3 вершин)."; + if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин."; + if (input.name.trim() === "") return "Укажите название заявки."; + if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) { + return "Важность должна быть числом ≥ 0."; + } + if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) { + return "Начало должно быть раньше конца."; + } + if (!input.kppValid) return "КПП — список целых чисел через запятую."; + if (!input.opticsEnabled && !input.rsaEnabled) { + return "Включите хотя бы один блок съёмки: Оптика или РСА."; + } + if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) { + return "Разрешение оптики должно быть ≥ 0.1 м."; + } + if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) { + return "Разрешение РСА должно быть ≥ 0.1 м."; + } + return null; +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx new file mode 100644 index 0000000..a19d202 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from "react"; +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; + +type Props = { + requests: RequestAtPoint[]; + selectedPolygonId?: string; + deleting: boolean; + deleteError?: string; + onHover: (polygonId: string | undefined) => void; + onSelect: (polygonId: string) => void; + onDelete: (requestId: string) => void; + onClose: () => void; +}; + +export function RequestDetailsPanel({ + requests, + selectedPolygonId, + deleting, + deleteError, + onHover, + onSelect, + onDelete, + onClose +}: Props) { + const selectedEntry = selectedPolygonId + ? requests.find((r) => r.polygon.id === selectedPolygonId) + : requests.length === 1 + ? requests[0] + : undefined; + + // Второй шаг подтверждения удаления. Сбрасываем при смене выбранной заявки, + // чтобы открытый запрос на удаление не «переезжал» на другую заявку. + const [confirmingDelete, setConfirmingDelete] = useState(false); + useEffect(() => { + setConfirmingDelete(false); + }, [selectedEntry?.item.id]); + + return ( + + ); +} + +function formatDateTime(value: string): string { + return formatLocalDateTime(parseLocal(value)); +} + +function surveyTypeLabel(surveyType: string): string { + switch (surveyType) { + case "OPTICS": + return "Оптическая"; + case "RSA": + return "Радиолокационная (РСА)"; + case "COMBINED": + return "Комбинированная"; + default: + return surveyType; + } +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx new file mode 100644 index 0000000..4e7b3ab --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/TguRequestsTab.tsx @@ -0,0 +1,411 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createRequest, deleteRequest, fetchRequestsForMap } from "../../api/requestApi"; +import { fetchStations, type StationDto } from "../../api/stationsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; +import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; +import { generateUuid } from "../../utils/uuid"; +import { parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; +import { DateTime24Field } from "../../components/timeFields"; +import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel"; +import { RequestDetailsPanel } from "./RequestDetailsPanel"; +import { polygonToWkt } from "./requestGeometry"; + +type TguRequestsTabProps = { + range: TimelineRange; + invalidRange: boolean; + platforms: TguPlatformUi[]; +}; + +type RequestsLoadState = { + fromValue: string; + toValue: string; + items: RequestMapItem[]; + loading: boolean; + loadedCount: number; + totalItems: number; + truncated: boolean; + error?: string; + loaded: boolean; +}; + +type PanelMode = "none" | "details" | "create"; + +function buildInitialLoadState(range: TimelineRange): RequestsLoadState { + return { + fromValue: toLocalInputValue(range.fromMs), + toValue: toLocalInputValue(range.toMs), + items: [], + loading: false, + loadedCount: 0, + totalItems: 0, + truncated: false, + loaded: false + }; +} + +export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTabProps) { + const [requestsState, setRequestsState] = useState(() => buildInitialLoadState(range)); + const [stations, setStations] = useState([]); + const [showStations, setShowStations] = useState(true); + const [redrawToken, setRedrawToken] = useState(0); + + const [panelMode, setPanelMode] = useState("none"); + const [requestsAtPoint, setRequestsAtPoint] = useState([]); + const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); + const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); + + const [draftPolygon, setDraftPolygon] = useState([]); + const [drawing, setDrawing] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(); + + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(); + const [notice, setNotice] = useState(); + + useEffect(() => { + fetchStations() + .then((items) => setStations(items)) + .catch(() => setStations([])); + }, []); + + const loadRequests = useCallback(async (fromValue: string, toValue: string) => { + const fromMs = parseLocal(fromValue); + const toMs = parseLocal(toValue); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + + setRequestsState((current) => ({ ...current, loading: true, error: undefined, loadedCount: 0 })); + try { + const result = await fetchRequestsForMap( + { endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) }, + (loaded, total) => setRequestsState((current) => ({ ...current, loadedCount: loaded, totalItems: total })) + ); + setRequestsState((current) => ({ + ...current, + items: result.items, + loadedCount: result.items.length, + totalItems: result.totalItems, + truncated: result.truncated, + loading: false, + loaded: true + })); + } catch (error) { + setRequestsState((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : "Не удалось загрузить заявки." + })); + } + }, []); + + useEffect(() => { + setRequestsState(buildInitialLoadState(range)); + }, [range]); + + const applyRange = useCallback(() => { + void loadRequests(requestsState.fromValue, requestsState.toValue); + }, [loadRequests, requestsState.fromValue, requestsState.toValue]); + + const scene = useMemo( + () => + buildTgu2DMapScene({ + range, + platforms, + requestItems: requestsState.items, + stationItems: showStations ? stations : [] + }), + [range, platforms, requestsState.items, showStations, stations] + ); + + const layers = useMemo( + () => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }), + [showStations] + ); + + const closeAll = useCallback(() => { + setRequestsAtPoint([]); + setSelectedRequestPolygonId(undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + }, []); + + const handleRequestsAtPoint = useCallback( + (polygons: MapPolygon[]) => { + if (panelMode === "create") return; + if (polygons.length === 0) { + closeAll(); + setPanelMode("none"); + return; + } + const joined = polygons.flatMap((polygon) => { + const item = requestsState.items.find((r) => r.id === polygon.requestId); + return item ? [{ polygon, item }] : []; + }); + setRequestsAtPoint(joined); + setSelectedRequestPolygonId(joined.length === 1 ? joined[0].polygon.id : undefined); + setHoveredRequestPolygonId(undefined); + setDeleteError(undefined); + setPanelMode(joined.length > 0 ? "details" : "none"); + }, + [panelMode, requestsState.items, closeAll] + ); + + // Детали заявок открываются через onRequestsAtPoint. onObjectSelect карта + // вызывает с undefined одновременно с onRequestsAtPoint, поэтому здесь no-op, + // чтобы не затирать только что открытую панель (станции/пустой клик уже + // закрывают панель через onRequestsAtPoint([])). + const handleObjectSelect = useCallback((_selection?: Tgu2DMapSelection) => {}, []); + + const startCreate = useCallback(() => { + closeAll(); + setDraftPolygon([]); + setDrawing(true); + setCreateError(undefined); + setNotice(undefined); + setPanelMode("create"); + }, [closeAll]); + + const cancelCreate = useCallback(() => { + setDraftPolygon([]); + setDrawing(false); + setCreateError(undefined); + setPanelMode("none"); + }, []); + + const handleDrawAddPoint = useCallback( + (point: GeoPoint) => { + if (panelMode !== "create" || !drawing) return; + setDraftPolygon((current) => [...current, point]); + }, + [panelMode, drawing] + ); + + const submitCreate = useCallback( + async (form: RequestFormSubmit) => { + let geometry: string; + try { + geometry = polygonToWkt(draftPolygon); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Некорректная геометрия."); + return; + } + + setCreating(true); + setCreateError(undefined); + try { + const id = generateUuid(); + await createRequest({ + id, + name: form.name, + geometry, + importance: form.importance, + beginDateTime: toLocalIso(parseLocal(form.beginDateTime)), + endDateTime: toLocalIso(parseLocal(form.endDateTime)), + kpp: form.kpp, + highPriorityTransmit: form.highPriorityTransmit, + optics: form.optics, + rsa: form.rsa + }); + setDraftPolygon([]); + setDrawing(false); + setPanelMode("none"); + setNotice(`Заявка «${form.name}» создана.`); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setCreateError(error instanceof Error ? error.message : "Не удалось создать заявку."); + } finally { + setCreating(false); + } + }, + [draftPolygon, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const handleDelete = useCallback( + async (requestId: string) => { + setDeleting(true); + setDeleteError(undefined); + try { + await deleteRequest(requestId); + closeAll(); + setPanelMode("none"); + setNotice("Заявка удалена."); + void loadRequests(requestsState.fromValue, requestsState.toValue); + } catch (error) { + setDeleteError(error instanceof Error ? error.message : "Не удалось удалить заявку."); + } finally { + setDeleting(false); + } + }, + [closeAll, loadRequests, requestsState.fromValue, requestsState.toValue] + ); + + const fromMs = parseLocal(requestsState.fromValue); + const toMs = parseLocal(requestsState.toValue); + const rangeInvalid = !Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs; + + const hasPanel = panelMode !== "none"; + + return ( +
+
+
+
Заявки
+
+ {requestsState.loaded ? `Загружено: ${requestsState.items.length}` : "Заявки не загружены"} +
+
+
+ + +
+ + +
+ +
+ + +
+ {invalidRange &&
Некорректный интервал: from должен быть меньше to.
} + {panelMode === "create" && drawing && ( +
+ Кликайте по карте, чтобы добавить вершины полигона. Нажмите «Завершить» (≥3 вершин). +
+ )} + +
+ + {panelMode === "details" && ( + { + closeAll(); + setPanelMode("none"); + }} + /> + )} + + {panelMode === "create" && ( + setDraftPolygon((cur) => cur.slice(0, -1))} + onClear={() => setDraftPolygon([])} + onFinishDrawing={() => setDrawing(false)} + onRestartDrawing={() => { + setDraftPolygon([]); + setDrawing(true); + }} + onSubmit={submitCreate} + onCancel={cancelCreate} + /> + )} +
+
+ ); +} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts new file mode 100644 index 0000000..4469fab --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { polygonToWkt } from "./requestGeometry"; + +describe("polygonToWkt", () => { + it("формирует WKT POLYGON с замыканием кольца", () => { + const wkt = polygonToWkt([ + { lon: 10, lat: 20 }, + { lon: 30, lat: 20 }, + { lon: 30, lat: 40 } + ]); + expect(wkt).toBe("POLYGON((10 20, 30 20, 30 40, 10 20))"); + }); + + it("не дублирует замыкающую точку, если кольцо уже замкнуто", () => { + const wkt = polygonToWkt([ + { lon: 0, lat: 0 }, + { lon: 5, lat: 0 }, + { lon: 5, lat: 5 }, + { lon: 0, lat: 0 } + ]); + expect(wkt).toBe("POLYGON((0 0, 5 0, 5 5, 0 0))"); + }); + + it("бросает ошибку при менее чем 3 вершинах", () => { + expect(() => polygonToWkt([{ lon: 0, lat: 0 }, { lon: 1, lat: 1 }])).toThrow(); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts new file mode 100644 index 0000000..f85be6a --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/features/tgu-requests/requestGeometry.ts @@ -0,0 +1,27 @@ +import type { GeoPoint } from "../tgu-map-2d/model/mapTypes"; + +/** + * Преобразует набор вершин полигона в WKT POLYGON. + * Порядок координат WKT — "lon lat". Кольцо замыкается первой вершиной, + * если оно ещё не замкнуто. Требуется минимум 3 вершины. + */ +export function polygonToWkt(points: GeoPoint[]): string { + if (points.length < 3) { + throw new Error("Полигон должен содержать минимум 3 вершины."); + } + + const ring = [...points]; + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first.lon !== last.lon || first.lat !== last.lat) { + ring.push(first); + } + + const coords = ring.map((p) => `${formatCoord(p.lon)} ${formatCoord(p.lat)}`).join(", "); + return `POLYGON((${coords}))`; +} + +function formatCoord(value: number): string { + // ограничиваем разрядность, убираем хвостовые нули + return Number(value.toFixed(6)).toString(); +} diff --git a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts index 6c46c12..fb916c4 100644 --- a/services/pcp-tgu-ops-ui/src/model/requestTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/requestTypes.ts @@ -63,3 +63,44 @@ export type RequestMapItem = { createdAt: string; updatedAt: string; }; + +export type OpticsResultType = "PANCHROMATIC" | "MULTISPECTRAL" | "PANSHARPENING"; +export type RsaResultType = "SPOTLIGHT" | "STRIPMAP" | "SCANSAR"; +export type Polarisation = "HH" | "VV"; + +export type OpticsParams = { + resultType: OpticsResultType; + resolution: number; + sunAngleMin: number; + sunAngleMax: number; + clouds: number; +}; + +export type RsaParams = { + resultType: RsaResultType; + interferometry: boolean; + polarisation: Polarisation; + resolution: number; +}; + +export type CreateRequestPayload = { + id: string; + name: string; + geometry: string; + importance: number; + beginDateTime: string; + endDateTime: string; + kpp: number[]; + highPriorityTransmit: boolean; + optics?: OpticsParams; + rsa?: RsaParams; +}; + +export type CreateRequestResult = { + id: string; + name: string; + status: RequestStatus; + surveyType: SurveyType; + importance: number; + message?: string; +}; diff --git a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts index e62484d..48a5363 100644 --- a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts @@ -4,8 +4,6 @@ export type TguPlanStatus = | "WAITING_DECISION" | "PLANNED" | "ISSUING" - | "EXPIRED" - | "SUPERSEDED" | "START_AMBIGUOUS" | string; @@ -18,6 +16,18 @@ export type TguPlan = { endTime: string; kppId: string; status: TguPlanStatus; + /** + * Предшественник плана в цепочке. `null`/отсутствует — у плана нет предыстории (старт новой + * цепочки «без истории» или самый первый план): на таймлайне в него не ведут стрелку. + */ + basePlanId?: string | null; + /** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */ + projected?: boolean; + /** + * Время «снятия с учёта» (ISO без зоны) терминально-негативного плана. `null`/отсутствует — план + * на учёте. Снятый план гасится на таймлайне и не считается проблемой в индикаторе КА. + */ + dismissedAt?: string | null; }; export type TguPlatform = { diff --git a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts index cfc1137..d0c0cbe 100644 --- a/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/timelineTypes.ts @@ -22,7 +22,7 @@ export type TimelineRange = { toMs: number; }; -export type TguActiveTab = "timeline" | "map" | "editor"; +export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation"; export type TimelineRow = { spacecraftId: string; @@ -33,16 +33,25 @@ export type TimelineRow = { export type TimelinePlanSegment = { plan: TguPlanUi; rowIndex: number; - laneOffset: number; + /** Индекс дорожки внутри строки КА (0 — верхняя); пересекающиеся планы получают разные дорожки. */ + lane: number; + /** Абсолютная вертикальная координата центра линии плана. */ + centerY: number; x: number; x2: number; width: number; }; -export type TimelineLink = { - spacecraftId: string; - fromPlanId: string; - toPlanId: string; +export type TimelineRowLayout = { + row: TimelineRow; + rowIndex: number; + /** Верх строки (абсолютный y). */ + y: number; + height: number; + /** Сколько дорожек занято планами (>=1). */ + laneCount: number; + /** Абсолютный y верхней границы страйпа ЗРВ. */ + zrvBandY: number; }; export type TimelineStatusStyle = { @@ -50,4 +59,6 @@ export type TimelineStatusStyle = { label: string; color: string; className: string; + /** Человеческое пояснение статуса — тултип в легенде. */ + description: string; }; diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index ab4094a..7f86e9f 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -47,6 +47,15 @@ font-size: 0.68rem; } +.tgu-editor-panel__list { + margin: 0; + padding-left: 1.1rem; + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.72rem; +} + .tgu-editor-toolbar__group { display: flex; gap: 0.35rem; @@ -57,6 +66,36 @@ min-width: 1rem; } +.tgu-editor-target-zrv { + display: inline-flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; +} + +.tgu-editor-target-zrv__label { + color: var(--text-dim); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tgu-select { + height: 1.95rem; + max-width: 14rem; + border: 1px solid var(--line); + border-radius: 7px; + padding: 0 0.5rem; + background: #101214; + color: var(--text); + font-size: 0.78rem; +} + +.tgu-select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .tgu-editor-type-dot { width: 0.62rem; height: 0.62rem; @@ -147,6 +186,25 @@ font-weight: 700; } +.tgu-editor-rail__plan-dates { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2rem 0.6rem; + margin: 0; +} + +.tgu-editor-rail__plan-dates dt { + color: var(--text-dim); + font-size: 0.66rem; +} + +.tgu-editor-rail__plan-dates dd { + margin: 0; + color: var(--text); + font-size: 0.7rem; + text-align: right; +} + .tgu-editor-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -250,9 +308,23 @@ user-select: none; } +.map-pass-selector--empty { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0.65rem; + width: auto; + max-width: 28rem; +} + +.map-pass-selector__empty-note { + color: var(--text-muted, #98a2ac); + font-size: 0.74rem; +} + .map-pass-selector__header { display: flex; - align-items: center; + align-items: baseline; gap: 0.45rem; padding: 0.4rem 0.65rem; } @@ -553,26 +625,34 @@ border-left: 1px solid var(--grid); } -.tgu-editor-timeline__now { +.tgu-editor-timeline__bound { position: absolute; bottom: 0; z-index: 8; - border-left: 1.5px dashed var(--now); + border-left: 1.5px solid var(--accent); pointer-events: none; } -.tgu-editor-timeline__now span { +.tgu-editor-timeline__bound span { position: absolute; - left: 0.18rem; top: 0; border-radius: 3px; padding: 0 0.24rem; - background: var(--now); + background: var(--accent); color: var(--bg-base); font-family: var(--mono); font-size: 0.55rem; font-weight: 700; text-transform: uppercase; + white-space: nowrap; +} + +.tgu-editor-timeline__bound--start span { + left: 0.18rem; +} + +.tgu-editor-timeline__bound--end span { + right: 0.18rem; } .tgu-editor-timeline__lane { @@ -622,11 +702,15 @@ gap: 0.28rem; height: calc(100% - 1.5rem); overflow: hidden; + appearance: none; border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 5px; padding: 0.25rem 0.45rem; - cursor: grab; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; user-select: none; } @@ -649,20 +733,43 @@ pointer-events: none; } -.tgu-editor-work__resize { +/* Активная дорожка разбита на два ряда: верхний — съёмка, нижний — сброс. + Бар центрируется по своему ряду и растёт вместе с ним, но не выше потолка — + чтобы узкие включения не растягивались в непропорционально высокие полоски. */ +.tgu-editor-work--shoot, +.tgu-editor-work--downlink { + height: min(56px, calc(50% - 0.55rem)); + transform: translateY(-50%); +} + +.tgu-editor-work--shoot { + top: 25%; +} + +.tgu-editor-work--downlink { + top: 75%; +} + +.tgu-editor-timeline__row-hint { position: absolute; - top: 0; - bottom: 0; - width: 0.45rem; - cursor: ew-resize; + right: 0.65rem; + font-style: normal; + font-size: 0.56rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; } -.tgu-editor-work__resize--left { - left: 0; +.tgu-editor-timeline__row-hint--shoot { + top: 25%; + transform: translateY(-50%); + color: var(--t-optical); } -.tgu-editor-work__resize--right { - right: 0; +.tgu-editor-timeline__row-hint--downlink { + top: 75%; + transform: translateY(-50%); + color: var(--accent); } .tgu-editor-work__dot { @@ -756,10 +863,16 @@ .tgu-editor-panel__actions { display: flex; align-items: center; - justify-content: space-between; + flex-wrap: wrap; gap: 0.6rem; } +.tgu-editor-panel__actions > .tgu-button { + flex: 1 1 0; + min-width: 0; + white-space: nowrap; +} + .tgu-editor-panel__head h2 { margin: 0; font-size: 0.95rem; @@ -856,6 +969,18 @@ text-align: left; } +/* Узкая правая панель: строка прохода не помещается в один ряд — раскладываем + подпись и параметры видимости в столбец, чтобы текст не обрезался. */ +.tgu-editor-pass-list button { + flex-direction: column; + align-items: stretch; + gap: 0.2rem; +} + +.tgu-editor-pass-list small { + white-space: normal; +} + .tgu-editor-pass-list span, .tgu-editor-station span { color: var(--text); @@ -921,7 +1046,8 @@ font-size: 0.62rem; } -.tgu-editor-time-stepper output { +.tgu-editor-time-stepper output, +.tgu-editor-time-stepper input { flex: 1; min-width: 0; border: 1px solid var(--line); @@ -934,6 +1060,31 @@ text-align: center; } +.tgu-editor-time-stepper input::-webkit-calendar-picker-indicator { + filter: invert(1); + opacity: 0.5; + cursor: pointer; +} + +/* Секундный степпер окна маршрута: поле ввода на своей строке, кнопки ± — ниже, + чтобы помещаться в узкую правую панель. */ +.tgu-editor-time-stepper--seconds { + gap: 0.3rem; +} + +.tgu-editor-time-stepper--seconds > input { + width: 100%; +} + +.tgu-editor-time-stepper__steps { + display: flex; + gap: 0.25rem; +} + +.tgu-editor-time-stepper__steps button { + flex: 1; +} + .tgu-editor-panel__color { position: absolute; top: 0.2rem; @@ -963,3 +1114,110 @@ max-height: 24rem; } } + +/* Список объектов под точкой клика на карте (раскрывающиеся карточки). */ +.tgu-editor-pick { + display: grid; + gap: 0.35rem; +} + +.tgu-editor-pick__row { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + column-gap: 0.5rem; + align-items: center; + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.55rem; + background: var(--bg-panel-2); + color: var(--text); + text-align: left; + cursor: pointer; +} + +.tgu-editor-pick__row:hover { + border-color: rgba(104, 195, 189, 0.45); +} + +.tgu-editor-pick__item.is-open .tgu-editor-pick__row { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.14); +} + +.tgu-editor-pick__swatch { + grid-row: 1 / span 2; + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--text-dim); +} + +.tgu-editor-pick__swatch--requests { background: #ffa040; } +.tgu-editor-pick__swatch--planWorks { background: #4fd2b0; } +.tgu-editor-pick__swatch--drop { background: #ffcf3f; } +.tgu-editor-pick__swatch--station { background: #d5e2e2; } +.tgu-editor-pick__swatch--swath { background: #6fb7ff; } + +.tgu-editor-pick__title { + font-size: 0.74rem; + font-weight: 600; +} + +.tgu-editor-pick__sub { + grid-column: 2; + color: var(--text-dim); + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tgu-editor-pick__chevron { + grid-row: 1 / span 2; + color: var(--text-dim); + font-size: 0.7rem; +} + +/* Вложенная карточка детали внутри раскрытого элемента — без двойной рамки/отступов. */ +.tgu-editor-pick__detail { + padding: 0.35rem 0 0.1rem; +} + +.tgu-editor-pick__detail .tgu-editor-panel { + border: 0; + padding: 0; +} + +/* Диалог выбора режима очистки плана (всё / оставить ручные съёмки). */ +.tgu-editor-clear-dialog { + width: min(26rem, calc(100vw - 2rem)); +} + +.tgu-editor-clear-dialog__text { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted, #9fb0b3); +} + +.tgu-editor-clear-dialog__choices { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.tgu-editor-clear-dialog__choices > .tgu-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + width: 100%; + padding: 0.6rem 0.75rem; + text-align: left; +} + +.tgu-editor-clear-dialog__choices > .tgu-button > small { + font-weight: 400; + font-size: 0.72rem; + opacity: 0.85; +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 0e1999f..cd56a02 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -16,7 +16,7 @@ .tgu-toolbar__row { display: flex; - align-items: end; + align-items: center; gap: 0.75rem; min-height: 4.4rem; padding: 0.75rem 1rem 0.65rem; @@ -53,8 +53,9 @@ } .tgu-field { - display: grid; - gap: 0.25rem; + display: flex; + align-items: center; + gap: 0.45rem; color: var(--text-muted); font-size: 0.72rem; } @@ -101,6 +102,12 @@ color: #eafffd; } +.tgu-button.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + .tgu-button--accept { border-color: rgba(47, 158, 68, 0.65); background: rgba(47, 158, 68, 0.16); @@ -114,7 +121,6 @@ .tgu-tabs { display: inline-flex; gap: 0.15rem; - align-self: center; border: 1px solid var(--line); border-radius: 7px; padding: 0.18rem; @@ -150,6 +156,7 @@ align-items: center; gap: 0.35rem; white-space: nowrap; + cursor: help; } .tgu-legend__swatch { @@ -159,18 +166,30 @@ background: #79828d; } -.tgu-legend__note { - color: var(--accent-strong); +.status-awaiting-layin { + background-color: #2f9e44; } -.status-accepted { - background-color: #2f9e44; +.status-laid-in { + background-color: #1b6b34; } .status-rejected { background-color: #d64545; } +.status-not-accepted { + background-color: #e8590c; +} + +.status-layin-failed { + background-color: #a61e4d; +} + +.status-superseded { + background-color: #6b7280; +} + .status-waiting { background-color: #f0ad2e; } @@ -183,14 +202,6 @@ background-color: #2f80ed; } -.status-expired { - background-color: #495057; -} - -.status-superseded { - background-color: #c7ced6; -} - .status-ambiguous { background-color: #b42318; outline: 1px dashed #ffd1d1; @@ -200,6 +211,11 @@ background-color: #79828d; } +.status-draft { + background-color: #a98bdb; + outline: 1px dashed #e7dbff; +} + .tgu-workspace { display: grid; grid-template-columns: 18.5rem minmax(0, 1fr) 22rem; @@ -219,6 +235,17 @@ border-right: 1px solid var(--line); } +.tgu-sidebar__legend { + border-top: 1px solid var(--line); + margin-top: auto; +} + +.tgu-sidebar__legend .tgu-legend { + flex-direction: column; + align-items: flex-start; + padding: 0.75rem 1rem; +} + .tgu-panel-heading { display: flex; justify-content: space-between; @@ -344,7 +371,6 @@ .tgu-timeline__svg { display: block; width: 100%; - min-height: 26rem; border: 1px solid var(--line); border-radius: 8px; background: #151819; @@ -380,6 +406,13 @@ font-size: 11px; } +.tgu-timeline__paused-badge { + fill: #f0a73d; + font-size: 11px; + font-weight: 600; + cursor: help; +} + .tgu-timeline__now { stroke: #f46e4f; stroke-width: 1.7; @@ -408,15 +441,24 @@ font-weight: 700; } -.tgu-timeline__arrow { - fill: none; - stroke: #8aa2a6; - stroke-width: 1.5; - opacity: 0.8; -} - .tgu-timeline-plan { cursor: pointer; + transition: opacity 0.12s ease; +} + +/* Снят с учёта: приглушаем план и делаем штриховку — виден, но явно «второстепенный». + Объявлен ВЫШЕ is-dimmed, чтобы при активном выборе сильное выбор-затемнение перебивало это. */ +.tgu-timeline-plan.is-dismissed-plan { + opacity: 0.4; +} +.tgu-timeline-plan.is-dismissed-plan .tgu-timeline-plan__bar { + stroke-dasharray: 3 4; +} + +/* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются + яркие рамки текущего/прошлого/следующего звена цепочки. */ +.tgu-timeline-plan.is-dimmed { + opacity: 0.16; } .tgu-timeline-plan__bar { @@ -424,11 +466,32 @@ stroke-width: 13; } +/* Черновик на таймлайне — пунктирная линия, чтобы отличить кандидата от реальных планов. */ +.tgu-timeline-plan__bar.status-draft { + stroke-dasharray: 7 5; +} + +/* Яркая рамка ТЕКУЩЕГО (выбранного) плана — blue-600. */ .tgu-timeline-plan__selection { - stroke: #efffff; + stroke: #2563eb; stroke-linecap: round; stroke-width: 19; - opacity: 0.85; + opacity: 1; +} + +/* Яркая рамка соседнего по цепочке плана: прошлый — slate-400, будущий — amber-500. */ +.tgu-timeline-plan__neighbor { + stroke-linecap: round; + stroke-width: 19; + opacity: 1; +} + +.tgu-timeline-plan__neighbor--prev { + stroke: #94a3b8; +} + +.tgu-timeline-plan__neighbor--next { + stroke: #f59e0b; } .tgu-timeline-plan__label { @@ -508,6 +571,18 @@ font-weight: 800; } +.tgu-details__pills { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +/* Бейдж «снят с учёта» рядом со статусом: нейтральный серый, чтобы не путать со статусом. */ +.tgu-status-pill--dismissed { + background: #475569; + color: #e2e8f0; +} + .tgu-details-grid { display: grid; gap: 0.72rem; @@ -534,6 +609,34 @@ padding-top: 0.35rem; } +/* Действия над черновиком — переносим на несколько строк, чтобы три кнопки помещались. */ +.tgu-details__actions--draft { + flex-wrap: wrap; +} + +/* Тестовый блок — отделён сверху, с предупреждающей подписью «ТЕСТОВЫЙ ФУНКЦИОНАЛ». */ +.tgu-details__test-block { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.9rem; + padding-top: 0.7rem; + border-top: 1px dashed var(--line); +} + +.tgu-details__test-label { + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + color: #d97706; +} + +.tgu-details__test-hint { + font-size: 0.74rem; + line-height: 1.3; + color: var(--muted, #79828d); +} + .tgu-alert { border: 1px solid var(--line); border-radius: 7px; @@ -832,6 +935,26 @@ stroke-width: 2; } +.tgu-2d-map__overlay polyline.is-selected { + stroke: #f0ad2e; + stroke-width: 7; + stroke-linecap: round; + stroke-linejoin: round; + opacity: 0.85; +} + +.tgu-2d-map__overlay circle { + fill: rgba(104, 195, 189, 0.12); + stroke: rgba(130, 222, 216, 0.9); + stroke-width: 2; +} + +.tgu-2d-map__overlay circle.is-selected { + fill: rgba(240, 173, 46, 0.15); + stroke: #f0ad2e; + stroke-width: 2.5; +} + .tgu-2d-map__tooltip { position: absolute; z-index: 8; @@ -872,6 +995,22 @@ pointer-events: none; } +.tgu-2d-map__coords { + position: absolute; + left: 50%; + bottom: 0.5rem; + transform: translateX(-50%); + border-radius: 4px; + padding: 0.2rem 0.55rem; + background: rgba(8, 16, 15, 0.72); + color: var(--text); + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.66rem; + letter-spacing: 0.02em; + white-space: nowrap; + pointer-events: none; +} + .tgu-2d-map__zoom { position: absolute; right: 0.75rem; @@ -998,6 +1137,7 @@ } .tgu-map-requests-filter .tgu-field { + display: grid; grid-template-columns: 2rem minmax(0, 1fr); align-items: center; } @@ -1059,3 +1199,401 @@ background: rgba(192, 128, 255, 0.25); border-color: rgba(192, 128, 255, 0.85); } + +/* ── Requests-at-point panel ───────────────────────────── */ + +.tgu-map-content.has-requests-panel { + grid-template-columns: 19rem minmax(0, 1fr) 20rem; +} + +.tgu-map-requests-panel { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + border-left: 1px solid var(--line); + background: var(--bg-panel); +} + +.tgu-map-requests-list { + display: flex; + flex: 1; + flex-direction: column; + gap: 1px; + overflow-y: auto; + background: var(--line-soft); +} + +.tgu-map-request-row { + display: flex; + gap: 0.65rem; + align-items: flex-start; + width: 100%; + padding: 0.6rem 0.9rem; + border: none; + background: var(--bg-panel); + color: var(--text); + cursor: pointer; + text-align: left; +} + +.tgu-map-request-row:hover { + background: var(--bg-hover); +} + +.tgu-map-request-row.is-selected { + background: rgba(240, 173, 46, 0.1); + outline: 1px solid rgba(240, 173, 46, 0.4); + outline-offset: -1px; +} + +.tgu-map-request-row__type { + flex-shrink: 0; + align-self: center; + width: 1rem; + height: 0.4rem; + border-radius: 3px; + border: 1.5px dashed; +} + +.tgu-map-request-row__body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.tgu-map-request-row__name { + overflow: hidden; + color: var(--text); + font-size: 0.82rem; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tgu-map-request-row__meta { + color: var(--text-dim); + font-size: 0.72rem; + line-height: 1.4; +} + +.tgu-map-request-details { + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; + border-top: 1px solid var(--line); + padding: 0.75rem 0.9rem; +} + +.tgu-map-request-details .tgu-map-panel__label { + margin-bottom: 0.1rem; +} + +.tgu-map-request-details__grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.8rem; + margin: 0; +} + +.tgu-map-request-details__grid dt { + color: var(--text-dim); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.5; +} + +.tgu-map-request-details__grid dd { + overflow-wrap: anywhere; + margin: 0; + color: var(--text); + font-size: 0.8rem; + line-height: 1.5; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-requests-panel { + grid-template-columns: 1fr; + } + + .tgu-map-requests-panel { + max-height: 18rem; + border-left: 0; + border-top: 1px solid var(--line); + } +} + +/* ── Вкладка «Заявки» ───────────────────────────────────── */ + +.tgu-map-content.has-side-panel { + grid-template-columns: 19rem minmax(0, 1fr) 24rem; +} + +.tgu-map-overlay--hint { + border-color: rgba(104, 195, 189, 0.5); + color: #bdeee9; +} + +/* Черновой полигон при рисовании заявки */ +.tgu-2d-map__overlay .tgu-2d-map__draft-fill { + fill: rgba(240, 173, 46, 0.16); + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-line { + fill: none; + stroke: #f0ad2e; + stroke-width: 2; + stroke-dasharray: 6 4; +} + +.tgu-2d-map__overlay .tgu-2d-map__draft-vertex { + fill: #f0ad2e; + stroke: #1a1c1e; + stroke-width: 1.5; +} + +/* Панель-форма создания заявки */ +.tgu-requests-form__body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.2rem; + overflow-y: auto; +} + +.tgu-requests-form__section { + display: grid; + gap: 0.5rem; + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--line-soft); +} + +.tgu-requests-field { + display: grid; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 0.5rem; + align-items: center; + font-size: 0.8rem; +} + +.tgu-requests-field > span { + color: var(--text-dim); + font-size: 0.74rem; +} + +.tgu-requests-field input, +.tgu-requests-field select { + width: 100%; + min-height: 1.9rem; + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.25rem 0.45rem; + background: #101214; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-field input:focus, +.tgu-requests-field select:focus { + outline: none; + border-color: rgba(104, 195, 189, 0.6); +} + +.tgu-requests-checkbox { + display: flex; + gap: 0.5rem; + align-items: center; + color: var(--text); + font-size: 0.8rem; +} + +.tgu-requests-checkbox input { + accent-color: var(--accent); +} + +.tgu-requests-form__block-toggle { + font-weight: 650; +} + +.tgu-requests-form__block { + display: grid; + gap: 0.5rem; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line-soft); + border-radius: 7px; + background: #111416; +} + +.tgu-requests-form__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.tgu-requests-confirm { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 0.6rem; + border: 1px solid rgba(229, 115, 115, 0.45); + border-radius: 6px; + background: rgba(229, 115, 115, 0.08); +} + +.tgu-requests-confirm__title { + font-weight: 600; + color: var(--text); +} + +.tgu-requests-confirm__subtitle { + color: var(--text-dim); + font-size: 0.8rem; + word-break: break-word; +} + +.tgu-requests-form__hint { + color: var(--text-dim); + font-size: 0.75rem; + line-height: 1.4; +} + +.tgu-requests-form__geo-status { + color: var(--text); + font-size: 0.78rem; + font-weight: 650; +} + +.tgu-requests-form__footer { + padding: 0.75rem 0.9rem; + border-top: 1px solid var(--line); +} + +.tgu-requests-form__footer .tgu-button { + width: 100%; +} + +@media (max-width: 1100px) { + .tgu-map-content.has-side-panel { + grid-template-columns: 1fr; + } +} + +/* «Создать план»: кнопка над таймлайном + модальный диалог новой цепочки. */ +.tgu-center__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 1rem; + padding: 0.5rem 0.75rem 0; +} + +.tgu-center__toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + color: #c4ced0; + cursor: pointer; + user-select: none; +} + +.tgu-center__toggle input { + cursor: pointer; +} + +.tgu-chain-dialog__backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); +} + +.tgu-chain-dialog { + width: min(34rem, calc(100vw - 2rem)); + max-height: calc(100vh - 3rem); + overflow: auto; + display: flex; + flex-direction: column; + gap: 0.8rem; + padding: 1rem 1.1rem 1.1rem; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg-panel); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} + +.tgu-chain-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.tgu-chain-dialog__header h2 { + margin: 0; + font-size: 1.05rem; +} + +.tgu-chain-dialog__field { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.82rem; +} + +.tgu-chain-dialog__field > span { + color: var(--text-muted, #9fb0b3); +} + +.tgu-chain-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.3rem; +} + +.tgu-chain-dialog__chain { + margin: 0; + padding-left: 1.1rem; + font-size: 0.8rem; + line-height: 1.5; + max-height: 12rem; + overflow: auto; +} + +.tgu-chain-dialog__hint { + font-size: 0.78rem; + color: var(--text-muted, #9fb0b3); +} + +/* ЗРВ выбранного КА на таймлайне — отдельный страйп снизу строки, не перекрывает планы. */ +.tgu-timeline__zrv { + fill: rgba(90, 170, 200, 0.22); + stroke: rgba(90, 170, 200, 0.6); + stroke-width: 1; + rx: 2; + pointer-events: all; +} + +.tgu-timeline__zrv.is-selected { + fill: rgba(240, 173, 46, 0.35); + stroke: #f0ad2e; + stroke-width: 1.6; +} + +/* Подпись страйпа ЗРВ слева — как имя КА над цепочкой планов. */ +.tgu-timeline__zrv-label { + fill: rgba(90, 170, 200, 0.85); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} diff --git a/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts b/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts new file mode 100644 index 0000000..dbd03be --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/dataVolume.ts @@ -0,0 +1,16 @@ +/** + * Форматирование объёма данных режима (съёмка/сброс). Бэк отдаёт объём в мегабайтах + * (см. ModeDataRates в pcp-types-lib: накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с ÷ 8). + */ + +/** До 1000 — в МБ, дальше — в ГБ (1 ГБ = 1000 МБ). Тут МБ = мегабайт. «—», если значения нет. */ +export function formatVolume(mb?: number | null): string { + if (mb == null || !Number.isFinite(mb) || mb <= 0) { + return "—"; + } + if (mb >= 1000) { + const gb = mb / 1000; + return `${gb >= 100 ? Math.round(gb) : gb.toFixed(1)} ГБ`; + } + return `${Math.round(mb)} МБ`; +} diff --git a/services/pcp-tgu-ops-ui/src/utils/localTime.ts b/services/pcp-tgu-ops-ui/src/utils/localTime.ts new file mode 100644 index 0000000..35e5364 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/localTime.ts @@ -0,0 +1,57 @@ +/** + * Единый разбор и форматирование времени в локальной зоне. + * + * Бэкенд (Kotlin `LocalDateTime`) отдаёт время как ISO-строку без зоны + * (например `"2026-05-29T01:00:00"`) — это настенные часы локальной зоны, в которой + * работают все сервисы, без UTC-семантики. Браузер по умолчанию трактует такую naive-строку + * как локальное время — это нам и нужно: разбор, показ и ввод идут в зоне браузера, а + * сериализация назад отдаёт те же настенные часы naive-строкой без зоны. + * + * Строки с явной зоной (`...Z`/офсет, напр. валидность платформ из НСИ) парсятся как + * абсолютный момент и показываются в локальной зоне. + */ + +function pad(n: number): string { + return String(n).padStart(2, "0"); +} + +/** Разбирает ISO-строку в эпоху-мс: naive — как локальное время, зонированную — как её момент. */ +export function parseLocal(value: string): number { + return Date.parse(value); +} + +/** Дата-время в локальной зоне, формат ru-RU. */ +export function formatLocalDateTime(ms: number): string { + return new Date(ms).toLocaleString("ru-RU", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }); +} + +/** Дата (дд.мм) в локальной зоне. */ +export function formatLocalDate(ms: number): string { + return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); +} + +/** Время (чч:мм) в локальной зоне. */ +export function formatLocalTime(ms: number): string { + return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); +} + +/** Значение для пикеров (`YYYY-MM-DDTHH:mm`) из локальных компонент. */ +export function toLocalInputValue(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** + * naive `LocalDateTime` без зоны (`YYYY-MM-DDTHH:mm:ss`) из локальных компонент эпохи-мс. + * Все бэкенды ожидают `LocalDateTime` без зоны — настенные часы локальной зоны. + */ +export function toLocalIso(ms: number): string { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts new file mode 100644 index 0000000..a4d4f4c --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/uuid.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { generateUuid, uuidV4Fallback } from "./uuid"; + +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +describe("generateUuid", () => { + it("возвращает валидный UUID v4", () => { + expect(generateUuid()).toMatch(UUID_V4); + }); +}); + +describe("uuidV4Fallback (insecure context)", () => { + it("даёт валидный UUID v4 без crypto.randomUUID", () => { + expect(uuidV4Fallback()).toMatch(UUID_V4); + }); + + it("генерирует разные значения", () => { + expect(uuidV4Fallback()).not.toBe(uuidV4Fallback()); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/utils/uuid.ts b/services/pcp-tgu-ops-ui/src/utils/uuid.ts new file mode 100644 index 0000000..0bd9dd9 --- /dev/null +++ b/services/pcp-tgu-ops-ui/src/utils/uuid.ts @@ -0,0 +1,35 @@ +/** + * Генерация UUID v4. + * + * `crypto.randomUUID()` доступен только в secure context (https или localhost), + * поэтому при открытии фронта по сетевому адресу (http://:5174) он отсутствует + * и падает с «crypto.randomUUID is not a function». `crypto.getRandomValues`, наоборот, + * доступен и в insecure context — используем его как фолбэк (RFC 4122 v4). + */ +export function generateUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return uuidV4Fallback(); +} + +/** RFC 4122 v4 на основе crypto.getRandomValues (работает в insecure context). */ +export function uuidV4Fallback(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // версия 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); +} diff --git a/services/pcp-tgu-ops-ui/tsconfig.app.json b/services/pcp-tgu-ops-ui/tsconfig.app.json index 7a7a882..21c6762 100644 --- a/services/pcp-tgu-ops-ui/tsconfig.app.json +++ b/services/pcp-tgu-ops-ui/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], - "allowJs": false, + "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, @@ -17,5 +17,7 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["src"] + "include": ["src"], + "comment": "Страж utcTime.guard.test.ts использует node:fs/process и проверяется только в vitest (node-окружение), а не при типизации приложения — иначе потребовался бы @types/node.", + "exclude": ["src/**/*.guard.test.ts"] } diff --git a/services/pcp-tgu-ops-ui/vite.config.ts b/services/pcp-tgu-ops-ui/vite.config.ts index 917129f..de3bddb 100644 --- a/services/pcp-tgu-ops-ui/vite.config.ts +++ b/services/pcp-tgu-ops-ui/vite.config.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { + host: "0.0.0.0", port: 5174, proxy: { "/api/pcp-request": { @@ -11,6 +12,30 @@ export default defineConfig({ changeOrigin: true, rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, "") }, + "/api/pcp-ballistics": { + target: "http://localhost:7003", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-ballistics/, "") + }, + "/api/pcp-mission": { + target: "http://localhost:7010", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, "") + }, + "/api/pcp-tgu": { + target: "http://localhost:7011", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, "") + }, + "/api/pcp-complex": { + target: "http://localhost:7002", + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api\/pcp-complex/, "") + }, + "/api/stations": { + target: "http://localhost:7009", + changeOrigin: true + }, "/api": { target: "http://localhost:7008", changeOrigin: true diff --git a/services/pcp-tgu-service/build.gradle.kts b/services/pcp-tgu-service/build.gradle.kts index 05636e5..3b4bccb 100644 --- a/services/pcp-tgu-service/build.gradle.kts +++ b/services/pcp-tgu-service/build.gradle.kts @@ -11,17 +11,23 @@ version = "0.0.1" java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of((property("versions.java") as String).toInt()) } } dependencies { + // Общие типы/слой толерантности времени; тянет spring-boot-starter-web (servlet/MVC), + // из-за чего приложение запускается как сервлетное (MVC), а не WebFlux. + implementation(project(":libs:pcp-types-lib")) + // Spring Boot + // starter-webflux оставлен только ради WebClient (внешние HTTP-клиенты, см. integration/api); + // тип приложения — сервлетный (MVC), т.к. в classpath есть spring-webmvc из starter-web выше. implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("org.springframework.boot:spring-boot-starter-actuator") runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:3.0.0") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") diff --git a/services/pcp-tgu-service/platforms.json b/services/pcp-tgu-service/platforms.json index 79cabcf..121cc4c 100644 --- a/services/pcp-tgu-service/platforms.json +++ b/services/pcp-tgu-service/platforms.json @@ -1 +1 @@ -[{"id":"4ae3e206-03ee-410e-aae5-96eb69435557","businessKey":"2013-030A","data":{"name":"Resurs-P No.1","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":39186,"operator":"Roscosmos / RSC Progress","decay_date":"2021-12-19","designator":"2013-030A","launch_date":"2013-06-25","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"7307c2f2-c92c-44fa-b864-10ffad2fe88c","businessKey":"2014-085A","data":{"name":"Resurs-P No.2","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":40360,"operator":"Roscosmos / RSC Progress","decay_date":"2022-04-13","designator":"2014-085A","launch_date":"2014-12-26","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"17080530-8d5b-47bd-ab9c-f7151d492578","businessKey":"2026-Emissio1","data":{"name":"Emissio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":2,"operator":"НСТАРТ","designator":"2026-001E","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"2bc048fc-7788-46fd-b368-37ee1b8b1a7a","businessKey":"2026-Reflexio1","data":{"name":"Reflexio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":30,"operator":"НСТАРТ","designator":"2026-001R","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"933bbfd1-774b-440e-aa83-256936129e0a","businessKey":"2016-016A","data":{"name":"Resurs-P No.3","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":41386,"operator":"Roscosmos / RSC Progress","designator":"2016-016A","launch_date":"2016-03-13","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"da0bed3a-5557-452d-a235-53ca26080bca","businessKey":"2024-046A","data":{"name":"Resurs-P No.4","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","operator":"Roscosmos / RSC Progress","designator":"2024-046A","launch_date":"2024-03-31","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"5293be80-4107-4bed-a8f1-4aeb8d833e03","businessKey":"1999-068A","data":{"name":"Terra (EOS AM-1)","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.2,"semimajor_axis_km":7083},"status":"STANDBY","country":"US","mass_kg":4864,"mission":"TERRA_AQUA","norad_id":25994,"operator":"NASA","designator":"1999-068A","launch_date":"1999-12-18","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"1435cdcc-0b81-4761-9b4e-a4e991160156","businessKey":"2021-016A","data":{"name":"Arktika-M No.1","orbit":{"type":"HEO"},"status":"STANDBY","country":"RU","mass_kg":2200,"mission":"ARKTIKA_M","operator":"Roshydromet / Lavochkin Association","designator":"2021-016A","launch_date":"2021-02-28","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"906b8317-72c7-436e-a582-d4e8df774430","businessKey":"2015-074A","data":{"name":"Elektro-L No.2","orbit":{"type":"GEO"},"status":"STANDBY","country":"RU","mass_kg":1740,"mission":"ELEKTRO_L","operator":"Roshydromet / Lavochkin Association","designator":"2015-074A","launch_date":"2015-12-11","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"2cf4b26e-f44c-43eb-9155-9e412df8d0ba","businessKey":"2019-038A","data":{"name":"Meteor-M No.2-2","orbit":{"type":"SSO","inclination_deg":98.83},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2019-038A","launch_date":"2019-07-05","satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"ea2e466c-3c8a-4e2d-8c0e-d0634e7cda53","businessKey":"2014-037A","data":{"name":"Meteor-M No.2","orbit":{"type":"SSO","inclination_deg":98.86},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2014-037A","launch_date":"2014-07-08","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"99c1d6d2-0e0d-4cb5-a7a8-7e8f934e22c2","businessKey":"2017-076A","data":{"name":"Kanopus-V No.5 (Grand)","orbit":{"type":"SSO","inclination_deg":97.45},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","operator":"Roscosmos / VNIIEM","designator":"2017-076A","launch_date":"2017-12-01","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"5381755f-2397-4863-b4f3-55301e73b27a","businessKey":"2012-039A","data":{"name":"Kanopus-V No.1","orbit":{"type":"SSO","inclination_deg":97.43},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","norad_id":38707,"operator":"Roscosmos / VNIIEM","designator":"2012-039A","launch_date":"2012-07-22","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"f0fc5aad-5b4a-49b0-9ee6-0ab79327a7e6","businessKey":"2024-149E","data":{"name":"Kondor-FKA No.2","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":1050,"mission":"KONDOR_FKA","operator":"Roscosmos","designator":"2024-149E","launch_date":"2024-08-13","spectrum_bands":["S_BAND"],"satellite_type_code":"SAR_X_HIRES","norad_id":62138},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"b8c703b6-3882-49a4-a033-22df87b3fb3d","businessKey":"2024-149E-56756","data":{"name":"Kondor-FKA No.2","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":1050,"mission":"KONDOR_FKA","operator":"Roscosmos","designator":"2024-149E-56756","launch_date":"2024-08-13","spectrum_bands":["S_BAND"],"satellite_type_code":"SAR_X_HIRES","norad_id":56756},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"54fbf999-b1c0-4868-a51c-eb7191f1e867","businessKey":"2014-016A","data":{"name":"Sentinel-1A","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.18,"semimajor_axis_km":7064},"status":"STANDBY","country":"FR","mass_kg":2300,"mission":"SENTINEL_1","norad_id":39634,"operator":"ESA / Copernicus","designator":"2014-016A","launch_date":"2014-04-03","spectrum_bands":["C_BAND"],"satellite_type_code":"SAR_X_HIRES"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"d5cf2788-cec5-43a1-9bda-ab3e8ec5c4aa","businessKey":"2015-028A","data":{"name":"Sentinel-2A","orbit":{"type":"SSO","inclination_deg":98.62,"semimajor_axis_km":7167},"status":"STANDBY","country":"FR","mass_kg":1140,"mission":"SENTINEL_2","norad_id":40697,"operator":"ESA / Copernicus","designator":"2015-028A","launch_date":"2015-06-23","spectrum_bands":["VIS","NIR","SWIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}}] +[{"id":"4ae3e206-03ee-410e-aae5-96eb69435557","businessKey":"2013-030A","data":{"name":"Resurs-P No.1","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":39186,"operator":"Roscosmos / RSC Progress","decay_date":"2021-12-19","designator":"2013-030A","launch_date":"2013-06-25","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"7307c2f2-c92c-44fa-b864-10ffad2fe88c","businessKey":"2014-085A","data":{"name":"Resurs-P No.2","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":40360,"operator":"Roscosmos / RSC Progress","decay_date":"2022-04-13","designator":"2014-085A","launch_date":"2014-12-26","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"17080530-8d5b-47bd-ab9c-f7151d492578","businessKey":"2026-Emissio1","data":{"name":"Emissio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":2,"operator":"НСТАРТ","designator":"2026-001E","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"2bc048fc-7788-46fd-b368-37ee1b8b1a7a","businessKey":"2026-Reflexio1","data":{"name":"Reflexio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":20,"operator":"НСТАРТ","designator":"2026-001R","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"933bbfd1-774b-440e-aa83-256936129e0a","businessKey":"2016-016A","data":{"name":"Resurs-P No.3","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":41386,"operator":"Roscosmos / RSC Progress","designator":"2016-016A","launch_date":"2016-03-13","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"da0bed3a-5557-452d-a235-53ca26080bca","businessKey":"2024-046A","data":{"name":"Resurs-P No.4","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","operator":"Roscosmos / RSC Progress","designator":"2024-046A","launch_date":"2024-03-31","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"5293be80-4107-4bed-a8f1-4aeb8d833e03","businessKey":"1999-068A","data":{"name":"Terra (EOS AM-1)","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.2,"semimajor_axis_km":7083},"status":"STANDBY","country":"US","mass_kg":4864,"mission":"TERRA_AQUA","norad_id":25994,"operator":"NASA","designator":"1999-068A","launch_date":"1999-12-18","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"1435cdcc-0b81-4761-9b4e-a4e991160156","businessKey":"2021-016A","data":{"name":"Arktika-M No.1","orbit":{"type":"HEO"},"status":"STANDBY","country":"RU","mass_kg":2200,"mission":"ARKTIKA_M","operator":"Roshydromet / Lavochkin Association","designator":"2021-016A","launch_date":"2021-02-28","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"906b8317-72c7-436e-a582-d4e8df774430","businessKey":"2015-074A","data":{"name":"Elektro-L No.2","orbit":{"type":"GEO"},"status":"STANDBY","country":"RU","mass_kg":1740,"mission":"ELEKTRO_L","operator":"Roshydromet / Lavochkin Association","designator":"2015-074A","launch_date":"2015-12-11","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"2cf4b26e-f44c-43eb-9155-9e412df8d0ba","businessKey":"2019-038A","data":{"name":"Meteor-M No.2-2","orbit":{"type":"SSO","inclination_deg":98.83},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2019-038A","launch_date":"2019-07-05","satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"ea2e466c-3c8a-4e2d-8c0e-d0634e7cda53","businessKey":"2014-037A","data":{"name":"Meteor-M No.2","orbit":{"type":"SSO","inclination_deg":98.86},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2014-037A","launch_date":"2014-07-08","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"99c1d6d2-0e0d-4cb5-a7a8-7e8f934e22c2","businessKey":"2017-076A","data":{"name":"Kanopus-V No.5 (Grand)","orbit":{"type":"SSO","inclination_deg":97.45},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","operator":"Roscosmos / VNIIEM","designator":"2017-076A","launch_date":"2017-12-01","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"5381755f-2397-4863-b4f3-55301e73b27a","businessKey":"2012-039A","data":{"name":"Kanopus-V No.1","orbit":{"type":"SSO","inclination_deg":97.43},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","norad_id":38707,"operator":"Roscosmos / VNIIEM","designator":"2012-039A","launch_date":"2012-07-22","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"f0fc5aad-5b4a-49b0-9ee6-0ab79327a7e6","businessKey":"2024-149E","data":{"name":"Kondor-FKA No.2","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":1050,"mission":"KONDOR_FKA","operator":"Roscosmos","designator":"2024-149E","launch_date":"2024-08-13","spectrum_bands":["S_BAND"],"satellite_type_code":"SAR_X_HIRES","norad_id":62138},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"b8c703b6-3882-49a4-a033-22df87b3fb3d","businessKey":"2024-149E-56756","data":{"name":"Kondor-FKA No.2","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":1050,"mission":"KONDOR_FKA","operator":"Roscosmos","designator":"2024-149E-56756","launch_date":"2024-08-13","spectrum_bands":["S_BAND"],"satellite_type_code":"SAR_X_HIRES","norad_id":56756},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"54fbf999-b1c0-4868-a51c-eb7191f1e867","businessKey":"2014-016A","data":{"name":"Sentinel-1A","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.18,"semimajor_axis_km":7064},"status":"STANDBY","country":"FR","mass_kg":2300,"mission":"SENTINEL_1","norad_id":39634,"operator":"ESA / Copernicus","designator":"2014-016A","launch_date":"2014-04-03","spectrum_bands":["C_BAND"],"satellite_type_code":"SAR_X_HIRES"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"d5cf2788-cec5-43a1-9bda-ab3e8ec5c4aa","businessKey":"2015-028A","data":{"name":"Sentinel-2A","orbit":{"type":"SSO","inclination_deg":98.62,"semimajor_axis_km":7167},"status":"STANDBY","country":"FR","mass_kg":1140,"mission":"SENTINEL_2","norad_id":40697,"operator":"ESA / Copernicus","designator":"2015-028A","launch_date":"2015-06-23","spectrum_bands":["VIS","NIR","SWIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}}] diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt new file mode 100644 index 0000000..7b6d145 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/MissionPlaningProperties.kt @@ -0,0 +1,9 @@ +package space.nstart.pcp_tgu_service.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "mission-planing") +data class MissionPlaningProperties( + /** Базовый URL pcp-mission-planing-service. */ + val baseUrl: String +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt index 45a22d7..e1e83de 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/PlanningProperties.kt @@ -35,6 +35,24 @@ data class PlanningProperties( /** Минимальный запас времени для повторной выдачи после REJECTED. */ val minReissueLeadMinutes: Long, + /** Минимальное время на подготовку срочно перевыдаваемого плана (после негатива), в минутах. */ + val reissuePrepMinutes: Long, + /** TTL lease'а worker job claim в секундах. */ - val workerLockTtlSeconds: Long + val workerLockTtlSeconds: Long, + + /** + * Сколько минут после начала плана ждать решения оператора, прежде чем продолжить цепочку + * без подтверждения. По истечении план НЕ помечается ACCEPTED — он остаётся WAITING_DECISION, + * но перестаёт блокировать выдачу следующего плана и становится новым якорем цепочки. + */ + val waitingDecisionGraceMinutes: Long, + + /** + * Порог заморозки перед стартом плана для ручной перевыдачи в свой слот (replace). + * Если до начала плана осталось меньше этого числа минут (now >= startTime - layinFreezeMinutes), + * план считается невозвратным: заменить его в своём слоте уже нельзя (доступен только fork-forward). + * Вместе со статусом LAID_IN образует «точку невозврата». + */ + val layinFreezeMinutes: Long ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt new file mode 100644 index 0000000..2bb3eaa --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/RestJacksonConfig.kt @@ -0,0 +1,4 @@ +package space.nstart.pcp_tgu_service.config + +// Пустой — Jackson 3 регистрирует JavaTimeModule/KotlinModule автоматически через Boot-автоконфиг. +// LocalDateTime сериализуется как ISO-строка без зоны при disable(WRITE_DATES_AS_TIMESTAMPS). diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt index f6d38db..adcd9e8 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/TopicsProperties.kt @@ -9,5 +9,17 @@ data class TopicsProperties( val visibilityWindowsChanged: String, /** Topic с решением ACCEPTED/REJECTED по конкретной attempt выдачи плана. */ - val satellitePlanDecision: String + val satellitePlanDecision: String, + + /** Topic-событие «не заложен» по принятому плану. */ + val layinFailed: String, + + /** Topic-событие «план заложен» (позитивный фидбек закладки) — переводит план в LAID_IN. */ + val layinConfirmed: String, + + /** Topic-событие «не принят» по принятому плану. */ + val planNotAccepted: String, + + /** Topic-событие об изменении параметров станций (перезапрос ЗРВ). */ + val stationParamsChanged: String ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt index e199783..0de8b07 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlanController.kt @@ -1,36 +1,141 @@ package space.nstart.pcp_tgu_service.controller -/** Содержит методы API для чтения текущих планов. */ +/** Содержит методы API для чтения текущих планов, создания черновика и ручной выдачи плана. */ +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers +import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest +import space.nstart.pcp_tgu_service.dto.CreateChainPlanResponse +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.CreateDraftResponse import space.nstart.pcp_tgu_service.dto.PlanResponse +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.dto.ZrvWindowResponse +import space.nstart.pcp_tgu_service.service.CreateChainPlanUseCase +import space.nstart.pcp_tgu_service.service.CreateDraftUseCase +import space.nstart.pcp_tgu_service.service.DeleteDraftUseCase +import space.nstart.pcp_tgu_service.service.DismissPlanUseCase +import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker +import space.nstart.pcp_tgu_service.service.PlanProjectionService import space.nstart.pcp_tgu_service.service.PlanQueryService +import java.util.UUID @RestController @RequestMapping("/api/plans") class PlanController( - private val planQueryService: PlanQueryService + private val planQueryService: PlanQueryService, + private val issueDuePlanWorker: IssueDuePlanWorker, + private val createDraftUseCase: CreateDraftUseCase, + private val deleteDraftUseCase: DeleteDraftUseCase, + private val dismissPlanUseCase: DismissPlanUseCase, + private val createChainPlanUseCase: CreateChainPlanUseCase, + private val planProjectionService: PlanProjectionService ) { - /** Возвращает планы по всем КА с указанным окном подтверждённой истории. */ + /** Возвращает планы по всем КА (persisted ∪ проекция будущего) с окном истории. */ @GetMapping - fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): Mono> = - Mono.fromCallable { - planQueryService.getAllPlans(historyDays).map(PlanResponse::from) - }.subscribeOn(Schedulers.boundedElastic()) + fun getAllPlans(@RequestParam(defaultValue = "7") historyDays: Long): List = + planQueryService.getAllPlans(historyDays) - /** Возвращает цепочку планов для одного КА. */ + /** + * Черновики (DRAFT) для режима «Черновики» во вкладке «Планы»: созданные, но ещё не + * отправленные планы-кандидаты. Отдельный эндпоинт, т.к. остальные чтения DRAFT отсекают. + * Литеральный путь `/drafts` приоритетнее шаблона `/{spacecraftId}`. + */ + @GetMapping("/drafts") + fun getDraftPlans(@RequestParam(required = false) spacecraftId: String?): List = + planQueryService.getDraftPlans(spacecraftId) + + /** Возвращает цепочку планов для одного КА (persisted ∪ проекция будущего). */ @GetMapping("/{spacecraftId}") fun getPlansBySpacecraftId( @PathVariable spacecraftId: String, @RequestParam(defaultValue = "7") historyDays: Long - ): Mono> = - Mono.fromCallable { - planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays).map(PlanResponse::from) - }.subscribeOn(Schedulers.boundedElastic()) + ): List = + planQueryService.getPlansBySpacecraftId(spacecraftId, historyDays) + + /** + * «Сырые» ЗРВ КА из последнего снапшота (границы реальных зон) — из них оператор набирает окно + * нового плана во вкладке «Планы». Пусто, если снапшота нет. + */ + @GetMapping("/{spacecraftId}/zrv") + fun getZrvWindows(@PathVariable spacecraftId: String): List = + planProjectionService.rawZrvWindows(spacecraftId).map(ZrvWindowResponse::from) + + /** + * Создаёт план новой цепочки (действие «Создать план» во вкладке «Планы»): окно из двух ЗРВ, + * опциональная база-предыстория (или цепочка без истории). Возвращает план-кандидат (DRAFT) и + * прогноз будущей цепочки от него. План становится рабочим после ручной отправки (promote) и + * приёмки. 404 — базы нет; 422 — окно не из ЗРВ / база чужого КА / нечисловой spacecraftId. + */ + @PostMapping("/new-chain") + fun createChainPlan(@RequestBody body: CreateChainPlanRequest): CreateChainPlanResponse = + createChainPlanUseCase.createChainPlan(body) + + /** + * Создаёт черновик плана (DRAFT) на основе basePlanId. + * Режим replace — черновик в том же слоте (база ещё правима). + * Режим fork-forward — черновик на следующей ЗРВ (база невозвратна). + * Возвращает draftPlanId, с которым работают Ф3–Ф5. + */ + @PostMapping("/{planId}/draft") + fun createDraft( + @PathVariable planId: UUID, + @RequestBody body: CreateDraftRequest + ): CreateDraftResponse = createDraftUseCase.createDraft(planId, body) + + /** + * Ручная выдача плана оператором («Применить / Отправить вручную»). Стартует новую attempt + * и Camunda-процесс под гардом «точки невозврата»: план должен быть в будущем за порогом заморозки, + * не заложен (≠ LAID_IN) и по КА не должно быть активной выдачи. + * 404 — плана нет; 409 — по КА уже идёт выдача; 422 — точка невозврата пройдена (нужен fork-forward). + */ + @PostMapping("/{planId}/issue") + fun issuePlan(@PathVariable planId: UUID): ProcessStartResponse = + issueDuePlanWorker.issuePlanNow(planId) + + /** + * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и выдаёт его. Под lock'ом + * состояния КА: оптимистичный лок базы (версия цепочки не уехала), контроль валидности слота + * и гард точки невозврата. + * 404 — черновика нет; 422 — план не DRAFT / слот сместился / точка невозврата пройдена; + * 409 — база изменилась с момента создания черновика либо по КА уже идёт выдача. + */ + @PostMapping("/{draftPlanId}/promote") + fun promoteDraft(@PathVariable draftPlanId: UUID): ProcessStartResponse = + issueDuePlanWorker.promoteDraftNow(draftPlanId) + + /** + * Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию, снимая блокировку «один + * открытый черновик на базу». 404 — плана нет; 422 — план не DRAFT (живые планы не трогаем). + */ + @DeleteMapping("/{draftPlanId}") + fun deleteDraft(@PathVariable draftPlanId: UUID): ResponseEntity { + deleteDraftUseCase.deleteDraft(draftPlanId) + return ResponseEntity.noContent().build() + } + + /** + * «Снять с учёта» терминально-негативный план: проставляет `dismissedAt`, чтобы он перестал + * считаться проблемой и красить индикатор КА. Статус и цепочку не трогает. + * 404 — плана нет; 422 — статус не терминально-негативный. + */ + @PostMapping("/{planId}/dismiss") + fun dismissPlan(@PathVariable planId: UUID): ResponseEntity { + dismissPlanUseCase.dismiss(planId) + return ResponseEntity.noContent().build() + } + + /** «Вернуть на учёт»: снимает `dismissedAt`. 404 — плана нет. */ + @PostMapping("/{planId}/restore") + fun restorePlan(@PathVariable planId: UUID): ResponseEntity { + dismissPlanUseCase.restore(planId) + return ResponseEntity.noContent().build() + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt index 0d17c5d..4cd59bd 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt @@ -10,8 +10,6 @@ import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers /** * REST-контроллер для просмотра списка космических аппаратов. @@ -30,10 +28,8 @@ class PlatformController( @Operation(summary = "Получить список космических аппаратов") fun getPlatforms( @RequestParam(required = false) status: String? - ): Mono> = - Mono.fromCallable { - platformService.loadPlatforms(status).map { platform -> platform.toResponse() } - }.subscribeOn(Schedulers.boundedElastic()) + ): List = + platformService.loadPlatforms(status).map { platform -> platform.toResponse() } private fun PlatformCatalogItem.toResponse(): PlatformResponse = PlatformResponse( diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt index 03d105c..1999530 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/TestTguController.kt @@ -9,23 +9,18 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -import reactor.core.publisher.Mono -import reactor.core.scheduler.Schedulers import space.nstart.pcp_tgu_service.domain.PlanDecision +import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.dto.PlanResponse -import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase -import space.nstart.pcp_tgu_service.service.IssueDuePlanWorker +import space.nstart.pcp_tgu_service.service.PlanProjectionService import space.nstart.pcp_tgu_service.service.PlanQueryService -import space.nstart.pcp_tgu_service.service.PlanRecalculationJobService import space.nstart.pcp_tgu_service.service.PlatformService -import space.nstart.pcp_tgu_service.service.RebuildPlanScheduleUseCase import space.nstart.pcp_tgu_service.service.TestPlanDecisionService import space.nstart.pcp_tgu_service.service.VisibilityRefreshJobService -import java.nio.file.Files -import java.nio.file.Path import java.time.LocalDateTime import java.util.UUID @@ -37,9 +32,7 @@ import java.util.UUID @RequestMapping("/test/tgu") class TestTguController( private val visibilityRefreshJobService: VisibilityRefreshJobService, - private val planRecalculationJobService: PlanRecalculationJobService, - private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, - private val issueDuePlanWorker: IssueDuePlanWorker, + private val planProjectionService: PlanProjectionService, private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase, private val testPlanDecisionService: TestPlanDecisionService, private val externalPointsClient: ExternalPointsClient, @@ -51,32 +44,20 @@ class TestTguController( fun refreshVisibility( @PathVariable spacecraftId: String, @RequestParam(required = false) noradId: Long? - ): Mono = - Mono.fromRunnable { - visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") - }.subscribeOn(Schedulers.boundedElastic()) + ) { + visibilityRefreshJobService.enqueue(spacecraftId, noradId, "TEST_CONTROLLER") + } - /** Ставит и сразу выполняет rebuild persisted schedule для КА. */ - @PostMapping("/spacecraft/{spacecraftId}/rebuild-plans") - fun rebuildPlans(@PathVariable spacecraftId: String): Mono = - Mono.fromRunnable { - planRecalculationJobService.enqueue(spacecraftId, "TEST_CONTROLLER") - rebuildPlanScheduleUseCase.rebuild(spacecraftId) - }.subscribeOn(Schedulers.boundedElastic()) - - /** Принудительно выдаёт конкретный persisted plan через новую attempt. */ - @PostMapping("/plans/{planId}/issue") - fun issuePlan(@PathVariable planId: UUID): Mono = - Mono.fromCallable { - issueDuePlanWorker.issuePlanNow(planId) - }.subscribeOn(Schedulers.boundedElastic()) + /** Возвращает вычисленную on-demand проекцию будущей цепочки КА (диагностика). */ + @GetMapping("/spacecraft/{spacecraftId}/projection") + fun getProjection(@PathVariable spacecraftId: String): List = + planProjectionService.project(spacecraftId).map(PlanResponse::projected) /** Инжектит decision event через тот же use case, что Kafka consumer. */ @PostMapping("/plan-decisions") - fun handleDecision(@RequestBody message: PlanDecisionMessage): Mono = - Mono.fromRunnable { - handlePlanDecisionUseCase.handle(message) - }.subscribeOn(Schedulers.boundedElastic()) + fun handleDecision(@RequestBody message: PlanDecisionMessage) { + handlePlanDecisionUseCase.handle(message) + } /** Генерирует ACCEPTED/REJECTED decision event для active attempt указанного persisted plan. */ @PostMapping("/plans/{planId}/decision") @@ -84,23 +65,26 @@ class TestTguController( @PathVariable planId: UUID, @RequestParam decision: PlanDecision, @RequestParam(required = false) reason: String? - ): Mono = - Mono.fromCallable { - testPlanDecisionService.generateDecision(planId, decision, reason) - }.subscribeOn(Schedulers.boundedElastic()) + ): PlanDecisionMessage = + testPlanDecisionService.generateDecision(planId, decision, reason) - /** Загружает cache аппаратов из файла с root-array payload НСИ вместо запроса в НСИ. */ - @PostMapping("/platforms/load-from-file") - fun loadPlatformsFromFile(@RequestParam("path") filePath: String): Mono = - Mono.fromCallable { - val path = Path.of(filePath).toAbsolutePath().normalize() - val platforms = platformService.loadPlatformsFromNsiPayload(Files.readString(path)) - TestPlatformsLoadResponse( - filePath = path.toString(), - loadedCount = platforms.size, - allowedCount = platformService.loadAllowedPlatforms().size - ) - }.subscribeOn(Schedulers.boundedElastic()) + /** Генерирует событие «план заложен» (AWAITING_LAYIN → LAID_IN) для указанного persisted plan. */ + @PostMapping("/plans/{planId}/confirm-layin") + fun confirmLayin( + @PathVariable planId: UUID, + @RequestParam(required = false) reason: String? + ): PlanExecutionFeedbackMessage = + testPlanDecisionService.generateLayinConfirmed(planId, reason) + + /** Загружает cache аппаратов из переданного в теле root-array payload НСИ вместо запроса в НСИ. */ + @PostMapping("/platforms/load-manual") + fun loadPlatformsFromPayload(@RequestBody platforms: List): TestPlatformsLoadResponse { + val loaded = platformService.loadPlatformsFromNsiPayload(platforms) + return TestPlatformsLoadResponse( + loadedCount = loaded.size, + allowedCount = platformService.loadAllowedPlatforms().size + ) + } /** Загружает raw payload окон видимости из баллистики для диагностики. */ @GetMapping("/external-points/{noradId}") @@ -112,21 +96,16 @@ class TestTguController( @RequestParam("time_stop") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) timeStop: LocalDateTime - ): Mono = - Mono.fromCallable { - externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) - }.subscribeOn(Schedulers.boundedElastic()) + ): String = + externalPointsClient.fetchRawVisibilityPayload(noradId, timeStart, timeStop) /** Возвращает persisted plan для проверки результата ручных операций. */ @GetMapping("/plans/{planId}") - fun getPlan(@PathVariable planId: UUID): Mono = - Mono.fromCallable { - PlanResponse.from(planQueryService.getByPlanId(planId)) - }.subscribeOn(Schedulers.boundedElastic()) + fun getPlan(@PathVariable planId: UUID): PlanResponse = + PlanResponse.from(planQueryService.getByPlanId(planId)) } data class TestPlatformsLoadResponse( - val filePath: String, val loadedCount: Int, val allowedCount: Int ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt new file mode 100644 index 0000000..6050f09 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/DraftDTOs.kt @@ -0,0 +1,23 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** Допустимые значения [CreateDraftRequest.mode]; та же строка уходит на mission-planing. */ +object DraftMode { + const val REPLACE = "replace" + const val FORK_FORWARD = "fork-forward" +} + +data class CreateDraftRequest( + /** [DraftMode.REPLACE] — черновик в том же слоте; [DraftMode.FORK_FORWARD] — черновик на следующей ЗРВ. */ + val mode: String, + /** Начало окна целевой ЗРВ (naive, локальное время). Обязательно для fork-forward. */ + val windowStart: LocalDateTime? = null, + /** Конец окна целевой ЗРВ (naive, локальное время). Обязательно для fork-forward. */ + val windowStop: LocalDateTime? = null, + /** КПП целевой ЗРВ. Обязательно для fork-forward. */ + val windowKppId: String? = null +) + +data class CreateDraftResponse(val draftPlanId: UUID) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt new file mode 100644 index 0000000..da074a0 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/NewChainDTOs.kt @@ -0,0 +1,51 @@ +package space.nstart.pcp_tgu_service.dto + +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import java.time.LocalDateTime +import java.util.UUID + +/** «Сырое» окно ЗРВ КА для ручного набора плана новой цепочки. Времена — naive ISO без зоны. */ +data class ZrvWindowResponse( + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val kppId: String +) { + companion object { + fun from(window: CalculatedPlan): ZrvWindowResponse = + ZrvWindowResponse( + startTime = window.startTime, + endTime = window.endTime, + kppId = window.kppId + ) + } +} + +/** + * Запрос создания плана новой цепочки (вкладка «Планы», действие оператора «Создать план»). + * + * Окно набирается из двух реальных ЗРВ: [windowStart]/[windowKppId] — начало (старт и КПП берутся + * у одной зоны), [windowStop] — конец (конец другой, не раньше начала зоны). [basePlanId] — + * «предыстория»: прошлый план КА, чьё содержимое клонируется в окно и от которого тянется история + * (`base_plan_id`). Если `null` — цепочка без истории: создаётся пустой план, наполняемый с нуля. + */ +data class CreateChainPlanRequest( + /** КА (= noradId строкой); обязателен для сценария «без базы». */ + val spacecraftId: String, + /** Прошлый план-предыстория или null для цепочки без истории. */ + val basePlanId: UUID? = null, + /** Начало окна (naive, локальное время) — старт выбранной стартовой ЗРВ. */ + val windowStart: LocalDateTime, + /** Конец окна (naive, локальное время) — конец выбранной конечной ЗРВ. */ + val windowStop: LocalDateTime, + /** КПП стартовой ЗРВ. */ + val windowKppId: String +) + +/** + * Ответ создания плана новой цепочки: id плана-кандидата (DRAFT, применяется через `promote`) и + * прогноз будущей цепочки от его окна (what-if, состояние КА не тронуто). + */ +data class CreateChainPlanResponse( + val draftPlanId: UUID, + val projectedChain: List +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt new file mode 100644 index 0000000..0ced40f --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanExecutionFeedbackMessage.kt @@ -0,0 +1,27 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие негативной обратной связи по выданному (принятому) плану. + * + * Используется двумя топиками — «не заложен» и «не принят». Сейчас форма общая; + * при будущем расхождении поведения её можно разделить. + */ +data class PlanExecutionFeedbackMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Идентификатор КА внутри TGU. */ + val spacecraftId: String, + + /** Stable persisted planId плана, по которому пришёл негатив (текущий базовый план). */ + val planId: UUID, + + /** Время события во внешней системе. */ + val occurredAt: LocalDateTime, + + /** Причина/комментарий. */ + val reason: String? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt index e705e51..4aebbef 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponse.kt @@ -1,12 +1,17 @@ package space.nstart.pcp_tgu_service.dto +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.nio.charset.StandardCharsets import java.time.LocalDateTime import java.util.UUID /** * DTO ответа REST API для плана. + * + * Времена ([startTime]/[endTime]) — naive `LocalDateTime`, сериализуются как ISO-строка без зоны + * (напр. `"2026-05-29T01:00:00"`). Хранение, домен и API работают на одних настенных часах. */ data class PlanResponse( @@ -16,17 +21,37 @@ data class PlanResponse( /** Идентификатор космического аппарата */ val spacecraftId: String, - /** Время начала плана */ + /** Время начала плана (ISO без зоны). */ val startTime: LocalDateTime, - /** Время окончания плана */ + /** Время окончания плана (ISO без зоны). */ val endTime: LocalDateTime, /** Идентификатор пункта закладки */ val kppId: String, /** Статус persisted плана в жизненном цикле выдачи. */ - val status: PlannedPlanStatus + val status: PlannedPlanStatus, + + /** + * Непосредственный предшественник плана в цепочке (`base_plan_id`). `null` — у плана нет + * предыстории: это начало новой цепочки (создан «без истории») либо самый первый план. + * Фронт по этому полю решает, рисовать ли входящую стрелку: в старт цепочки она не ведёт. + */ + val basePlanId: UUID? = null, + + /** + * `true` — это вычисленная на лету проекция будущего (в БД не записана), а не persisted-план. + * У проекции предшественник не хранится, но цепочку она продолжает — фронт связывает её + * соседние звенья по времени, поэтому начало цепочки определяется ТОЛЬКО для persisted. + */ + val projected: Boolean = false, + + /** + * Время «снятия с учёта» (деактивации) терминально-негативного плана оператором. `null` — план + * на учёте. Фронт по этому полю гасит план и исключает его из индикатора проблем КА. + */ + val dismissedAt: LocalDateTime? = null ) { companion object { @@ -41,7 +66,33 @@ data class PlanResponse( startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId, - status = plan.status + status = plan.status, + basePlanId = plan.basePlanId, + projected = false, + dismissedAt = plan.dismissedAt ) + + /** + * Виртуальный (не сохранённый) план будущей проекции со статусом PLANNED. + * + * planId детерминированно выводится из бизнес-ключа, чтобы один и тот же расчётный план + * стабильно опознавался фронтом между запросами. + */ + fun projected(plan: CalculatedPlan): PlanResponse = + PlanResponse( + planId = deterministicPlanId(plan), + spacecraftId = plan.spacecraftId, + startTime = plan.startTime, + endTime = plan.endTime, + kppId = plan.kppId, + status = PlannedPlanStatus.PLANNED, + basePlanId = null, + projected = true + ) + + private fun deterministicPlanId(plan: CalculatedPlan): UUID { + val businessKey = "${plan.spacecraftId}|${plan.startTime}|${plan.endTime}|${plan.kppId}" + return UUID.nameUUIDFromBytes(businessKey.toByteArray(StandardCharsets.UTF_8)) + } } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt new file mode 100644 index 0000000..55f48f2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/StationParamsChangedMessage.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.dto + +import java.time.LocalDateTime +import java.util.UUID + +/** + * Kafka-событие об изменении параметров станций (например, станция вышла из строя). + * + * По нему перезапрашиваются ЗРВ у баллистики по всем активным КА, и цепочка пересобирается + * через on-demand проекцию. + */ +data class StationParamsChangedMessage( + /** Dedup key для at-least-once доставки этого Kafka event. */ + val eventId: UUID, + + /** Какая станция изменилась; пока не используется для сужения набора КА. */ + val stationId: String?, + + /** Время события во внешней системе. */ + val occurredAt: LocalDateTime, + + /** Причина/комментарий. */ + val reason: String? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt deleted file mode 100644 index 49b2918..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlanRecalculationJobEntity.kt +++ /dev/null @@ -1,48 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime - -/** - * Job на перестроение persisted расписания планов одного КА. - */ -@Entity -@Table(name = "plan_recalculation_job") -class PlanRecalculationJobEntity( - @Id - @Column(name = "spacecraft_id", nullable = false, updatable = false) - var spacecraftId: String, - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: JobStatus = JobStatus.PENDING, - - @Column(name = "reason") - var reason: String? = null, - - @Column(name = "locked_by") - var lockedBy: String? = null, - - @Column(name = "locked_until") - var lockedUntil: LocalDateTime? = null, - - @Column(name = "attempts", nullable = false) - var attempts: Int = 0, - - @Column(name = "rerun_requested", nullable = false) - var rerunRequested: Boolean = false, - - @Column(name = "requested_at", nullable = false) - var requestedAt: LocalDateTime = LocalDateTime.now(), - - @Column(name = "updated_at", nullable = false) - var updatedAt: LocalDateTime = LocalDateTime.now() -) { - /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ - constructor() : this(spacecraftId = "") -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt index 43259aa..5e03805 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanEntity.kt @@ -47,8 +47,13 @@ class PlannedPlanEntity( @Column(name = "accepted_at") var acceptedAt: LocalDateTime? = null, - @Column(name = "superseded_by_plan_id") - var supersededByPlanId: UUID? = null, + /** + * «Снято с учёта»: время деактивации терминально-негативного плана оператором. NULL — план на + * учёте. Косметико-статистический флаг ([space.nstart.pcp_tgu_service.service.DismissPlanUseCase]): + * не меняет статус и состояние цепочки, обратим. + */ + @Column(name = "dismissed_at") + var dismissedAt: LocalDateTime? = null, @Column(name = "created_at", nullable = false) var createdAt: LocalDateTime = LocalDateTime.now(), diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt index 4c2b784..ba541fd 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/PlannedPlanStatus.kt @@ -4,12 +4,38 @@ package space.nstart.pcp_tgu_service.entity * Жизненный цикл persisted business-plan в расписании TGU. */ enum class PlannedPlanStatus { + /** Виртуальный статус проекции будущего: НИКОГДА не пишется в БД (см. PlanProjectionService). */ PLANNED, + + /** + * Черновик: план в стадии правки оператором. Персистируется, но НЕ является головой цепочки + * и НЕ попадает в проекцию/read-путь как запланированный слот. Не выдаётся авто-воркером. + * Становится активным только после promote (Ф5) — смена статуса на PLANNED/ISSUING. + */ + DRAFT, ISSUING, WAITING_DECISION, ACCEPTED, - REJECTED, + + /** Принят и оптимистично считается заложенным; текущий базовый план цепочки. */ + AWAITING_LAYIN, + + /** Подтверждённая закладка (терминальный успех). */ + LAID_IN, + + /** Пришло событие «не заложен» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ + LAYIN_FAILED, + + /** Пришло событие «не принят» (терминал); запускает срочную перевыдачу на ближайшую ЗРВ. */ + NOT_ACCEPTED, + + /** + * Вытеснён: по тому же окну КА другой план ушёл в LAID_IN (заложен), а этот — конкурирующая + * выдача/коррекция (ISSUING/WAITING_DECISION/AWAITING_LAYIN) на пересекающееся окно. Терминал, + * цепочку не двигает. Решение по запоздавшей attempt такого плана записывается, но игнорируется. + */ SUPERSEDED, - EXPIRED, + + REJECTED, START_AMBIGUOUS } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt index ced8a80..9996614 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/SpacecraftPlanningStateEntity.kt @@ -27,6 +27,20 @@ class SpacecraftPlanningStateEntity( @Column(name = "chain_version", nullable = false) var chainVersion: Long = 0, + /** + * Если задано — ближайшую ЗРВ со стартом не раньше этого времени нужно выдать срочно + * (после REJECTED / «не заложен» / «не принят»), не дожидаясь обычного issue_not_before. + */ + @Column(name = "urgent_reissue_from") + var urgentReissueFrom: LocalDateTime? = null, + + /** + * Цепочка стоит на паузе после REJECTED: проекция пуста, воркер не выдаёт. Снимается при ручной + * выдаче / промоуте черновика / приёмке — то есть когда оператор осознанно вмешался. + */ + @Column(name = "paused_after_reject", nullable = false) + var pausedAfterReject: Boolean = false, + @Column(name = "updated_at", nullable = false) var updatedAt: LocalDateTime = LocalDateTime.now() ) { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt deleted file mode 100644 index b9615e9..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanEntity.kt +++ /dev/null @@ -1,63 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** JPA-сущность, хранящая план вместе с его процессным и подтверждающим жизненным циклом. */ -import jakarta.persistence.Column -import jakarta.persistence.Entity -import jakarta.persistence.EnumType -import jakarta.persistence.Enumerated -import jakarta.persistence.Id -import jakarta.persistence.Table -import java.time.LocalDateTime -import java.util.UUID - -@Entity -@Table(name = "tracked_plan") -class TrackedPlanEntity( - @Id - @Column(name = "plan_id", nullable = false, updatable = false) - var planId: UUID, - - @Column(name = "spacecraft_id", nullable = false) - var spacecraftId: String, - - @Column(name = "start_time", nullable = false) - var startTime: LocalDateTime, - - @Column(name = "end_time", nullable = false) - var endTime: LocalDateTime, - - @Column(name = "kpp_id", nullable = false) - var kppId: String, - - @Column(name = "process_instance_key") - var processInstanceKey: Long? = null, - - @Column(name = "created_at", nullable = false) - var createdAt: LocalDateTime = LocalDateTime.now(), - - @Column(name = "confirmed_at") - var confirmedAt: LocalDateTime? = null, - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: TrackedPlanStatus = TrackedPlanStatus.WAITING_CONFIRMATION, - - @Enumerated(EnumType.STRING) - @Column(name = "plan_source", nullable = false) - var planSource: TrackedPlanSource = TrackedPlanSource.AUTO_CHAIN, - - @Column(name = "base_plan_id") - var basePlanId: UUID? = null, - - @Column(name = "superseded_by_plan_id") - var supersededByPlanId: UUID? = null -) { - /** Создаёт пустой экземпляр сущности, необходимый для JPA. */ - constructor() : this( - planId = UUID.randomUUID(), - spacecraftId = "", - startTime = LocalDateTime.MIN, - endTime = LocalDateTime.MIN, - kppId = "" - ) -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt deleted file mode 100644 index beab660..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanSource.kt +++ /dev/null @@ -1,7 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** Описывает способ создания отслеживаемого плана. */ -enum class TrackedPlanSource { - AUTO_CHAIN, - MANUAL -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt deleted file mode 100644 index 57d2131..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/entity/TrackedPlanStatus.kt +++ /dev/null @@ -1,9 +0,0 @@ -package space.nstart.pcp_tgu_service.entity - -/** Описывает состояние жизненного цикла отслеживаемого плана. */ -enum class TrackedPlanStatus { - NEW, - WAITING_CONFIRMATION, - CONFIRMED, - SUPERSEDED -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt new file mode 100644 index 0000000..b5c4bed --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/MissionPlaningClient.kt @@ -0,0 +1,84 @@ +package space.nstart.pcp_tgu_service.integration.api + +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.MissionPlaningProperties +import java.time.Duration +import java.time.LocalDateTime +import java.util.UUID + +data class MissionCloneRequest( + val mode: String, + val windowStart: LocalDateTime? = null, + val windowStop: LocalDateTime? = null +) + +data class MissionCloneResponse(val newMissionId: UUID) + +data class MissionBlankRequest( + val satelliteId: Long, + val missionStart: LocalDateTime, + val missionStop: LocalDateTime +) + +@Component +class MissionPlaningClient(properties: MissionPlaningProperties) { + + private val webClient: WebClient = WebClient.builder() + .baseUrl(properties.baseUrl) + .build() + + private val timeout: Duration = Duration.ofSeconds(30) + + fun cloneMission(missionId: UUID, req: MissionCloneRequest): MissionCloneResponse = + webClient.post() + .uri("/api/missions/{id}/clone", missionId) + .bodyValue(req) + .retrieve() + .onStatus({ it.isError }) { resp -> + resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body -> + // 4xx апстрима пробрасываем как есть (404 — нет базовой миссии и т.п.), + // 5xx сворачиваем в 502: проблема не в запросе оператора, а в mission-planing. + val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY + else HttpStatus.valueOf(resp.statusCode().value()) + ResponseStatusException(status, "Клон миссии $missionId не удался (${resp.statusCode()}): $body") + } + } + .bodyToMono(MissionCloneResponse::class.java) + .block(timeout) + ?: throw ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "Пустой ответ mission-planing на клон миссии $missionId" + ) + + /** Создаёт пустую миссию на окно (план новой цепочки «без истории»). */ + fun createBlankMission(req: MissionBlankRequest): MissionCloneResponse = + webClient.post() + .uri("/api/missions/blank") + .bodyValue(req) + .retrieve() + .onStatus({ it.isError }) { resp -> + resp.bodyToMono(String::class.java).defaultIfEmpty("").map { body -> + val status = if (resp.statusCode().is5xxServerError) HttpStatus.BAD_GATEWAY + else HttpStatus.valueOf(resp.statusCode().value()) + ResponseStatusException(status, "Создание пустой миссии не удалось (${resp.statusCode()}): $body") + } + } + .bodyToMono(MissionCloneResponse::class.java) + .block(timeout) + ?: throw ResponseStatusException( + HttpStatus.BAD_GATEWAY, + "Пустой ответ mission-planing на создание пустой миссии" + ) + + /** Компенсация: удаляет клонированную миссию при сбое сохранения DRAFT-плана. Best-effort. */ + fun deleteMission(missionId: UUID) { + webClient.delete() + .uri("/api/missions/{id}", missionId) + .retrieve() + .toBodilessEntity() + .block(timeout) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt index 1a46d9c..1a1bca2 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParser.kt @@ -10,6 +10,7 @@ import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeParseException +import java.time.temporal.ChronoUnit /** * Парсит raw payload баллистики в доменную модель расчёта без доступа к БД. @@ -18,7 +19,6 @@ import java.time.format.DateTimeParseException class VisibilityPayloadParser( private val objectMapper: ObjectMapper ) { - private val zoneId: ZoneId = ZoneId.systemDefault() /** Группирует RVA records по станциям и сортирует окна для алгоритма планирования. */ fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints { @@ -42,11 +42,22 @@ class VisibilityPayloadParser( return SpacecraftPoints(spacecraftId = spacecraftId, points = points) } - /** Разбирает UTC/ISO строку времени в локальную LocalDateTime, как делал прежний client. */ + /** + * Разбирает ISO-строку времени баллистики в naive `LocalDateTime`. + * + * Если время приходит с зоной (`...Z`/офсет), момент проецируем в локальный пояс JVM + * (`ZoneId.systemDefault()`): naive-время в системе — настенные часы локальной зоны, в которой + * работают все сервисы, без UTC-семантики. Naive-строку без зоны берём как есть. + * + * Обрезаем до микросекунд — это эффективная точность системы: `planned_plan.start_time/end_time` + * хранятся как Postgres `timestamp(6)`, при записи наносекундный хвост баллистики всё равно теряется. + * Без обрезки проекция (наносекунды) и persisted-планы (микросекунды) перестают совпадать по + * бизнес-ключу — ломается дедуп в read-пути и матчинг базы в [calculatePlansAfter]. + */ private fun toLocalDateTime(value: String): LocalDateTime = try { - Instant.parse(value).atZone(zoneId).toLocalDateTime() + LocalDateTime.ofInstant(Instant.parse(value), ZoneId.systemDefault()) } catch (_: DateTimeParseException) { LocalDateTime.parse(value) - } + }.truncatedTo(ChronoUnit.MICROS) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinConfirmedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinConfirmedConsumer.kt new file mode 100644 index 0000000..c4f3304 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinConfirmedConsumer.kt @@ -0,0 +1,25 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase + +/** + * Kafka consumer события «план заложен» (позитивный фидбек закладки); делегирует перевод + * принятого плана в LAID_IN (новая база цепочки) в use case. + */ +@Component +class LayinConfirmedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.layinConfirmed}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleLayinConfirmed(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt new file mode 100644 index 0000000..a8a11f8 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/LayinFailedConsumer.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase + +/** + * Kafka consumer события «не заложен»; делегирует срочную перевыдачу в use case. + */ +@Component +class LayinFailedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.layinFailed}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleLayinFailed(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt new file mode 100644 index 0000000..35802fe --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/PlanNotAcceptedConsumer.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage +import space.nstart.pcp_tgu_service.service.HandlePlanDecisionUseCase + +/** + * Kafka consumer события «не принят»; пока поведение идентично «не заложен», обработчик раздельный. + */ +@Component +class PlanNotAcceptedConsumer( + val topicsProperties: TopicsProperties, + private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.planNotAccepted}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: PlanExecutionFeedbackMessage) { + handlePlanDecisionUseCase.handleNotAccepted(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt new file mode 100644 index 0000000..dc8c5e6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/kafka/StationParamsChangedConsumer.kt @@ -0,0 +1,24 @@ +package space.nstart.pcp_tgu_service.integration.kafka + +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import space.nstart.pcp_tgu_service.config.TopicsProperties +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.service.HandleStationParamsChangedUseCase + +/** + * Kafka consumer события об изменении параметров станций; запускает перезапрос ЗРВ. + */ +@Component +class StationParamsChangedConsumer( + val topicsProperties: TopicsProperties, + private val handleStationParamsChangedUseCase: HandleStationParamsChangedUseCase +) { + @KafkaListener( + topics = ["#{__listener.topicsProperties.stationParamsChanged}"], + groupId = "\${spring.kafka.consumer.group-id}" + ) + fun consume(message: StationParamsChangedMessage) { + handleStationParamsChangedUseCase.handle(message) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt deleted file mode 100644 index 8f237e6..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlanRecalculationJobRepository.kt +++ /dev/null @@ -1,85 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import java.time.LocalDateTime - -/** Доступ к jobs перестроения расписания. */ -interface PlanRecalculationJobRepository : JpaRepository { - /** Атомарно ставит или переоткрывает coalesced recalculation job по spacecraftId. */ - @Modifying - @Query( - value = """ - INSERT INTO plan_recalculation_job( - spacecraft_id, - status, - reason, - locked_by, - locked_until, - rerun_requested, - requested_at, - updated_at - ) - VALUES ( - :spacecraftId, - 'PENDING', - :reason, - NULL, - NULL, - FALSE, - :now, - :now - ) - ON CONFLICT (spacecraft_id) DO UPDATE SET - status = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status - ELSE 'PENDING' - END, - reason = EXCLUDED.reason, - locked_by = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by - ELSE NULL - END, - locked_until = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until - ELSE NULL - END, - rerun_requested = CASE - WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE - ELSE FALSE - END, - requested_at = EXCLUDED.requested_at, - updated_at = EXCLUDED.updated_at - """, - nativeQuery = true - ) - fun upsertPending( - @Param("spacecraftId") spacecraftId: String, - @Param("reason") reason: String, - @Param("now") now: LocalDateTime - ): Int - - /** Claim pending или просроченные running jobs через row locks для горизонтального scale-out. */ - @Query( - value = """ - SELECT * - FROM plan_recalculation_job - WHERE status = 'PENDING' - OR ( - status = 'RUNNING' - AND locked_until < :now - ) - ORDER BY requested_at - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - fun findPendingForUpdate( - @Param("now") now: LocalDateTime, - @Param("limit") limit: Int - ): List -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt index c2a284f..457f462 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -8,95 +8,76 @@ import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import java.time.LocalDateTime import java.util.UUID -/** Доступ к persisted расписанию business-plan. */ +/** Доступ к persisted планам (только выданные и их история; будущее не хранится). */ interface PlannedPlanRepository : JpaRepository { - /** Ищет persisted plan по стабильному business key. */ - fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppId( + /** Возвращает планы КА в порядке старта (для read-проекции и истории). */ + fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List + + /** Планы, порождённые от данного базового плана с указанным статусом (для гарда дублей DRAFT). */ + fun findAllByBasePlanIdAndStatus(basePlanId: UUID, status: PlannedPlanStatus): List + + /** + * Планы КА в одном из статусов — для вычисления «в полёте» (ISSUING/WAITING_DECISION) и набора + * конкурентов при закладке (supersede). Заменяет опору на единственный active-слот состояния. + */ + fun findAllBySpacecraftIdAndStatusIn( + spacecraftId: String, + statuses: Collection + ): List + + /** + * Планы КА с точным совпадением окна (старт+конец) и статусом — гард на дубли черновиков: + * сочетание (КА + начало + конец) уникально среди DRAFT. + */ + fun findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus( spacecraftId: String, startTime: LocalDateTime, endTime: LocalDateTime, - kppId: String - ): PlannedPlanEntity? - - /** Возвращает планы КА в порядке старта. */ - fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List - - /** Возвращает все планы в порядке старта. */ - fun findAllByOrderByStartTime(): List - - /** Возвращает будущие planned-планы КА. */ - fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId: String, - status: PlannedPlanStatus, - startTime: LocalDateTime + status: PlannedPlanStatus ): List - /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ - fun findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId: String, - status: PlannedPlanStatus, - startTime: LocalDateTime - ): PlannedPlanEntity? + /** Возвращает все планы в порядке старта (для read-проекции и истории). */ + fun findAllByOrderByStartTime(): List - /** Атомарно создаёт или обновляет PLANNED plan по business key, сохраняя stable planId. */ + /** + * Создаёт новую строку выдаваемого плана сразу в статусе ISSUING. + * + * В on-demand-модели каждая выдача — это INSERT с новым plan_id; апсерта по бизнес-ключу нет, + * повторная выдача того же окна после негатива создаёт отдельную строку (история хранится вечно). + */ @Query( value = """ - WITH upsert AS ( - INSERT INTO planned_plan( - plan_id, - spacecraft_id, - start_time, - end_time, - kpp_id, - status, - issue_not_before, - base_plan_id, - chain_version, - created_at, - updated_at - ) - VALUES ( - :planId, - :spacecraftId, - :startTime, - :endTime, - :kppId, - 'PLANNED', - :issueNotBefore, - :basePlanId, - :chainVersion, - :now, - :now - ) - ON CONFLICT (spacecraft_id, start_time, end_time, kpp_id) DO UPDATE SET - status = CASE - WHEN planned_plan.status IN ('SUPERSEDED', 'EXPIRED') THEN 'PLANNED' - ELSE planned_plan.status - END, - issue_not_before = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.issue_not_before - ELSE planned_plan.issue_not_before - END, - base_plan_id = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.base_plan_id - ELSE planned_plan.base_plan_id - END, - chain_version = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.chain_version - ELSE planned_plan.chain_version - END, - updated_at = CASE - WHEN planned_plan.status IN ('PLANNED', 'SUPERSEDED', 'EXPIRED') THEN EXCLUDED.updated_at - ELSE planned_plan.updated_at - END - RETURNING * + INSERT INTO planned_plan( + plan_id, + spacecraft_id, + start_time, + end_time, + kpp_id, + status, + issue_not_before, + base_plan_id, + chain_version, + created_at, + updated_at ) - SELECT * - FROM upsert + VALUES ( + :planId, + :spacecraftId, + :startTime, + :endTime, + :kppId, + 'ISSUING', + :issueNotBefore, + :basePlanId, + :chainVersion, + :now, + :now + ) + RETURNING * """, nativeQuery = true ) - fun upsertPlannedByBusinessKey( + fun insertIssuingPlan( @Param("planId") planId: UUID, @Param("spacecraftId") spacecraftId: String, @Param("startTime") startTime: LocalDateTime, @@ -108,7 +89,7 @@ interface PlannedPlanRepository : JpaRepository { @Param("now") now: LocalDateTime ): PlannedPlanEntity - /** Блокирует plan для обработки decision event или issue state transition. */ + /** Блокирует plan для обработки decision/feedback event или issue state transition. */ @Query( value = """ SELECT * @@ -119,25 +100,4 @@ interface PlannedPlanRepository : JpaRepository { nativeQuery = true ) fun lockByPlanId(@Param("planId") planId: UUID): PlannedPlanEntity? - - /** Claim due plans через PostgreSQL row locks, чтобы несколько pod не выдали один plan дважды. */ - @Query( - value = """ - SELECT * - FROM planned_plan - WHERE status = 'PLANNED' - AND (issue_not_before IS NULL OR issue_not_before <= :now) - AND start_time <= :issueBefore - AND start_time > :now - ORDER BY start_time - LIMIT :limit - FOR UPDATE SKIP LOCKED - """, - nativeQuery = true - ) - fun findDueForUpdate( - @Param("now") now: LocalDateTime, - @Param("issueBefore") issueBefore: LocalDateTime, - @Param("limit") limit: Int - ): List } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt deleted file mode 100644 index 21a5d4b..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/TrackedPlanJpaRepository.kt +++ /dev/null @@ -1,46 +0,0 @@ -package space.nstart.pcp_tgu_service.repository - -/** Предоставляет методы доступа к БД для отслеживаемых планов и их состояний. */ -import space.nstart.pcp_tgu_service.entity.TrackedPlanEntity -import space.nstart.pcp_tgu_service.entity.TrackedPlanStatus -import org.springframework.data.jpa.repository.JpaRepository -import java.time.LocalDateTime -import java.util.UUID - -interface TrackedPlanJpaRepository : JpaRepository { - /** Возвращает отслеживаемые планы КА в указанном статусе. */ - fun findAllBySpacecraftIdAndStatus(spacecraftId: String, status: TrackedPlanStatus): List - - /** Возвращает все tracked-планы КА, упорядоченные по времени старта. */ - fun findAllBySpacecraftIdOrderByStartTime(spacecraftId: String): List - - /** Возвращает планы, подтверждённые с указанного момента, упорядоченные по времени старта. */ - fun findAllBySpacecraftIdAndStatusAndConfirmedAtGreaterThanEqualOrderByStartTime( - spacecraftId: String, - status: TrackedPlanStatus, - confirmedAt: LocalDateTime - ): List - - /** Возвращает отслеживаемый план по его бизнес-полям и статусу. */ - fun findBySpacecraftIdAndStartTimeAndEndTimeAndKppIdAndStatus( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String, - status: TrackedPlanStatus - ): TrackedPlanEntity? - - /** Возвращает последний tracked-план с теми же бизнес-полями независимо от статуса. */ - fun findTopBySpacecraftIdAndStartTimeAndEndTimeAndKppIdOrderByCreatedAtDesc( - spacecraftId: String, - startTime: LocalDateTime, - endTime: LocalDateTime, - kppId: String - ): TrackedPlanEntity? - - /** Возвращает последний подтверждённый отслеживаемый план для КА. */ - fun findTopBySpacecraftIdAndStatusOrderByConfirmedAtDesc( - spacecraftId: String, - status: TrackedPlanStatus - ): TrackedPlanEntity? -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt new file mode 100644 index 0000000..4740984 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCase.kt @@ -0,0 +1,182 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest +import space.nstart.pcp_tgu_service.dto.CreateChainPlanResponse +import space.nstart.pcp_tgu_service.dto.DraftMode +import space.nstart.pcp_tgu_service.dto.PlanResponse +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionBlankRequest +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * Создаёт план новой цепочки (вкладка «Планы», действие оператора «Создать план») и сразу считает + * прогноз будущей цепочки от него. + * + * Сценарий: КА что-то случилось / нужно перепланировать — оператор берёт за основу прошлый план + * («предыстория») или начинает с нуля, выбирает окно из двух реальных ЗРВ и получает план-кандидат + * (DRAFT). Кандидат не меняет состояние КА: он становится рабочим только после ручной отправки + * ([IssueDuePlanWorker.promoteDraftNow]) и приёмки (якорение цепочки в acceptPlan). + * + * Два режима наполнения: + * - **с базой** (`basePlanId != null`): клон включений прошлого плана, пересёкшихся с окном + * (тот же fork-forward-клон), `base_plan_id = basePlanId` (история тянется); + * - **без базы**: пустая миссия, `base_plan_id = null` (новая история с нуля). + * + * Атомарность как в [CreateDraftUseCase]: DRAFT сохраняется только после успешного создания миссии; + * при сбое сохранения — компенсация (удаление миссии). + */ +@Service +class CreateChainPlanUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val planProjectionService: PlanProjectionService, + private val missionPlaningClient: MissionPlaningClient, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun createChainPlan(req: CreateChainPlanRequest): CreateChainPlanResponse { + val now = LocalDateTime.now() + + if (!req.windowStop.isAfter(req.windowStart)) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "windowStop должен быть позже windowStart") + } + if (!req.windowStart.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStart (${req.windowStart}) должен быть строго в будущем" + ) + } + + // База (если задана) должна принадлежать тому же КА — иначе клон чужого содержимого. + val base: PlannedPlanEntity? = req.basePlanId?.let { basePlanId -> + val plan = plannedPlanRepository.findById(basePlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Базовый план $basePlanId не найден") } + if (plan.spacecraftId != req.spacecraftId) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Базовый план $basePlanId принадлежит другому КА (${plan.spacecraftId}, ожидался ${req.spacecraftId})" + ) + } + if (plan.status == PlannedPlanStatus.DRAFT) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Нельзя взять за основу черновик") + } + plan + } + + // Гард на дубли: сочетание (КА + начало + конец) уникально среди черновиков. Повторное создание + // того же окна не плодит второй черновик — оператор не теряется в одинаковых кандидатах. + val duplicate = plannedPlanRepository + .findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus( + req.spacecraftId, req.windowStart, req.windowStop, PlannedPlanStatus.DRAFT + ) + .firstOrNull() + if (duplicate != null) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Черновик на окно [${req.windowStart}–${req.windowStop}] для КА ${req.spacecraftId} уже есть " + + "(${duplicate.planId}) — откройте его или удалите" + ) + } + + // Окно должно совпадать с реальными ЗРВ снапшота (старт+КПП одной зоны, конец — другой). + val rawWindows = planProjectionService.rawZrvWindows(req.spacecraftId) + if (!ZrvWindowMatcher.matches(rawWindows, req.windowStart, req.windowStop, req.windowKppId)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно [${req.windowStart}–${req.windowStop}, kpp=${req.windowKppId}] не набирается из реальных ЗРВ КА ${req.spacecraftId}" + ) + } + + // Создаём миссию-носитель содержимого: клон базы в окно либо пустая миссия. + val newMissionId = if (base != null) { + missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest( + mode = DraftMode.FORK_FORWARD, + windowStart = req.windowStart, + windowStop = req.windowStop + ) + ).newMissionId + } else { + missionPlaningClient.createBlankMission( + MissionBlankRequest( + satelliteId = parseSatelliteId(req.spacecraftId), + missionStart = req.windowStart, + missionStop = req.windowStop + ) + ).newMissionId + } + + val draft = saveDraftWithCompensation(req, base, newMissionId) + + // Прогноз будущей цепочки от окна нового плана (what-if, состояние КА не тронуто). + val projected = planProjectionService + .projectAfterWindow(req.spacecraftId, draft.toCalculatedPlan()) + .map(PlanResponse::projected) + + return CreateChainPlanResponse(draftPlanId = draft.planId, projectedChain = projected) + } + + private fun saveDraftWithCompensation( + req: CreateChainPlanRequest, + base: PlannedPlanEntity?, + newMissionId: UUID + ): PlannedPlanEntity { + // Снимок версии цепочки — оптимистичный токен для promote (как в CreateDraftUseCase). + val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(req.spacecraftId) + .map { it.chainVersion } + .orElse(0L) + + return try { + val draft = plannedPlanRepository.save( + PlannedPlanEntity( + planId = newMissionId, + spacecraftId = req.spacecraftId, + startTime = req.windowStart, + endTime = req.windowStop, + kppId = req.windowKppId, + status = PlannedPlanStatus.DRAFT, + basePlanId = base?.planId, + chainVersion = baseChainVersion + ) + ) + log.info( + "Создан план новой цепочки: draftPlanId={}, basePlanId={}, spacecraftId={}, окно [{}–{}], chainVersion={}", + draft.planId, base?.planId, req.spacecraftId, req.windowStart, req.windowStop, baseChainVersion + ) + draft + } catch (ex: Exception) { + log.error( + "Сбой сохранения плана новой цепочки {} после создания миссии; компенсация (удалить миссию)", + newMissionId, ex + ) + try { + missionPlaningClient.deleteMission(newMissionId) + } catch (compEx: Exception) { + log.error("Компенсация не удалась: миссия {} осталась осиротевшей", newMissionId, compEx) + } + throw ex + } + } + + private fun parseSatelliteId(spacecraftId: String): Long = + spacecraftId.toLongOrNull() + ?: throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "spacecraftId '$spacecraftId' не числовой — нельзя создать пустую миссию без базы" + ) + + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = startTime, endTime = endTime, kppId = kppId) +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt new file mode 100644 index 0000000..e679caa --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCase.kt @@ -0,0 +1,247 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.CreateDraftResponse +import space.nstart.pcp_tgu_service.dto.DraftMode +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * Создаёт черновик плана (DRAFT) на основе существующего базового плана. + * + * Режим **replace**: базовый план ещё правим (до точки невозврата) — черновик наследует + * его окно (startTime/endTime/kppId) и предзаполняется клоном его миссии целиком. + * + * Режим **fork-forward**: базовый план невозвратен (заложен или прошло время) — черновик + * создаётся на выбранной оператором будущей ЗРВ; клон миссии содержит только включения, + * пересёкшиеся с окном новой ЗРВ. + * + * Атомарность: DRAFT-план создаётся только после успешного клона миссии; при сбое клона + * план не сохраняется — осиротевших строк нет. Если сохранение плана падает после клона — + * выполняется компенсация (удаление клона миссии). + */ +@Service +class CreateDraftUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val planProjectionService: PlanProjectionService, + private val missionPlaningClient: MissionPlaningClient, + private val planningProperties: PlanningProperties, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository +) { + private val log = LoggerFactory.getLogger(javaClass) + + fun createDraft(basePlanId: UUID, req: CreateDraftRequest): CreateDraftResponse { + val base = plannedPlanRepository.findById(basePlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $basePlanId not found") } + + val now = LocalDateTime.now() + + return when (req.mode) { + DraftMode.REPLACE -> createReplaceDraft(base, now) + DraftMode.FORK_FORWARD -> createForkForwardDraft(base, req, now) + else -> throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown draft mode: ${req.mode}") + } + } + + private fun createReplaceDraft(base: PlannedPlanEntity, now: LocalDateTime): CreateDraftResponse { + if (base.status == PlannedPlanStatus.DRAFT) { + throw ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Нельзя создать черновик от черновика") + } + if (base.status == PlannedPlanStatus.LAID_IN) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План ${base.planId} заложен (LAID_IN) — используйте fork-forward" + ) + } + val freezeAt = base.startTime.minusMinutes(planningProperties.layinFreezeMinutes) + if (!now.isBefore(freezeAt)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "До старта плана ${base.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин " + + "— окно выдачи заморожено, используйте fork-forward" + ) + } + + // Идемпотентность: один открытый черновик на слот (КА + окно). Повторный клик не плодит + // вторую миссию-клон и вторую DRAFT-строку. Окно replace-черновика 1:1 совпадает с базой. + assertNoOpenDraftForWindow(base.spacecraftId, base.startTime, base.endTime) + + val cloneResponse = missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest(mode = DraftMode.REPLACE) + ) + + // Replace-черновик — это КОРРЕКЦИЯ исходного плана: он занимает тот же слот цепочки, что и + // база, поэтому его предшественник (basePlanId) = предшественник базы (base.basePlanId), а + // НЕ сама база. Так после закладки коррекция вытеснит исходный план на том же окне, а на + // таймлайне стрелка коррекции ведёт к тому же предыдущему плану, что и у исходного. + return saveDraftWithCompensation( + draftPlanId = cloneResponse.newMissionId, + base = base, + draftBasePlanId = base.basePlanId, + startTime = base.startTime, + endTime = base.endTime, + kppId = base.kppId, + clonedMissionId = cloneResponse.newMissionId + ) + } + + private fun createForkForwardDraft( + base: PlannedPlanEntity, + req: CreateDraftRequest, + now: LocalDateTime + ): CreateDraftResponse { + val windowStart = req.windowStart + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowStart обязателен для fork-forward") + val windowStop = req.windowStop + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowStop обязателен для fork-forward") + val windowKppId = req.windowKppId + ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "windowKppId обязателен для fork-forward") + + if (!windowStart.isAfter(now)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStart ($windowStart) должен быть строго в будущем" + ) + } + if (!windowStop.isAfter(windowStart)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "windowStop должен быть позже windowStart" + ) + } + + // Валидируем, что выбранное окно соответствует реальной будущей ЗРВ из проекции. + assertWindowMatchesProjection(base.spacecraftId, windowStart, windowStop, windowKppId) + + // Идемпотентность: один открытый черновик на слот (КА + окно будущей ЗРВ). + assertNoOpenDraftForWindow(base.spacecraftId, windowStart, windowStop) + + val cloneResponse = missionPlaningClient.cloneMission( + missionId = base.planId, + req = MissionCloneRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop) + ) + + // Fork-forward — это ПРОДОЛЖЕНИЕ цепочки на будущую ЗРВ ПОСЛЕ базы (база заложена/в прошлом), + // поэтому предшественник нового плана = сама база (base.planId). + return saveDraftWithCompensation( + draftPlanId = cloneResponse.newMissionId, + base = base, + draftBasePlanId = base.planId, + startTime = windowStart, + endTime = windowStop, + kppId = windowKppId, + clonedMissionId = cloneResponse.newMissionId + ) + } + + /** + * Гард дублей: на (КА + окно) допускается не более одного открытого черновика. Заменяет прежний + * гард «один черновик на базовый план» — теперь ключ слота это окно, а не исходный план. + */ + private fun assertNoOpenDraftForWindow(spacecraftId: String, start: LocalDateTime, end: LocalDateTime) { + val existing = plannedPlanRepository + .findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus(spacecraftId, start, end, PlannedPlanStatus.DRAFT) + .firstOrNull() + if (existing != null) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "По КА $spacecraftId уже есть черновик ${existing.planId} на это окно — завершите или отмените его" + ) + } + } + + /** + * Проверяет, что тройка (windowStart, windowStop, kppId) соответствует хотя бы одному + * плану в будущей проекции КА. Точность сравнения — 1 секунда (компенсирует погрешность + * сериализации ISO-строк на фронте). + */ + private fun assertWindowMatchesProjection( + spacecraftId: String, + windowStart: LocalDateTime, + windowStop: LocalDateTime, + kppId: String + ) { + val projection = planProjectionService.project(spacecraftId) + val matched = projection.any { plan -> + plan.kppId == kppId && + diffSeconds(plan.startTime, windowStart) < 1 && + diffSeconds(plan.endTime, windowStop) < 1 + } + if (!matched) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно [$windowStart – $windowStop, kpp=$kppId] не соответствует ни одной будущей ЗРВ из проекции КА $spacecraftId" + ) + } + } + + private fun diffSeconds(a: LocalDateTime, b: LocalDateTime): Long = + Math.abs(java.time.Duration.between(a, b).toSeconds()) + + /** + * Сохраняет DRAFT-план с переданным planId (= clonedMissionId, инвариант planId==missionId). + * При сбое сохранения логирует компенсацию; удаление клона миссии — best-effort (сбой логируется). + */ + private fun saveDraftWithCompensation( + draftPlanId: UUID, + base: PlannedPlanEntity, + draftBasePlanId: UUID?, + startTime: LocalDateTime, + endTime: LocalDateTime, + kppId: String, + clonedMissionId: UUID + ): CreateDraftResponse { + // Снимок версии цепочки КА на момент создания черновика — оптимистичный токен для promote (Ф5): + // любой ACCEPT/grace-сдвиг базы инкрементит chain_version, и «Применить» по устаревшему черновику + // упадёт 409 «база уехала». chain_version у DRAFT хранит именно этот снимок (== живой при promote). + val baseChainVersion = spacecraftPlanningStateJpaRepository.findById(base.spacecraftId) + .map { it.chainVersion } + .orElse(0L) + + return try { + val draft = plannedPlanRepository.save( + PlannedPlanEntity( + planId = draftPlanId, + spacecraftId = base.spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId, + status = PlannedPlanStatus.DRAFT, + basePlanId = draftBasePlanId, + chainVersion = baseChainVersion + ) + ) + log.info( + "Создан черновик плана: draftPlanId={}, srcPlanId={}, basePlanId={}, spacecraftId={}, chainVersion={}", + draft.planId, base.planId, draftBasePlanId, base.spacecraftId, baseChainVersion + ) + CreateDraftResponse(draftPlanId = draft.planId) + } catch (ex: Exception) { + log.error( + "Сбой сохранения DRAFT-плана {} после клона миссии {}; попытка компенсации (удалить клон миссии)", + draftPlanId, clonedMissionId, ex + ) + try { + missionPlaningClient.deleteMission(clonedMissionId) + } catch (compEx: Exception) { + log.error( + "Компенсация не удалась: клон миссии {} остался осиротевшим", + clonedMissionId, compEx + ) + } + throw ex + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt new file mode 100644 index 0000000..81dba61 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCase.kt @@ -0,0 +1,57 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.util.UUID + +/** + * Отказ от черновика (Ф6): удаляет DRAFT-план и его клон-миссию. + * + * Снимает блокировку «один открытый черновик на базу» ([CreateDraftUseCase]) и подчищает + * осиротевший клон в mission-planing, если оператор начал правку, но не применил её. + * + * Порядок: сначала удаляем DRAFT-строку (локально, транзакционно), затем best-effort удаляем + * клон-миссию. При сбое удаления миссии остаётся лишь осиротевший клон (безвреден, GC позже), но + * блокирующей DRAFT-строки уже нет — оператор может сразу создать новый черновик. Обратный порядок + * рисковал бы DRAFT-строкой, ссылающейся на удалённую миссию (вечный 409 + 404 на правках). + * + * Удалять разрешено только статус DRAFT — живые/выданные планы этот путь не трогает (422). + */ +@Service +class DeleteDraftUseCase( + private val plannedPlanRepository: PlannedPlanRepository, + private val missionPlaningClient: MissionPlaningClient +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional + fun deleteDraft(draftPlanId: UUID) { + val plan = plannedPlanRepository.findById(draftPlanId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $draftPlanId not found") } + if (plan.status != PlannedPlanStatus.DRAFT) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $draftPlanId не является черновиком (status=${plan.status}) — удалить можно только DRAFT" + ) + } + plannedPlanRepository.delete(plan) + log.info("Черновик {} удалён (отказ от правок)", draftPlanId) + + // Клон миссии: planId == missionId. Best-effort — сбой удаления клона не откатывает удаление + // DRAFT-строки (блокирующей строки уже нет; осиротевший клон безвреден, GC позже). + try { + missionPlaningClient.deleteMission(draftPlanId) + } catch (ex: Exception) { + log.error( + "Черновик {} удалён, но клон миссии не удалось удалить — остался осиротевший клон", + draftPlanId, ex + ) + } + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCase.kt new file mode 100644 index 0000000..43666da --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCase.kt @@ -0,0 +1,77 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.util.UUID + +/** + * «Снять с учёта» / «Вернуть на учёт» терминально-негативный план. + * + * Оператор деактивирует отменённый/неудавшийся план, чтобы он перестал считаться проблемой и + * красить индикатор КА. Это чисто косметико-статистический флаг (`dismissed_at`): статус плана + * и состояние цепочки ([SpacecraftPlanningStateEntity]) НЕ трогаются — план остаётся в истории + * со своим терминальным статусом, цепочка не возобновляется и не паузится. Операция обратима + * ([restore]). + * + * Снимать с учёта разрешено только терминально-негативные статусы ([DISMISSIBLE_STATUSES]). Живые + * выдачи, успешный LAID_IN, DRAFT и виртуальный PLANNED этот путь не трогает (422) — у них «снятие + * с учёта» не имеет смысла. + */ +@Service +class DismissPlanUseCase( + private val plannedPlanRepository: PlannedPlanRepository +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional + fun dismiss(planId: UUID) { + val plan = plannedPlanRepository.findById(planId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $planId not found") } + if (plan.status !in DISMISSIBLE_STATUSES) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $planId в статусе ${plan.status} нельзя снять с учёта — только терминально-негативные " + + "($DISMISSIBLE_STATUSES)" + ) + } + if (plan.dismissedAt != null) { + log.info("План {} уже снят с учёта ({}) — идемпотентно", planId, plan.dismissedAt) + return + } + plan.dismissedAt = LocalDateTime.now() + plan.updatedAt = LocalDateTime.now() + plannedPlanRepository.save(plan) + log.info("План {} (status={}) снят с учёта", planId, plan.status) + } + + @Transactional + fun restore(planId: UUID) { + val plan = plannedPlanRepository.findById(planId) + .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan $planId not found") } + if (plan.dismissedAt == null) { + log.info("План {} уже на учёте — идемпотентно", planId) + return + } + plan.dismissedAt = null + plan.updatedAt = LocalDateTime.now() + plannedPlanRepository.save(plan) + log.info("План {} (status={}) возвращён на учёт", planId, plan.status) + } + + private companion object { + /** Терминально-негативные статусы, которые оператор может снять с учёта. */ + val DISMISSIBLE_STATUSES = setOf( + PlannedPlanStatus.REJECTED, + PlannedPlanStatus.START_AMBIGUOUS, + PlannedPlanStatus.LAYIN_FAILED, + PlannedPlanStatus.NOT_ACCEPTED, + PlannedPlanStatus.SUPERSEDED + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt index b61cea8..a7cf27f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandlePlanDecisionUseCase.kt @@ -1,11 +1,14 @@ package space.nstart.pcp_tgu_service.service +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity @@ -15,10 +18,18 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.Duration import java.time.LocalDateTime /** - * Обрабатывает decision events идемпотентно по eventId и строго по attemptId. + * Обрабатывает decision и закладочные feedback-события идемпотентно по eventId. + * + * - ACCEPTED — план оптимистично становится базой (AWAITING_LAYIN), цепочка продолжается. + * - REJECTED — план отклонён как неправильный: помечается терминально, авто-перевыдача НЕ + * запускается. Цепочка встаёт на паузу — оператор должен вручную создать исправленный план + * («Создать план»). Бессмысленно автоматически перевыдавать тот же план на то же окно. + * - «не заложен» / «не принят» — технический сбой доставки (содержимое не отклонялось): план + * помечается терминально, и на ближайшую ЗРВ запускается срочная перевыдача ([reissueOnNearestWindow]). */ @Service class HandlePlanDecisionUseCase( @@ -26,15 +37,15 @@ class HandlePlanDecisionUseCase( private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val plannedPlanRepository: PlannedPlanRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, - private val planRecalculationJobService: PlanRecalculationJobService, private val acceptedPlanSlotsUpdateJobService: AcceptedPlanSlotsUpdateJobService, - private val planningProperties: space.nstart.pcp_tgu_service.config.PlanningProperties + private val planningProperties: PlanningProperties ) { - /** Применяет решение; повтор того же eventId является успешным no-op. */ + private val log = LoggerFactory.getLogger(javaClass) + + /** Применяет решение по attempt; повтор того же eventId — успешный no-op. */ @Transactional fun handle(message: PlanDecisionMessage) { - val inserted = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) - if (inserted == 0) { + if (!claimEvent(message.eventId)) { return } @@ -60,18 +71,156 @@ class HandlePlanDecisionUseCase( if (attempt.status.isTerminal()) { return } - if (state.activeAttemptId != attempt.attemptId) { + // Несколько выдач по КА могут лететь одновременно (исходный план + коррекция того же окна), + // поэтому решение применяем к своему плану по его статусу, а не сверяя с единственным + // active-слотом. Если план уже терминальный (вытеснен заложенной коррекцией / отклонён) — + // запоздавшее решение просто пишем на attempt, цепочку им не двигаем. + if (plan.status !in IN_FLIGHT_STATUSES) { recordDecisionOnAttempt(message, attempt) planIssueAttemptRepository.save(attempt) return } when (message.decision) { - PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan, state) + PlanDecision.ACCEPTED -> acceptPlan(message, attempt, plan) PlanDecision.REJECTED -> rejectPlan(message, attempt, plan, state) } } + /** + * Событие «план заложен» (позитивный фидбек закладки): принятый план переходит в LAID_IN и + * становится базой цепочки. Именно закладка — а не ACCEPT — двигает якорь и chainVersion, + * поэтому до неё по одному окну могут одновременно «лететь» исходный план и его коррекция. + * Все ещё не заложенные конкуренты на пересекающееся окно гасятся в SUPERSEDED. + */ + @Transactional + fun handleLayinConfirmed(message: PlanExecutionFeedbackMessage) { + if (!claimEvent(message.eventId)) { + return + } + + val plan = plannedPlanRepository.lockByPlanId(message.planId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found") + if (plan.spacecraftId != message.spacecraftId) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Layin spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}" + ) + } + + val state = getOrCreateLockedState(message.spacecraftId) + if (plan.status != PlannedPlanStatus.AWAITING_LAYIN) { + // Закладка по плану не в статусе «ждёт закладки» (устаревшее/дубль/уже вытеснен) — игнор. + log.info("Layin-confirmed ignored: plan {} is not AWAITING_LAYIN (status={}).", plan.planId, plan.status) + return + } + + // Нельзя заложить план, если по КА уже есть заложенный (LAID_IN) со стартом в пределах + // ±[LAYIN_MIN_SEPARATION_MINUTES] минут — две закладки на практически одно и то же время + // недопустимы (один заложенный слот на окно). + val nearbyLaidIn = plannedPlanRepository + .findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, setOf(PlannedPlanStatus.LAID_IN)) + .firstOrNull { laid -> + laid.planId != plan.planId && + Duration.between(laid.startTime, plan.startTime).abs().toMinutes() < LAYIN_MIN_SEPARATION_MINUTES + } + if (nearbyLaidIn != null) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "По КА ${plan.spacecraftId} уже заложен план ${nearbyLaidIn.planId} со стартом в пределах " + + "$LAYIN_MIN_SEPARATION_MINUTES мин — закладка на то же время недопустима" + ) + } + + val now = LocalDateTime.now() + plan.status = PlannedPlanStatus.LAID_IN + plan.updatedAt = now + + // База/версия двигаются только здесь. Снимаем паузу и срочную перевыдачу — окно закрыто. + state.currentBasePlanId = plan.planId + state.chainVersion += 1 + state.urgentReissueFrom = null + state.pausedAfterReject = false + state.activePlanId = null + state.activeAttemptId = null + state.updatedAt = now + + supersedeCompetingPlans(plan, now) + + plannedPlanRepository.save(plan) + spacecraftPlanningStateJpaRepository.save(state) + + // Статусы слотов проставляем только по факту закладки на КА (LAID_IN), а не на ACCEPT: + // до закладки план ещё может быть вытеснен коррекцией, и занимать слоты рано. + acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) + } + + /** Гасит в SUPERSEDED ещё не заложенные планы КА, чьё окно пересекается с заложенным. */ + private fun supersedeCompetingPlans(laidIn: PlannedPlanEntity, now: LocalDateTime) { + val competitors = plannedPlanRepository + .findAllBySpacecraftIdAndStatusIn(laidIn.spacecraftId, LIVE_UNLAID_STATUSES) + .filter { it.planId != laidIn.planId && overlaps(it, laidIn) } + if (competitors.isEmpty()) { + return + } + for (competitor in competitors) { + competitor.status = PlannedPlanStatus.SUPERSEDED + competitor.updatedAt = now + } + plannedPlanRepository.saveAll(competitors) + log.info( + "Plan {} laid in; superseded {} competing plan(s) on overlapping window.", + laidIn.planId, competitors.size + ) + } + + private fun overlaps(a: PlannedPlanEntity, b: PlannedPlanEntity): Boolean = + a.startTime.isBefore(b.endTime) && b.startTime.isBefore(a.endTime) + + /** Событие «не заложен» по базовому плану — срочная перевыдача на ближайшую ЗРВ. */ + @Transactional + fun handleLayinFailed(message: PlanExecutionFeedbackMessage) { + applyNegativeFeedback(message, PlannedPlanStatus.LAYIN_FAILED) + } + + /** Событие «не принят» по базовому плану — пока поведение идентично «не заложен». */ + @Transactional + fun handleNotAccepted(message: PlanExecutionFeedbackMessage) { + applyNegativeFeedback(message, PlannedPlanStatus.NOT_ACCEPTED) + } + + private fun applyNegativeFeedback(message: PlanExecutionFeedbackMessage, terminalStatus: PlannedPlanStatus) { + if (!claimEvent(message.eventId)) { + return + } + + val plan = plannedPlanRepository.lockByPlanId(message.planId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan ${message.planId} was not found") + if (plan.spacecraftId != message.spacecraftId) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "Feedback spacecraftId=${message.spacecraftId} does not match plan spacecraftId=${plan.spacecraftId}" + ) + } + + val state = getOrCreateLockedState(message.spacecraftId) + if (state.currentBasePlanId != plan.planId || plan.status != PlannedPlanStatus.AWAITING_LAYIN) { + // Событие пришло не по текущему базовому плану (устарело/дубль логики) — ничего не делаем. + log.info( + "Negative feedback ignored: plan {} is not the current AWAITING_LAYIN base (status={}, base={}).", + plan.planId, plan.status, state.currentBasePlanId + ) + return + } + + val now = LocalDateTime.now() + plan.status = terminalStatus + plan.updatedAt = now + plannedPlanRepository.save(plan) + + reissueOnNearestWindow(state, plan, now) + } + private fun recordDecisionOnAttempt( message: PlanDecisionMessage, attempt: PlanIssueAttemptEntity @@ -90,27 +239,21 @@ class HandlePlanDecisionUseCase( private fun acceptPlan( message: PlanDecisionMessage, attempt: PlanIssueAttemptEntity, - plan: PlannedPlanEntity, - state: SpacecraftPlanningStateEntity + plan: PlannedPlanEntity ) { val now = LocalDateTime.now() recordDecisionOnAttempt(message, attempt) - plan.status = PlannedPlanStatus.ACCEPTED + plan.status = PlannedPlanStatus.AWAITING_LAYIN plan.acceptedAt = message.decisionTime plan.updatedAt = now - state.currentBasePlanId = plan.planId - state.activePlanId = null - state.activeAttemptId = null - state.chainVersion += 1 - state.updatedAt = now - + // ACCEPT больше НЕ запирает цепочку и не двигает базу/версию: принятый план лишь ждёт + // закладки. Базой (currentBasePlanId) и chainVersion управляет только LAID_IN + // ([handleLayinConfirmed]) — до закладки можно отправить и принять коррекцию того же окна. + // Статусы слотов тоже проставляются не здесь, а на закладке ([handleLayinConfirmed]). planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) - spacecraftPlanningStateJpaRepository.save(state) - planRecalculationJobService.enqueue(message.spacecraftId, "PLAN_ACCEPTED") - acceptedPlanSlotsUpdateJobService.enqueue(plan.planId) } private fun rejectPlan( @@ -122,44 +265,50 @@ class HandlePlanDecisionUseCase( val now = LocalDateTime.now() recordDecisionOnAttempt(message, attempt) + plan.status = PlannedPlanStatus.REJECTED + plan.updatedAt = now + + // Авто-перевыдачу НЕ взводим: REJECTED — это «план неправильный». Но при нескольких выдачах + // по КА отказ одной не должен глушить цепочку, если живёт другой кандидат (в полёте/принят, + // но не заложен) — он ещё может закрыть окно. Паузу (pausedAfterReject) ставим, только если + // других живых кандидатов не осталось: тогда проекция пуста, и оператор создаёт план вручную. + val hasOtherLiveCandidate = plannedPlanRepository + .findAllBySpacecraftIdAndStatusIn(plan.spacecraftId, LIVE_UNLAID_STATUSES) + .any { it.planId != plan.planId } state.activePlanId = null state.activeAttemptId = null + state.urgentReissueFrom = null + state.pausedAfterReject = !hasOtherLiveCandidate state.updatedAt = now - if (now < plan.startTime.minusMinutes(planningProperties.minReissueLeadMinutes)) { - plan.status = PlannedPlanStatus.PLANNED - plan.issueNotBefore = now - plan.updatedAt = now - } else { - plan.status = PlannedPlanStatus.EXPIRED - plan.updatedAt = now - promoteNextPlanOrEnqueueRebuild(plan.spacecraftId, now) - } - planIssueAttemptRepository.save(attempt) plannedPlanRepository.save(plan) spacecraftPlanningStateJpaRepository.save(state) } /** - * Decision use case только меняет state. Новую попытку позже создаст IssueDuePlanWorker. + * Общий маршрут после негатива (REJECTED/«не заложен»/«не принят»): снимает активную выдачу + * и взводит срочную перевыдачу на ближайшую ЗРВ. Базовый указатель не трогаем — проекция + * сама подберёт ближайшее окно (для принятого-и-провалившегося плана он же служит якорем цепочки). */ - private fun promoteNextPlanOrEnqueueRebuild(spacecraftId: String, now: LocalDateTime) { - val nextPlan = plannedPlanRepository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId = spacecraftId, - status = PlannedPlanStatus.PLANNED, - startTime = now + private fun reissueOnNearestWindow( + state: SpacecraftPlanningStateEntity, + failedPlan: PlannedPlanEntity, + now: LocalDateTime + ) { + state.activePlanId = null + state.activeAttemptId = null + state.urgentReissueFrom = maxOf( + now.plusMinutes(planningProperties.reissuePrepMinutes), + failedPlan.startTime ) - - if (nextPlan == null) { - planRecalculationJobService.enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") - } else { - nextPlan.issueNotBefore = now - nextPlan.updatedAt = now - plannedPlanRepository.save(nextPlan) - } + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) } + private fun claimEvent(eventId: java.util.UUID): Boolean = + processedPlanDecisionEventRepository.insertIfAbsent(eventId, LocalDateTime.now()) != 0 + private fun getOrCreateLockedState(spacecraftId: String): SpacecraftPlanningStateEntity { spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) @@ -169,4 +318,19 @@ class HandlePlanDecisionUseCase( private fun PlanIssueAttemptStatus.isTerminal(): Boolean = this != PlanIssueAttemptStatus.STARTING && this != PlanIssueAttemptStatus.STARTED + + private companion object { + /** Минимальный разнос по старту между двумя заложенными планами КА (минуты). */ + const val LAYIN_MIN_SEPARATION_MINUTES = 10L + + /** Выданный, но ещё не решённый план — его решение двигает цепочку. */ + val IN_FLIGHT_STATUSES = setOf(PlannedPlanStatus.ISSUING, PlannedPlanStatus.WAITING_DECISION) + + /** Живые, но ещё не заложенные планы КА: кандидаты на supersede и на «есть ли ещё кандидат». */ + val LIVE_UNLAID_STATUSES = setOf( + PlannedPlanStatus.ISSUING, + PlannedPlanStatus.WAITING_DECISION, + PlannedPlanStatus.AWAITING_LAYIN + ) + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt new file mode 100644 index 0000000..7583db4 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCase.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp_tgu_service.service + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime + +/** + * На изменение параметров станций перезапрашивает ЗРВ у баллистики по всем активным КА. + * + * Дальше [VisibilityRefreshWorker] обновит снапшоты, а будущая цепочка пересоберётся + * через on-demand [PlanProjectionService]. + */ +@Service +class HandleStationParamsChangedUseCase( + private val processedPlanDecisionEventRepository: ProcessedPlanDecisionEventRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val visibilityRefreshJobService: VisibilityRefreshJobService +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** Идемпотентно по eventId; ставит refresh job по всем активным КА. */ + @Transactional + fun handle(message: StationParamsChangedMessage) { + val claimed = processedPlanDecisionEventRepository.insertIfAbsent(message.eventId, LocalDateTime.now()) != 0 + if (!claimed) { + return + } + + val active = spacecraftSnapshotRepository.findAllByActiveTrue() + active.forEach { snapshot -> + visibilityRefreshJobService.enqueue(snapshot.spacecraftId, snapshot.noradId, REASON) + } + log.info( + "Station params changed (stationId={}): enqueued visibility refresh for {} active spacecraft.", + message.stationId, active.size + ) + } + + private companion object { + const val REASON = "STATION_PARAMS_CHANGED" + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt index 8f89117..98dc84f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorker.kt @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.support.TransactionTemplate import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus @@ -16,81 +17,294 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.UUID /** - * Выдаёт due plans из БД через попытки и короткий BPMN process per attempt. + * Выдаёт дозревшие планы. В on-demand-модели голова цепочки считается из снапшота ЗРВ + * ([PlanProjectionService]) и материализуется в planned_plan ровно в момент выдачи (статус ISSUING). */ @Service class IssueDuePlanWorker( private val planningProperties: PlanningProperties, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, private val plannedPlanRepository: PlannedPlanRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository, private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val planProjectionService: PlanProjectionService, private val bpmnPlanIssueProcessStarter: BpmnPlanIssueProcessStarter, private val transactionTemplate: TransactionTemplate ) { private val log = LoggerFactory.getLogger(javaClass) - /** Периодически claim'ит due plans и стартует BPMN вне DB transaction. */ + /** Периодически по активным КА считает голову проекции и выдаёт дозревший план. */ @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") fun issueDuePlans() { - val prepared = transactionTemplate.execute { prepareDuePlans(limit = 10) }.orEmpty() - prepared.forEach { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + spacecraftSnapshotRepository.findAllByActiveTrue().forEach { snapshot -> + val prepared = transactionTemplate.execute { prepareDueForSpacecraft(snapshot.spacecraftId) } + prepared?.let { preparedAttempt -> startPreparedAttempt(preparedAttempt) } + } } - /** Test-only/manual hook: создаёт новую attempt для конкретного planId и стартует её. */ + /** + * Ручная выдача конкретного persisted plan: создаёт новую attempt и стартует её. + * Гард «точки невозврата» (см. [assertReissuableInSlot]) выполняется под lock'ом состояния КА. + * Бросает 404, если плана нет; 409/422 — если выдать его нельзя. + */ fun issuePlanNow(planId: UUID): ProcessStartResponse { val prepared = transactionTemplate.execute { prepareSpecificPlan(planId) } - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found or cannot be issued") + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") return startPreparedAttempt(prepared) } - private fun prepareDuePlans(limit: Int): List { - val now = LocalDateTime.now() - val issueBefore = now.plusMinutes(planningProperties.notificationBeforeStartMinutes) - return plannedPlanRepository.findDueForUpdate(now, issueBefore, limit) - .mapNotNull { plan -> preparePlan(plan, now) } + /** + * «Применить» черновик (Ф5): промоутит DRAFT-план в живую цепочку и сразу выдаёт его. + * + * Под lock'ом состояния КА проверяет (в порядке): + * 1. план существует и действительно DRAFT (иначе 404 / 422); + * 2. **оптимистичный лок базы** — версия цепочки не изменилась с момента создания черновика + * ([assertDraftBaseUnchanged]); иначе 409 «база уехала, посмотрите дифф»; + * 3. **слот ещё валиден** — окно черновика по-прежнему соответствует реальной будущей ЗРВ + * (дрейф снапшота / перекрытие слота при replace, [assertDraftSlotStillValid]); иначе 422; + * 4. гард точки невозврата ([assertReissuableInSlot]) — не заморожено и нет активной выдачи + * другого плана; иначе 422 / 409. + * + * Затем DRAFT→ISSUING, новая attempt, и старт BPMN. Цепочка продолжается штатно: на внешний + * ACCEPT существующий acceptPlan сделает супершединг (chainVersion++). Прежние версии остаются + * в БД (откат = новый черновик из старой версии). basePlanId черновика не трогаем — это его + * провенанс (какой план он замещает/продолжает). + */ + fun promoteDraftNow(draftPlanId: UUID): ProcessStartResponse { + val prepared = transactionTemplate.execute { prepareDraftPromotion(draftPlanId) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Draft plan with planId=$draftPlanId was not found") + return startPreparedAttempt(prepared) } + /** Готовит выдачу головы проекции для КА под lock'ом состояния (scale-out safe). */ + private fun prepareDueForSpacecraft(spacecraftId: String): PreparedAttempt? { + val now = LocalDateTime.now() + val state = getOrCreateLockedState(spacecraftId) + // Форвард-цепочку не выдаём, пока по КА есть выдача «в работе»: ISSUING или WAITING_DECISION + // в пределах grace после старта. Прошедший grace больше не блокирует — молчаливо продолжаем + // (базу/версию это НЕ двигает, их двигает только LAID_IN). + if (hasBlockingInFlight(spacecraftId, now)) { + return null + } + + val projection = planProjectionService.project(spacecraftId) + if (projection.isEmpty()) { + return null + } + + val urgentFrom = state.urgentReissueFrom + val head = if (urgentFrom != null) { + projection.firstOrNull { plan -> !plan.startTime.isBefore(urgentFrom) } + } else { + projection.first() + } ?: return null + + if (!head.startTime.isAfter(now)) { + return null + } + + // Пауза после REJECTED централизована в PlanProjectionService.project(): для отклонённой + // цепочки проекция приходит пустой (выше уже сработал early-return), поэтому отдельного + // гарда «не выдавать отклонённое окно» здесь не нужно. + val issueNotBefore = head.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes) + val due = urgentFrom != null || !now.isBefore(issueNotBefore) + if (!due) { + return null + } + + val basePlanId = latestCommittedPlanId(spacecraftId, state.currentBasePlanId) + val plan = materializeIssuingPlan(head, basePlanId, state.chainVersion, issueNotBefore, now) + val attempt = createStartingAttempt(plan.planId, now) + + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + /** + * По КА есть выдача «в работе», блокирующая авто-выдачу следующего окна форвард-цепочки: + * ISSUING (только что выдан, решения ещё нет) или WAITING_DECISION в пределах + * [PlanningProperties.waitingDecisionGraceMinutes] после старта. WAITING_DECISION за grace + * больше не блокирует — молчаливо продолжаем цепочку (статус плана не меняем; базу/версию + * двигает только LAID_IN). Несколько выдач по КА (исходный план + коррекция) допустимы — + * блокирует наличие хотя бы одной «в работе». + */ + private fun hasBlockingInFlight(spacecraftId: String, now: LocalDateTime): Boolean = + plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, BLOCKING_INFLIGHT_STATUSES).any { plan -> + plan.status == PlannedPlanStatus.ISSUING || + now.isBefore(plan.startTime.plusMinutes(planningProperties.waitingDecisionGraceMinutes)) + } + + /** + * planId самого дальнего (по endTime) зафиксированного окна КА — провенанс (basePlanId) для + * следующего выданного плана форвард-цепочки. Включает ещё не заложенные выдачи, чтобы цепочка + * связывалась с непосредственно предыдущим окном, а не с последней заложенной базой. + */ + private fun latestCommittedPlanId(spacecraftId: String, fallback: UUID?): UUID? = + plannedPlanRepository.findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES) + .maxByOrNull { plan -> plan.endTime }?.planId ?: fallback + private fun prepareSpecificPlan(planId: UUID): PreparedAttempt? { val now = LocalDateTime.now() val plan = plannedPlanRepository.findById(planId).orElse(null) ?: return null - if (plan.status !in setOf(PlannedPlanStatus.PLANNED, PlannedPlanStatus.REJECTED)) { - return null - } - return preparePlan(plan, now) + val state = getOrCreateLockedState(plan.spacecraftId) + assertReissuableInSlot(plan, now) + + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + val attempt = createStartingAttempt(plan.planId, now) + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + // Оператор вручную выдал план — снимаем паузу после отказа (если была). + state.pausedAfterReject = false + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + return PreparedAttempt(plan.planId, attempt.attemptId) } - private fun preparePlan(plan: PlannedPlanEntity, now: LocalDateTime): PreparedAttempt? { - val state = getOrCreateLockedState(plan.spacecraftId) - if (state.activeAttemptId != null) { - return null + private fun prepareDraftPromotion(draftPlanId: UUID): PreparedAttempt? { + val now = LocalDateTime.now() + val plan = plannedPlanRepository.findById(draftPlanId).orElse(null) ?: return null + if (plan.status != PlannedPlanStatus.DRAFT) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План $draftPlanId не является черновиком (status=${plan.status}) — «Применить» доступно только для DRAFT" + ) } - val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)?.attemptNo ?: 0) + 1 - val attempt = planIssueAttemptRepository.save( + val state = getOrCreateLockedState(plan.spacecraftId) + assertDraftBaseUnchanged(plan, state) + assertDraftSlotStillValid(plan) + assertReissuableInSlot(plan, now) + + plan.status = PlannedPlanStatus.ISSUING + plan.updatedAt = now + plannedPlanRepository.save(plan) + + val attempt = createStartingAttempt(plan.planId, now) + state.activePlanId = plan.planId + state.activeAttemptId = attempt.attemptId + state.urgentReissueFrom = null + // Оператор отправил исправленный черновик — снимаем паузу после отказа (если была). + state.pausedAfterReject = false + state.updatedAt = now + spacecraftPlanningStateJpaRepository.save(state) + + log.info( + "Черновик {} применён (promote): spacecraft={}, окно [{}–{}], chainVersion={}", + plan.planId, plan.spacecraftId, plan.startTime, plan.endTime, plan.chainVersion + ) + return PreparedAttempt(plan.planId, attempt.attemptId) + } + + /** + * Оптимистичный лок базы для promote: версия цепочки, снятая при создании черновика + * ([PlannedPlanEntity.chainVersion] у DRAFT), должна совпадать с текущей версией цепочки КА. + * Любой ACCEPT/grace-сдвиг базы между созданием черновика и «Применить» инкрементит версию — + * тогда черновик строился от устаревшей базы и его нужно пересоздать (посмотрев дифф). + */ + private fun assertDraftBaseUnchanged(plan: PlannedPlanEntity, state: SpacecraftPlanningStateEntity) { + if (plan.chainVersion != state.chainVersion) { + throw ResponseStatusException( + HttpStatus.CONFLICT, + "База КА ${plan.spacecraftId} изменилась с момента создания черновика ${plan.planId} " + + "(цепочка v${plan.chainVersion} → v${state.chainVersion}) — посмотрите дифф и пересоздайте черновик" + ) + } + } + + /** + * Контроль перекрытия слота / дрейфа снапшота ЗРВ на «Применить»: окно черновика должно + * по-прежнему набираться из реальных ЗРВ снапшота. Сверка против «сырых» окон ЗРВ единым + * правилом [ZrvWindowMatcher] (начало+КПП одной зоны, конец — другой) — оно покрывает и окно + * из одной ЗРВ (replace/fork-forward), и окно из двух ЗРВ (план новой цепочки). Не набирается → + * снапшот сместился → 422. + */ + private fun assertDraftSlotStillValid(plan: PlannedPlanEntity) { + val rawWindows = planProjectionService.rawZrvWindows(plan.spacecraftId) + if (!ZrvWindowMatcher.matches(rawWindows, plan.startTime, plan.endTime, plan.kppId)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "Окно черновика ${plan.planId} [${plan.startTime}–${plan.endTime}, kpp=${plan.kppId}] " + + "больше не набирается из реальных ЗРВ — снапшот сместился, пересоздайте черновик" + ) + } + } + + /** + * Гард ручной replace-выдачи в свой слот (см. дизайн «точка невозврата»). Бросает [ResponseStatusException], + * если выдать план нельзя. Разрешено только когда выполнены ВСЕ условия: + * - план не подтверждён как заложенный (`status != LAID_IN` — иначе 422, доступен только fork-forward); + * - до старта плана больше [PlanningProperties.layinFreezeMinutes] минут (иначе 422 — окно заморожено). + * + * Гарда «по КА уже идёт выдача другого плана» больше НЕТ: несколько выдач по КА одновременно — + * норма (исходный план + коррекция того же окна). Конкуренция разрешается на закладке: ушедший + * в LAID_IN план гасит остальные в SUPERSEDED ([HandlePlanDecisionUseCase.handleLayinConfirmed]). + * + * Узкого списка «перевыдаваемых» статусов нет: правим в т.ч. ACCEPTED/AWAITING_LAYIN, пока план + * в будущем за порогом заморозки и не заложен. + */ + private fun assertReissuableInSlot(plan: PlannedPlanEntity, now: LocalDateTime) { + if (plan.status == PlannedPlanStatus.LAID_IN) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "План ${plan.planId} уже заложен (LAID_IN) — перевыдача в свой слот невозможна, используйте fork-forward" + ) + } + val freezeAt = plan.startTime.minusMinutes(planningProperties.layinFreezeMinutes) + if (!now.isBefore(freezeAt)) { + throw ResponseStatusException( + HttpStatus.UNPROCESSABLE_ENTITY, + "До старта плана ${plan.planId} осталось меньше ${planningProperties.layinFreezeMinutes} мин — " + + "окно выдачи заморожено (точка невозврата), используйте fork-forward" + ) + } + } + + private fun materializeIssuingPlan( + head: CalculatedPlan, + basePlanId: UUID?, + chainVersion: Long, + issueNotBefore: LocalDateTime, + now: LocalDateTime + ): PlannedPlanEntity = + plannedPlanRepository.insertIssuingPlan( + planId = UUID.randomUUID(), + spacecraftId = head.spacecraftId, + startTime = head.startTime, + endTime = head.endTime, + kppId = head.kppId, + issueNotBefore = issueNotBefore, + basePlanId = basePlanId, + chainVersion = chainVersion, + now = now + ) + + private fun createStartingAttempt(planId: UUID, now: LocalDateTime): PlanIssueAttemptEntity { + val attemptNo = (planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId)?.attemptNo ?: 0) + 1 + return planIssueAttemptRepository.save( PlanIssueAttemptEntity( attemptId = UUID.randomUUID(), - planId = plan.planId, + planId = planId, attemptNo = attemptNo, status = PlanIssueAttemptStatus.STARTING, createdAt = now, updatedAt = now ) ) - - plan.status = PlannedPlanStatus.ISSUING - plan.updatedAt = now - plannedPlanRepository.save(plan) - - state.activePlanId = plan.planId - state.activeAttemptId = attempt.attemptId - state.updatedAt = now - spacecraftPlanningStateJpaRepository.save(state) - - return PreparedAttempt(plan.planId, attempt.attemptId) } private fun startPreparedAttempt(preparedAttempt: PreparedAttempt): ProcessStartResponse { @@ -159,4 +373,20 @@ class IssueDuePlanWorker( val planId: UUID, val attemptId: UUID ) + + private companion object { + /** Выдачи «в работе», по которым решение ещё ожидается (для гейта авто-выдачи). */ + val BLOCKING_INFLIGHT_STATUSES = setOf( + PlannedPlanStatus.ISSUING, + PlannedPlanStatus.WAITING_DECISION + ) + + /** Зафиксированные окна КА (выданы/приняты/заложены) — источник провенанса форвард-цепочки. */ + val COMMITTED_STATUSES = setOf( + PlannedPlanStatus.ISSUING, + PlannedPlanStatus.WAITING_DECISION, + PlannedPlanStatus.AWAITING_LAYIN, + PlannedPlanStatus.LAID_IN + ) + } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index d086947..65cea70 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -9,36 +9,73 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import java.time.Duration import java.time.LocalDateTime +/** + * Строит цепочку планов по окнам видимости КА. + * + * Главный инвариант цепочки: у соседних планов строго возрастают и старт, и конец + * (`start[i] < start[i+1]` и `end[i] < end[i+1]`). Отсюда математически следует, что + * ни один план не может лежать целиком внутри другого. Перекрытие сохраняется: следующий + * план стартует на ЗРВ, попадающей внутрь предыдущего плана. + * + * Окна разных станций не сливаются — старт плана привязан к конкретной ЗРВ один к одному. + */ @Service class PlanCalculationService( private val planningProperties: PlanningProperties ) { - /** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */ - private val overlapShiftByWindows: Int = 1 + /** + * Возвращает «сырые» окна ЗРВ КА (наблюдательные окна точек закладки как есть), без построения + * цепочки и без подбора целевой длительности: одно окно = один [CalculatedPlan] `[startTime, endTime]` + * с `kppId = pointId`. Из этих окон оператор вручную набирает план новой цепочки (старт и конец — + * границы реальных ЗРВ), и против них же валидируется выбранное окно. + */ + fun rawWindows(spacecraftPoints: SpacecraftPoints): List = + buildCandidates(spacecraftPoints).map { candidate -> + candidate.toCalculatedPlan(spacecraftPoints.spacecraftId, candidate.endTime) + } /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) - return calculatePlansFromCandidates( + return buildChain( spacecraftId = spacecraftPoints.spacecraftId, candidates = candidates, - initialStartIndex = 0 + fromStartIndex = 0, + initialLowerEndBound = null ) } /** - * Строит часть цепочки после уже известного плана. + * Строит часть цепочки после уже известного (принятого) плана. * - * Если предыдущий план удалось сопоставить с текущими окнами наблюдения, - * следующий план начинается с нахлестом на одну ЗРВ. - * Если сопоставление не удалось, используется безопасный fallback без нахлеста. + * Первый новый план обязан стартовать строго внутри принятого плана (нахлёст без разрыва) + * и заканчиваться строго позже его конца (без вложенности). Если предыдущий план удалось + * сопоставить с текущими окнами — продолжаем с нахлестом на одну ЗРВ; иначе берём последнюю + * ЗРВ строго внутри принятого плана. Разрыв между принятым планом и хвостом не допускается. */ fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) + + if (candidates.isEmpty()) { + return emptyList() + } + + // Базовый (принятый) план полностью в прошлом относительно доступных окон — он + // больше не ограничивает будущее (нахлёст с ним невозможен и не нужен). Строим + // свежую цепочку с самого раннего окна. Иначе проекция схлопнулась бы в пустоту. + if (previousPlan.endTime <= candidates.first().startTime) { + return buildChain( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + fromStartIndex = 0, + initialLowerEndBound = null + ) + } + /** Индекс кандидата, с которого начинался предыдущий план. */ val previousStartIndex: Int = candidates.indexOfFirst { candidate -> candidate.pointId == previousPlan.kppId && candidate.startTime == previousPlan.startTime @@ -52,27 +89,45 @@ class PlanCalculationService( ?.index ?: -1 - /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ - val initialStartIndex: Int = if ( - previousStartIndex >= 0 && - previousEndIndex > previousStartIndex - ) { + /** Старт хвоста при удачном сопоставлении базового плана с текущими окнами. */ + val matchedStartIndex: Int = if (previousStartIndex >= 0 && previousEndIndex > previousStartIndex) { resolveNextStartIndex( + candidates = candidates, currentStartIndex = previousStartIndex, endIndex = previousEndIndex ) } else { - candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime } + previousStartIndex } - if (initialStartIndex == -1) { - return emptyList() + /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ + val fromStartIndex: Int = if (matchedStartIndex > previousStartIndex) { + matchedStartIndex + } else { + // База не сопоставилась или нахлёст по её окнам невозможен: стартуем с последней ЗРВ + // строго внутри принятого плана — нахлёст с базой без разрыва и без вложенности. + candidates.indices.lastOrNull { index -> + candidates[index].startTime > previousPlan.startTime && + candidates[index].startTime < previousPlan.endTime + } ?: -1 } - return calculatePlansFromCandidates( + if (fromStartIndex !in candidates.indices) { + // База перекрывается с окнами, но якорь хвоста подобрать не удалось — не + // схлопываемся в пустоту, а строим свежую цепочку с самого раннего окна. + return buildChain( + spacecraftId = spacecraftPoints.spacecraftId, + candidates = candidates, + fromStartIndex = 0, + initialLowerEndBound = null + ) + } + + return buildChain( spacecraftId = spacecraftPoints.spacecraftId, candidates = candidates, - initialStartIndex = initialStartIndex + fromStartIndex = fromStartIndex, + initialLowerEndBound = previousPlan.endTime ) } @@ -96,7 +151,8 @@ class PlanCalculationService( /** Индекс лучшего кандидата окончания для ближайшей закладки. */ val endIndex: Int = findEndCandidateIndexOrNull( candidates = candidates, - startIndex = nearestStartIndex + startIndex = nearestStartIndex, + lowerEndBound = null ) ?: return null return candidates[nearestStartIndex].toCalculatedPlan( @@ -105,37 +161,49 @@ class PlanCalculationService( ) } - /** Преобразует отсортированных кандидатов в последовательную цепочку планов. */ - private fun calculatePlansFromCandidates( + /** + * Строит последовательную цепочку планов, начиная с `fromStartIndex`. + * + * Поддерживает инвариант строгого роста старта и конца: следующий план обязан + * стартовать строго позже и заканчиваться строго позже предыдущего, поэтому ни один + * план не оказывается внутри другого. + */ + private fun buildChain( spacecraftId: String, candidates: List, - initialStartIndex: Int + fromStartIndex: Int, + initialLowerEndBound: LocalDateTime? ): List { - if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) { + if (candidates.isEmpty() || fromStartIndex !in candidates.indices || fromStartIndex >= candidates.lastIndex) { return emptyList() } /** Результирующая цепочка планов. */ val resultPlans: MutableList = mutableListOf() /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */ - var currentStartIndex: Int = initialStartIndex + var currentStartIndex: Int = fromStartIndex + /** Конец последнего добавленного плана — нижняя граница для конца следующего. */ + var lowerEndBound: LocalDateTime? = initialLowerEndBound while (currentStartIndex < candidates.lastIndex) { /** Текущий кандидат старта плана. */ val currentCandidate: StartCandidate = candidates[currentStartIndex] - /** Индекс найденного кандидата окончания плана. */ + /** Индекс найденного кандидата окончания плана со строго растущим концом. */ val endIndex: Int = findEndCandidateIndexOrNull( candidates = candidates, - startIndex = currentStartIndex + startIndex = currentStartIndex, + lowerEndBound = lowerEndBound ) ?: break resultPlans += currentCandidate.toCalculatedPlan( spacecraftId = spacecraftId, endTime = candidates[endIndex].endTime ) + lowerEndBound = candidates[endIndex].endTime /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */ val nextStartIndex: Int = resolveNextStartIndex( + candidates = candidates, currentStartIndex = currentStartIndex, endIndex = endIndex ) @@ -162,7 +230,7 @@ class PlanCalculationService( ) } } - .sortedBy { candidate -> candidate.startTime } + .sortedWith(compareBy({ candidate -> candidate.startTime }, { candidate -> candidate.endTime })) /** Возвращает целевую длительность плана из настроек. */ private fun targetDuration(): Duration = @@ -173,31 +241,73 @@ class PlanCalculationService( Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) /** - * Возвращает безопасный индекс старта следующего плана. + * Возвращает индекс старта следующего плана так, чтобы он гарантированно стартовал + * строго внутри текущего плана (нахлёст без разрыва). * - * Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего. - * Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат, - * чтобы не зациклиться и не повторить тот же самый план. + * Сначала пробуем нахлёст на одну ЗРВ: самая поздняя ЗРВ строго раньше конечной ЗРВ + * текущего плана. Если такой нет — берём самую позднюю ЗРВ, начинающуюся строго раньше + * конца текущего плана (нахлёст всё равно сохраняется). Нахлёст считается по времени, а не + * по плоскому индексу, поэтому устойчив к тому, что одну ЗРВ видят несколько станций сразу. + * + * Если ни одна ЗРВ не начинается внутри текущего плана, продолжить цепочку без разрыва + * невозможно — возвращаем `currentStartIndex`, и вызывающий обрывает цепочку. */ - private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int = - maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows) + private fun resolveNextStartIndex( + candidates: List, + currentStartIndex: Int, + endIndex: Int + ): Int { + /** Время старта текущего плана. */ + val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime + /** Время конца текущего плана. */ + val currentEndTime: LocalDateTime = candidates[endIndex].endTime + /** Время старта конечной ЗРВ текущего плана. */ + val endStartTime: LocalDateTime = candidates[endIndex].startTime + + /** Предпочтительный нахлёст: последняя ЗРВ строго перед конечной ЗРВ. */ + val oneWindowOverlap: Int? = candidates.indices.lastOrNull { index -> + candidates[index].startTime > currentStartTime && + candidates[index].startTime < endStartTime + } + if (oneWindowOverlap != null) { + return oneWindowOverlap + } + + /** Запасной нахлёст: последняя ЗРВ, начинающаяся строго раньше конца текущего плана. */ + val anyOverlap: Int? = candidates.indices.lastOrNull { index -> + candidates[index].startTime > currentStartTime && + candidates[index].startTime < currentEndTime + } + if (anyOverlap != null) { + return anyOverlap + } + + // Нахлёст невозможен — обрываем цепочку, чтобы не было разрыва между планами. + return currentStartIndex + } /** * Находит лучший конечный кандидат для плана, начинающегося с `startIndex`. * - * Сначала сервис старается попасть в целевое окно `target ± delta`. - * Если это невозможно, он всё равно обязан построить план и берёт - * любой доступный конец, наиболее близкий к целевой длительности. + * Рассматриваются только концы строго позже старта плана и строго позже конца предыдущего + * плана (`lowerEndBound`) — это гарантирует строгий рост конца цепочки и отсутствие вложенности. + * Сначала сервис старается попасть в целевое окно `target ± delta`; если это невозможно, + * берётся любой допустимый конец, наиболее близкий к целевой длительности. */ private fun findEndCandidateIndexOrNull( candidates: List, - startIndex: Int + startIndex: Int, + lowerEndBound: LocalDateTime? ): Int? { /** Кандидат, с которого начинается рассматриваемый план. */ val startCandidate: StartCandidate = candidates[startIndex] - /** Все доступные кандидаты окончания после выбранного старта. */ + /** Допустимые кандидаты окончания: позже старта, позже предыдущего конца. */ val allEndCandidates: List> = candidates.withIndex() - .filter { indexedCandidate -> indexedCandidate.index > startIndex } + .filter { indexedCandidate -> + indexedCandidate.index > startIndex && + indexedCandidate.value.endTime > startCandidate.startTime && + (lowerEndBound == null || indexedCandidate.value.endTime > lowerEndBound) + } if (allEndCandidates.isEmpty()) { return null @@ -239,8 +349,8 @@ class PlanCalculationService( /** * Выбирает лучший конец по минимальному отклонению от целевой длительности. * - * При равной близости предпочтение отдаётся более длинному плану, - * чтобы не занижать результат без необходимости. + * При равной близости предпочтение отдаётся более длинному плану, а затем более раннему концу, + * чтобы выбор был детерминированным. */ private fun selectPreferredEndCandidate( candidates: List>, @@ -257,6 +367,8 @@ class PlanCalculationService( ) }.thenByDescending { candidate -> Duration.between(startTime, candidate.value.endTime) + }.thenBy { candidate -> + candidate.value.endTime } ) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt new file mode 100644 index 0000000..dd5ded9 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionService.kt @@ -0,0 +1,117 @@ +package space.nstart.pcp_tgu_service.service + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository + +/** + * Считает будущую цепочку планов КА на лету из последнего снапшота ЗРВ и текущей базы. + * + * Проекция нигде не сохраняется: это детерминированная функция от (снапшот + база + конфиг). + * Её используют read-путь (UI) и воркер выдачи, чтобы понять, какой план выдавать следующим. + */ +@Service +class PlanProjectionService( + private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, + private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, + private val plannedPlanRepository: PlannedPlanRepository, + private val visibilityPayloadParser: VisibilityPayloadParser, + private val planCalculationService: PlanCalculationService +) { + /** Полная проекция будущей цепочки для КА; пусто, если нет снапшота или окон. */ + @Transactional(readOnly = true) + fun project(spacecraftId: String): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + + val state = spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) + + // Пауза после REJECTED: цепочка стоит, пока оператор вручную не создаст исправленный план + // (ручная выдача/промоут снимут флаг). Будущего автоматически быть не может — отдаём пусто. + // Это единый источник паузы: воркер выдачи получает пустую проекцию и ничего не материализует. + if (state?.pausedAfterReject == true) { + return emptyList() + } + + val anchorPlan = resolveAnchorPlan(spacecraftId, state) + return if (anchorPlan == null) { + planCalculationService.calculatePlans(points) + } else { + planCalculationService.calculatePlansAfter(points, anchorPlan.toCalculatedPlan()) + } + } + + /** + * «Сырые» окна ЗРВ КА из последнего снапшота — границы реальных зон, из которых оператор + * вручную набирает окно нового плана. Пусто, если снапшота нет. В отличие от [project] это + * не цепочка, а исходные окна (без подбора длительности и нахлёста). + */ + @Transactional(readOnly = true) + fun rawZrvWindows(spacecraftId: String): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + return planCalculationService.rawWindows(points) + } + + /** + * Прогноз будущей цепочки, если новый план занял окно [afterPlan]: считается тем же + * [PlanCalculationService.calculatePlansAfter] поверх текущего снапшота ЗРВ, но якорь — не + * состояние КА, а переданное окно. Используется при создании плана новой цепочки, чтобы сразу + * показать оператору, как ляжет цепочка «от него». Состояние КА не трогается (what-if). + */ + @Transactional(readOnly = true) + fun projectAfterWindow(spacecraftId: String, afterPlan: CalculatedPlan): List { + val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId).orElse(null) + ?: return emptyList() + val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) + return planCalculationService.calculatePlansAfter(points, afterPlan) + } + + /** + * Якорь, после которого строится будущая цепочка: самое дальнее (по endTime) зафиксированное + * окно КА среди ISSUING/WAITING_DECISION/AWAITING_LAYIN/LAID_IN. + * + * Берём именно крайнее зафиксированное окно (а не «принятую базу»), потому что выдач по КА может + * быть несколько одновременно (исходный план + коррекция), и базой цепочка становится только на + * закладке (LAID_IN). Якорение на крайнее окно не даёт проекции заново вывести уже выданный слот + * и нарисовать его призрак-дубль PLANNED при дрейфе снапшота ЗРВ. Если зафиксированных окон нет — + * откатываемся на `currentBasePlanId` (последняя заложенная база), иначе строим с нуля. + */ + private fun resolveAnchorPlan(spacecraftId: String, state: SpacecraftPlanningStateEntity?): PlannedPlanEntity? { + val committed = plannedPlanRepository + .findAllBySpacecraftIdAndStatusIn(spacecraftId, COMMITTED_STATUSES) + .maxByOrNull { plan -> plan.endTime } + if (committed != null) { + return committed + } + return state?.currentBasePlanId + ?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } + } + + private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = + CalculatedPlan( + spacecraftId = spacecraftId, + startTime = startTime, + endTime = endTime, + kppId = kppId + ) + + private companion object { + /** Зафиксированные окна КА (выданы/приняты/заложены) — кандидаты в якорь будущей цепочки. */ + val COMMITTED_STATUSES = setOf( + PlannedPlanStatus.ISSUING, + PlannedPlanStatus.WAITING_DECISION, + PlannedPlanStatus.AWAITING_LAYIN, + PlannedPlanStatus.LAID_IN + ) + } +} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt index 8ae5027..0dc80cf 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryService.kt @@ -4,32 +4,69 @@ import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.PlanResponse import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.UUID /** - * Read-only query service для persisted planned_plan. + * Read-сервис планов: объединяет persisted (выданные, история — вечно) с вычисленной + * на лету проекцией будущего ([PlanProjectionService]). Проекция в БД не пишется. */ @Service class PlanQueryService( - private val plannedPlanRepository: PlannedPlanRepository + private val plannedPlanRepository: PlannedPlanRepository, + private val spacecraftSnapshotRepository: SpacecraftSnapshotRepository, + private val planProjectionService: PlanProjectionService ) { - /** Возвращает все persisted планы с отсечением глубокой истории. */ + /** Все планы (persisted ∪ проекция) с отсечением глубокой истории по `historyDays`. */ @Transactional(readOnly = true) - fun getAllPlans(historyDays: Long): List { - val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) - return plannedPlanRepository.findAllByOrderByStartTime() - .filter { plan -> plan.endTime >= historySince } + fun getAllPlans(historyDays: Long): List { + val historySince = historySince(historyDays) + val persisted = plannedPlanRepository.findAllByOrderByStartTime() + .filter { plan -> plan.endTime >= historySince && plan.status != PlannedPlanStatus.DRAFT } + val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } + + val projected = spacecraftSnapshotRepository.findAllByActiveTrue() + .flatMap { snapshot -> planProjectionService.project(snapshot.spacecraftId) } + .filter { plan -> plan.businessKey() !in persistedKeys } + + return combine(persisted, projected) } - /** Возвращает persisted планы одного КА с отсечением глубокой истории. */ + /** Планы одного КА (persisted ∪ проекция) с отсечением глубокой истории. */ @Transactional(readOnly = true) - fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { - val historySince = LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) - return plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) - .filter { plan -> plan.endTime >= historySince } + fun getPlansBySpacecraftId(spacecraftId: String, historyDays: Long): List { + val historySince = historySince(historyDays) + val persisted = plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) + .filter { plan -> plan.endTime >= historySince && plan.status != PlannedPlanStatus.DRAFT } + val persistedKeys = persisted.mapTo(HashSet()) { plan -> plan.businessKey() } + + val projected = planProjectionService.project(spacecraftId) + .filter { plan -> plan.businessKey() !in persistedKeys } + + return combine(persisted, projected) + } + + /** + * Черновики (DRAFT) — отдельным чтением, т.к. основной read-path их намеренно отсекает. + * Для режима «Черновики» во вкладке «Планы»: список созданных, но ещё не отправленных + * планов-кандидатов (из диалога новой цепочки или из редактора). Опционально по одному КА. + */ + @Transactional(readOnly = true) + fun getDraftPlans(spacecraftId: String?): List { + val plans = if (spacecraftId != null) { + plannedPlanRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId) + } else { + plannedPlanRepository.findAllByOrderByStartTime() + } + return plans + .filter { plan -> plan.status == PlannedPlanStatus.DRAFT } + .map(PlanResponse::from) } /** Возвращает persisted plan по ID для test-only/debug API. */ @@ -37,4 +74,17 @@ class PlanQueryService( fun getByPlanId(planId: UUID): PlannedPlanEntity = plannedPlanRepository.findById(planId) .orElseThrow { ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") } + + private fun combine(persisted: List, projected: List): List = + (persisted.map(PlanResponse::from) + projected.map(PlanResponse::projected)) + .sortedBy { response -> response.startTime } + + private fun historySince(historyDays: Long): LocalDateTime = + LocalDateTime.now().minusDays(historyDays.coerceAtLeast(0)) + + private fun PlannedPlanEntity.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" + + private fun CalculatedPlan.businessKey(): String = + "$spacecraftId|$startTime|$endTime|$kppId" } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt deleted file mode 100644 index 1a59b39..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationJobService.kt +++ /dev/null @@ -1,20 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import java.time.LocalDateTime - -/** - * Coalesce-сервис для plan_recalculation_job по spacecraftId. - */ -@Service -class PlanRecalculationJobService( - private val planRecalculationJobRepository: PlanRecalculationJobRepository -) { - /** Ставит или переоткрывает job перестроения расписания без дублей. */ - @Transactional - fun enqueue(spacecraftId: String, reason: String) { - planRecalculationJobRepository.upsertPending(spacecraftId, reason, LocalDateTime.now()) - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt deleted file mode 100644 index a5879e2..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanRecalculationWorker.kt +++ /dev/null @@ -1,85 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.slf4j.LoggerFactory -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Service -import org.springframework.transaction.support.TransactionTemplate -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.entity.JobStatus -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import java.net.InetAddress -import java.time.LocalDateTime - -/** - * Claim'ит jobs перестроения расписания и вызывает RebuildPlanScheduleUseCase. - */ -@Service -class PlanRecalculationWorker( - private val planningProperties: PlanningProperties, - private val planRecalculationJobRepository: PlanRecalculationJobRepository, - private val rebuildPlanScheduleUseCase: RebuildPlanScheduleUseCase, - private val transactionTemplate: TransactionTemplate -) { - private val log = LoggerFactory.getLogger(javaClass) - private val workerId = "${InetAddress.getLocalHost().hostName}-${java.util.UUID.randomUUID()}" - - /** Периодически обрабатывает pending recalculation jobs. */ - @Scheduled(fixedDelayString = "\${planning.notification-check-fixed-delay-ms}") - fun processPendingJobs() { - val jobs = claimJobs(limit = 10) - jobs.forEach { job -> processJob(job.spacecraftId) } - } - - private fun claimJobs(limit: Int): List = - transactionTemplate.execute { - val now = LocalDateTime.now() - val jobs = planRecalculationJobRepository.findPendingForUpdate(now, limit) - jobs.forEach { job -> - job.status = JobStatus.RUNNING - job.lockedBy = workerId - job.lockedUntil = now.plusSeconds(planningProperties.workerLockTtlSeconds) - job.attempts += 1 - job.updatedAt = now - } - planRecalculationJobRepository.saveAll(jobs) - jobs - }.orEmpty() - - private fun processJob(spacecraftId: String) { - try { - rebuildPlanScheduleUseCase.rebuild(spacecraftId) - markDone(spacecraftId) - } catch (ex: Exception) { - log.warn("Plan recalculation job failed. spacecraftId={}", spacecraftId, ex) - releaseOrFail(spacecraftId) - } - } - - private fun markDone(spacecraftId: String) { - transactionTemplate.executeWithoutResult { - val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult - job.status = if (job.rerunRequested) JobStatus.PENDING else JobStatus.DONE - job.rerunRequested = false - job.lockedBy = null - job.lockedUntil = null - job.updatedAt = LocalDateTime.now() - planRecalculationJobRepository.save(job) - } - } - - private fun releaseOrFail(spacecraftId: String) { - transactionTemplate.executeWithoutResult { - val job = planRecalculationJobRepository.findById(spacecraftId).orElse(null) ?: return@executeWithoutResult - job.status = if (job.attempts >= MAX_ATTEMPTS) JobStatus.FAILED else JobStatus.PENDING - job.lockedBy = null - job.lockedUntil = null - job.updatedAt = LocalDateTime.now() - planRecalculationJobRepository.save(job) - } - } - - private companion object { - const val MAX_ATTEMPTS = 3 - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt index 96fc185..f2a91dc 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt @@ -46,8 +46,11 @@ class PlatformService( /** Replaces the in-memory platform cache from a raw NSI response payload for test-only manual runs. */ fun loadPlatformsFromNsiPayload(payloadJson: String): List = + loadPlatformsFromNsiPayload(objectMapper.readValue(payloadJson, Array::class.java).toList()) + + /** Replaces the in-memory platform cache from an already-parsed NSI payload for test-only manual runs. */ + fun loadPlatformsFromNsiPayload(platforms: List): List = synchronized(cacheLock) { - val platforms = objectMapper.readValue(payloadJson, Array::class.java).toList() cachePlatforms(platforms, Instant.now(clock)) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt deleted file mode 100644 index dc6bfa0..0000000 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt +++ /dev/null @@ -1,113 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.CalculatedPlan -import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity -import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity -import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser -import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository -import java.time.LocalDateTime -import java.util.UUID - -/** - * Перестраивает persisted расписание планов из последнего raw visibility snapshot. - */ -@Service -class RebuildPlanScheduleUseCase( - private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, - private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, - private val plannedPlanRepository: PlannedPlanRepository, - private val visibilityPayloadParser: VisibilityPayloadParser, - private val planCalculationService: PlanCalculationService, - private val planningProperties: PlanningProperties -) { - /** Rebuild по одному КА: upsert сохраняет стабильный planId для business key. */ - @Transactional - fun rebuild(spacecraftId: String) { - val snapshot = spacecraftVisibilitySnapshotRepository.findById(spacecraftId) - .orElseThrow { IllegalStateException("Visibility snapshot for spacecraftId=$spacecraftId was not found") } - val points = visibilityPayloadParser.parse(spacecraftId, snapshot.payloadJson) - val state = getOrCreateState(spacecraftId) - val basePlan = state.currentBasePlanId?.let { planId -> plannedPlanRepository.findById(planId).orElse(null) } - - val calculatedPlans = if (basePlan == null) { - planCalculationService.calculatePlans(points) - } else { - planCalculationService.calculatePlansAfter(points, basePlan.toCalculatedPlan()) - } - - val now = LocalDateTime.now() - val chainVersion = state.chainVersion + 1 - val savedPlans = calculatedPlans.map { calculatedPlan -> - upsertPlan(calculatedPlan, state.currentBasePlanId, chainVersion, now) - } - - supersedeObsoleteFuturePlans( - spacecraftId = spacecraftId, - calculatedBusinessKeys = savedPlans.map { plan -> plan.businessKey() }.toSet(), - now = now - ) - state.chainVersion = chainVersion - state.updatedAt = now - spacecraftPlanningStateJpaRepository.save(state) - } - - private fun getOrCreateState(spacecraftId: String): SpacecraftPlanningStateEntity { - spacecraftPlanningStateJpaRepository.findById(spacecraftId).orElse(null) - ?: spacecraftPlanningStateJpaRepository.saveAndFlush(SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)) - return spacecraftPlanningStateJpaRepository.lockBySpacecraftId(spacecraftId) - ?: throw IllegalStateException("Planning state for spacecraftId=$spacecraftId was not found after create") - } - - private fun upsertPlan( - calculatedPlan: CalculatedPlan, - basePlanId: UUID?, - chainVersion: Long, - now: LocalDateTime - ): PlannedPlanEntity = - plannedPlanRepository.upsertPlannedByBusinessKey( - planId = UUID.randomUUID(), - spacecraftId = calculatedPlan.spacecraftId, - startTime = calculatedPlan.startTime, - endTime = calculatedPlan.endTime, - kppId = calculatedPlan.kppId, - issueNotBefore = calculatedPlan.startTime.minusMinutes(planningProperties.notificationBeforeStartMinutes), - basePlanId = basePlanId, - chainVersion = chainVersion, - now = now - ) - - private fun supersedeObsoleteFuturePlans( - spacecraftId: String, - calculatedBusinessKeys: Set, - now: LocalDateTime - ) { - plannedPlanRepository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( - spacecraftId = spacecraftId, - status = PlannedPlanStatus.PLANNED, - startTime = now - ) - .filter { plan -> plan.businessKey() !in calculatedBusinessKeys } - .forEach { plan -> - plan.status = PlannedPlanStatus.SUPERSEDED - plan.updatedAt = now - plannedPlanRepository.save(plan) - } - } - - private fun PlannedPlanEntity.toCalculatedPlan(): CalculatedPlan = - CalculatedPlan( - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId - ) - - private fun PlannedPlanEntity.businessKey(): String = - "$spacecraftId|$startTime|$endTime|$kppId" -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt index 2836a6e..7dde8c8 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SpacecraftVisibilitySnapshotService.kt @@ -9,16 +9,16 @@ import java.security.MessageDigest import java.time.LocalDateTime /** - * Сохраняет raw visibility payload и ставит recalculation только при изменении hash. + * Сохраняет raw visibility payload. Расписание считается on-demand из снапшота + * ([PlanProjectionService]), поэтому отдельный triggers пересчёта здесь не нужен. */ @Service class SpacecraftVisibilitySnapshotService( private val spacecraftVisibilitySnapshotRepository: SpacecraftVisibilitySnapshotRepository, - private val visibilityPayloadParser: VisibilityPayloadParser, - private val planRecalculationJobService: PlanRecalculationJobService + private val visibilityPayloadParser: VisibilityPayloadParser ) { /** - * Сохраняет snapshot. Recalculation job создаётся только для нового или изменившегося payload. + * Сохраняет snapshot. Hash используется только для метаданных и диагностики изменений окон. */ @Transactional fun saveSnapshot(spacecraftId: String, noradId: Long, payloadJson: String, fetchedAt: LocalDateTime) { @@ -42,7 +42,6 @@ class SpacecraftVisibilitySnapshotService( updatedAt = now ) ) - planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") return } @@ -60,10 +59,6 @@ class SpacecraftVisibilitySnapshotService( } spacecraftVisibilitySnapshotRepository.save(existing) - - if (changed) { - planRecalculationJobService.enqueue(spacecraftId, "VISIBILITY_CHANGED") - } } private fun extractMetadata(spacecraftId: String, payloadJson: String): VisibilityMetadata { diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt index 10f7d9a..0c6fb08 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCase.kt @@ -40,8 +40,10 @@ class SyncSpacecraftFromNsiUseCase( name = platform.name, status = platform.status, mission = platform.mission, - validFrom = platform.validFrom?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), - validTo = platform.validTo?.atZone(ZoneId.systemDefault())?.toLocalDateTime(), + // validFrom/validTo приходят как Instant; в naive-поля БД пишем настенные часы локальной + // зоны JVM (ZoneId.systemDefault()) — naive-время в системе локальное, без UTC-семантики. + validFrom = platform.validFrom?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }, + validTo = platform.validTo?.let { LocalDateTime.ofInstant(it, ZoneId.systemDefault()) }, now = now ) visibilityRefreshJobService.enqueue(spacecraftId, noradId, "NSI_SYNC") diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt index fdd8b56..351f0fa 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionService.kt @@ -6,53 +6,37 @@ import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime import java.util.UUID /** - * Test-only helper: генерирует decision event для текущей active attempt persisted plan. + * Test-only helper: генерирует decision event для последней (живой) attempt persisted plan. */ @ConditionalOnProperty(prefix = "tgu.test-controller", name = ["enabled"], havingValue = "true") @Service class TestPlanDecisionService( private val plannedPlanRepository: PlannedPlanRepository, private val planIssueAttemptRepository: PlanIssueAttemptRepository, - private val spacecraftPlanningStateJpaRepository: SpacecraftPlanningStateJpaRepository, private val handlePlanDecisionUseCase: HandlePlanDecisionUseCase ) { /** * Создаёт synthetic decision event и проводит его через тот же use case, что Kafka consumer. + * Решение адресуется последней attempt плана напрямую — выдач по КА может быть несколько разом, + * единого active-слота больше нет. */ fun generateDecision(planId: UUID, decision: PlanDecision, reason: String?): PlanDecisionMessage { val plan = plannedPlanRepository.findById(planId).orElse(null) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") - val state = spacecraftPlanningStateJpaRepository.findById(plan.spacecraftId).orElse(null) - ?: throw ResponseStatusException( - HttpStatus.CONFLICT, - "Planning state for spacecraftId=${plan.spacecraftId} was not found" - ) - if (state.activePlanId != planId || state.activeAttemptId == null) { - throw ResponseStatusException( - HttpStatus.CONFLICT, - "Plan $planId is not the active plan waiting for decision" - ) - } - val attempt = planIssueAttemptRepository.findById(state.activeAttemptId!!).orElse(null) + val attempt = planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(planId) ?: throw ResponseStatusException( HttpStatus.CONFLICT, - "Active attempt ${state.activeAttemptId} for plan $planId was not found" + "Plan $planId has no issue attempt to decide on" ) - if (attempt.planId != planId) { - throw ResponseStatusException( - HttpStatus.CONFLICT, - "Active attempt ${attempt.attemptId} does not belong to plan $planId" - ) - } if (attempt.status !in ACTIVE_ATTEMPT_STATUSES) { throw ResponseStatusException( HttpStatus.CONFLICT, @@ -73,6 +57,26 @@ class TestPlanDecisionService( return message } + /** + * Создаёт synthetic событие «план заложен» и проводит его через тот же use case, что Kafka + * consumer ([HandlePlanDecisionUseCase.handleLayinConfirmed]) — для ручной проверки перехода + * AWAITING_LAYIN → LAID_IN из UI. + */ + fun generateLayinConfirmed(planId: UUID, reason: String?): PlanExecutionFeedbackMessage { + val plan = plannedPlanRepository.findById(planId).orElse(null) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Plan with planId=$planId was not found") + + val message = PlanExecutionFeedbackMessage( + eventId = UUID.randomUUID(), + spacecraftId = plan.spacecraftId, + planId = plan.planId, + occurredAt = LocalDateTime.now(), + reason = reason?.takeIf { it.isNotBlank() } ?: DEFAULT_REASON + ) + handlePlanDecisionUseCase.handleLayinConfirmed(message) + return message + } + private companion object { val ACTIVE_ATTEMPT_STATUSES = setOf(PlanIssueAttemptStatus.STARTING, PlanIssueAttemptStatus.STARTED) const val DEFAULT_REASON = "TEST_CONTROLLER" diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt new file mode 100644 index 0000000..dc973ef --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcher.kt @@ -0,0 +1,50 @@ +package space.nstart.pcp_tgu_service.service + +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import java.time.Duration +import java.time.LocalDateTime + +/** + * Сверка набранного оператором окна плана с «сырыми» ЗРВ КА. + * + * Окно собирается из двух реальных зон: старт (и КПП) берётся у одной ЗРВ, конец — у другой (не + * раньше старта). Поэтому окно валидно, если: + * - начало совпадает (с точностью до секунды) с началом какой-то ЗРВ **этого же КПП**, и + * - конец совпадает с концом какой-то ЗРВ, и + * - конец строго позже начала. + * + * Случай «одна ЗРВ» (текущие replace/fork-forward, где старт и конец — границы одной зоны) — частный + * случай этого правила, поэтому проверка единая для создания плана и для контроля слота на promote. + */ +object ZrvWindowMatcher { + + /** + * Допуск сравнения времён — компенсирует усечение ISO-строк на фронте до целых секунд + * (`.slice(0,19)`): реальная ЗРВ имеет доли секунды, набранное окно — нет, расхождение строго + * меньше секунды. Сравнивается истинная абсолютная длительность (не усечённые секунды): у + * отрицательного `Duration` доли нормализуются в +nanos, поэтому `.toSeconds()` округлял бы 0.18с + * вниз до −1 и ломал совпадение. + */ + private val TOLERANCE = Duration.ofSeconds(1) + + fun matches( + windows: List, + windowStart: LocalDateTime, + windowStop: LocalDateTime, + windowKppId: String + ): Boolean { + if (!windowStop.isAfter(windowStart)) { + return false + } + val startMatches = windows.any { zrv -> + zrv.kppId == windowKppId && within(zrv.startTime, windowStart) + } + val stopMatches = windows.any { zrv -> + within(zrv.endTime, windowStop) + } + return startMatches && stopMatches + } + + private fun within(a: LocalDateTime, b: LocalDateTime): Boolean = + Duration.between(a, b).abs() <= TOLERANCE +} diff --git a/services/pcp-tgu-service/src/main/resources/application.yml b/services/pcp-tgu-service/src/main/resources/application.yml index 3b44568..d6dd150 100644 --- a/services/pcp-tgu-service/src/main/resources/application.yml +++ b/services/pcp-tgu-service/src/main/resources/application.yml @@ -7,26 +7,36 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:master} planning: notification-before-start-minutes: 263 - target-plan-duration-minutes: 180 - target-plan-duration-delta-minutes: 60 + target-plan-duration-minutes: 1440 + target-plan-duration-delta-minutes: 240 external-update-enabled: true - external-points-lookahead-days: 3 + external-points-lookahead-days: 10 in-memory-history-days: 14 external-update-fixed-delay-ms: 600000 notification-check-fixed-delay-ms: 30000 min-reissue-lead-minutes: 5 + reissue-prep-minutes: 30 worker-lock-ttl-seconds: 300 + waiting-decision-grace-minutes: 30 + layin-freeze-minutes: 10 topics: visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1 satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1 + layin-failed: pcp.tgu.layin-failed.v1 + layin-confirmed: pcp.tgu.layin-confirmed.v1 + plan-not-accepted: pcp.tgu.plan-not-accepted.v1 + station-params-changed: pcp.tgu.station-params-changed.v1 + +mission-planing: + base-url: ${pcp.services.mission:http://localhost:7010} tgu: test-controller: diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql new file mode 100644 index 0000000..03cc7d6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V10__drop_planned_plan_business_key_unique.sql @@ -0,0 +1,5 @@ +-- В on-demand-модели планы не апсертятся по бизнес-ключу: при перевыдаче того же окна +-- (после REJECTED/негатива) создаётся новая строка с новым plan_id, а старая остаётся в истории. +-- Поэтому уникальность по (spacecraft_id, start_time, end_time, kpp_id) больше не нужна. +ALTER TABLE planned_plan + DROP CONSTRAINT IF EXISTS uq_planned_plan_business_key; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql new file mode 100644 index 0000000..f0c51f6 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V11__purge_legacy_planned_rows.sql @@ -0,0 +1,11 @@ +-- Чистка легаси: до редизайна старый код материализовал всю будущую цепочку в planned_plan +-- со статусом PLANNED. Новая модель «храним только выданное» проекцию в БД не пишет — будущее +-- считается на лету. Persisted-строк в статусе PLANNED как класса больше не существует. +-- +-- Эти строки и порождали видимые «дубликаты» в UI: persisted-PLANNED (микросекунды) показывался +-- рядом со своим проекционным двойником (наносекунды), не схлопываясь по бизнес-ключу. +-- +-- Удаляем только PLANNED. Выданная история (ISSUING/WAITING_DECISION/ACCEPTED/AWAITING_LAYIN/ +-- LAID_IN/REJECTED/LAYIN_FAILED/NOT_ACCEPTED/SUPERSEDED/EXPIRED и пр.) не затрагивается. +DELETE FROM planned_plan +WHERE status = 'PLANNED'; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql new file mode 100644 index 0000000..85ee844 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V12__drop_legacy_superseded_expired.sql @@ -0,0 +1,19 @@ +-- Чистка легаси статусов SUPERSEDED/EXPIRED. +-- +-- В старой модели вся будущая цепочка материализовалась в planned_plan как PLANNED-строки. +-- SUPERSEDED гасил будущие планы, выпавшие из пересчитанной цепочки (это делал ныне удалённый +-- RebuildPlanScheduleUseCase); EXPIRED помечал неизданный план, чьё окно прошло. +-- +-- Новая модель «храним только выданное, будущее считаем на лету» проекцию в БД не пишет: +-- перекрывать (supersede) нечего, а неизданный просроченный план строкой никогда не был. +-- Оба статуса в коде больше не присваиваются (убраны из PlannedPlanStatus). +-- +-- Эти строки — не выданная история, а легаси-бухгалтерия будущего, поэтому удаляем безвозвратно. +-- Выданная история (ISSUING/WAITING_DECISION/ACCEPTED/AWAITING_LAYIN/LAID_IN/REJECTED/ +-- LAYIN_FAILED/NOT_ACCEPTED/START_AMBIGUOUS) не затрагивается. +DELETE FROM planned_plan +WHERE status IN ('SUPERSEDED', 'EXPIRED'); + +-- Столбец под supersede-флоу больше не используется (поле supersededByPlanId удалено из сущности). +ALTER TABLE planned_plan + DROP COLUMN IF EXISTS superseded_by_plan_id; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V13__planning_state_paused_after_reject.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V13__planning_state_paused_after_reject.sql new file mode 100644 index 0000000..11d407d --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V13__planning_state_paused_after_reject.sql @@ -0,0 +1,6 @@ +-- Цепочка КА стоит на паузе после REJECTED: пока флаг TRUE, проекция пуста и воркер ничего не +-- выдаёт — оператор должен вручную создать исправленный план («Создать план»). Флаг снимается при +-- ручной выдаче / промоуте черновика / приёмке. В отличие от urgent_reissue_from (тех. негативы +-- LAYIN_FAILED/NOT_ACCEPTED авто-перевыдаются), отказ решением требует осознанного вмешательства. +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS paused_after_reject BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V14__planned_plan_dismissed_at.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V14__planned_plan_dismissed_at.sql new file mode 100644 index 0000000..97e666d --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V14__planned_plan_dismissed_at.sql @@ -0,0 +1,6 @@ +-- «Снято с учёта»: оператор деактивирует терминально-негативный план (REJECTED, START_AMBIGUOUS, +-- LAYIN_FAILED, NOT_ACCEPTED, SUPERSEDED), чтобы он не считался проблемой и не красил индикатор КА. +-- Чисто косметико-статистический флаг: статус плана и состояние цепочки не трогает, обратим +-- (restore = dismissed_at -> NULL). NULL — план на учёте (как раньше). +ALTER TABLE planned_plan + ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMP NULL; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql new file mode 100644 index 0000000..1ce8ff1 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V7__drop_tracked_plan.sql @@ -0,0 +1,2 @@ +-- Legacy-таблица из V1/V2: заменена на planned_plan (V4), в коде не используется. +DROP TABLE IF EXISTS tracked_plan; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql new file mode 100644 index 0000000..e21fd14 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V8__drop_plan_recalculation_job.sql @@ -0,0 +1,2 @@ +-- Расписание считается on-demand из снапшота ЗРВ; отдельная очередь пересчёта больше не нужна. +DROP TABLE IF EXISTS plan_recalculation_job; diff --git a/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql b/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql new file mode 100644 index 0000000..b8591a2 --- /dev/null +++ b/services/pcp-tgu-service/src/main/resources/db/migration/V9__planning_state_urgent_reissue.sql @@ -0,0 +1,4 @@ +-- Время, начиная с которого нужно срочно выдать следующий план после негативного события +-- (REJECTED / «не заложен» / «не принят»). NULL — срочной перевыдачи не требуется. +ALTER TABLE spacecraft_planning_state + ADD COLUMN IF NOT EXISTS urgent_reissue_from TIMESTAMP NULL; diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt new file mode 100644 index 0000000..18e0dd8 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/dto/PlanResponseContractTest.kt @@ -0,0 +1,38 @@ +package space.nstart.pcp_tgu_service.dto + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для [PlanResponse]: naive `LocalDateTime` → ISO без зоны + * (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам. + */ +class PlanResponseContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes plan times as naive ISO without zone`() { + val response = PlanResponse( + planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + kppId = "KPP-1", + status = PlannedPlanStatus.PLANNED + ) + + val node = mapper.readTree(mapper.writeValueAsString(response)) + + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z") + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt index df21568..d00721d 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -114,6 +114,9 @@ class ExternalPointsClientTest { externalUpdateFixedDelayMs = 0, notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, - workerLockTtlSeconds = 300 + reissuePrepMinutes = 30, + workerLockTtlSeconds = 300, + waitingDecisionGraceMinutes = 30, + layinFreezeMinutes = 10 ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt new file mode 100644 index 0000000..ffd17cf --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/VisibilityPayloadParserTest.kt @@ -0,0 +1,67 @@ +package space.nstart.pcp_tgu_service.integration.api + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.TimeZone + +class VisibilityPayloadParserTest { + + private val parser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()) + + private val originalTimeZone: TimeZone = TimeZone.getDefault() + + @AfterEach + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun `projects zoned instant into JVM default zone as naive local LocalDateTime`() { + // naive-время в системе — настенные часы локальной зоны JVM (без UTC-семантики). Парсер проецирует + // зонированный инстант через ZoneId.systemDefault(), поэтому на МСК (+3) 14:14Z даёт локальные 17:14. + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) + val payload = """ + [{ + "noradId": 20, + "stationId": 102, + "onStart": {"time": "2026-06-02T14:14:08Z", "elevation": 5.0}, + "onStop": {"time": "2026-06-02T14:20:00Z", "elevation": 5.0}, + "onMaximum": {"time": "2026-06-02T14:17:00Z", "elevation": 45.0} + }] + """.trimIndent() + + val window = parser.parse("20", payload).points.single().observationWindows.single() + + // Инстант 14:14Z спроецирован в локальную зону JVM (МСК, +3) — настенные 17:14. + assertEquals(LocalDateTime.parse("2026-06-02T17:14:08"), window.startTime) + assertEquals(LocalDateTime.parse("2026-06-02T17:20:00"), window.endTime) + } + + @Test + fun `truncates window times to microseconds to match persisted timestamp precision`() { + // Баллистика отдаёт наносекунды; БД (timestamp(6)) хранит микросекунды. Парсер обязан + // обрезать хвост, иначе проекция и persisted-планы не совпадут по бизнес-ключу. + val payload = """ + [{ + "noradId": 20, + "stationId": 102, + "onStart": {"time": "2026-06-02T14:14:08.548125028Z", "elevation": 5.0}, + "onStop": {"time": "2026-06-03T14:01:27.615390539Z", "elevation": 5.0}, + "onMaximum": {"time": "2026-06-02T20:00:00Z", "elevation": 45.0} + }] + """.trimIndent() + + val points = parser.parse("20", payload) + val window = points.points.single().observationWindows.single() + + assertEquals(0, window.startTime.nano % 1_000, "наносекундный остаток должен быть обрезан") + assertEquals(0, window.endTime.nano % 1_000, "наносекундный остаток должен быть обрезан") + // Микросекундная часть сохраняется как есть. + assertEquals(548_125_000, window.startTime.nano) + assertEquals(615_390_000, window.endTime.nano) + assertEquals(LocalDateTime::class.java, window.startTime.javaClass) + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt index 85bcc69..da72127 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/AcceptedPlanSlotsUpdateFlowTest.kt @@ -120,7 +120,7 @@ class AcceptedPlanSlotsUpdateFlowTest { starter: AcceptedPlanSlotsUpdateProcessStarter ): AcceptedPlanSlotsUpdateWorker = AcceptedPlanSlotsUpdateWorker( - planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300), + planningProperties = PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10), acceptedPlanSlotsUpdateJobRepository = repository, acceptedPlanSlotsUpdateProcessStarter = starter, transactionTemplate = transactionTemplate() diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt new file mode 100644 index 0000000..edc8d9f --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateChainPlanUseCaseTest.kt @@ -0,0 +1,235 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateChainPlanRequest +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.integration.api.MissionBlankRequest +import space.nstart.pcp_tgu_service.integration.api.MissionCloneRequest +import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class CreateChainPlanUseCaseTest { + + private val spacecraftId = "25544" + private val kppId = "KPP-1" + + @Test + fun `with base clones mission into the window and creates a DRAFT with basePlanId and chain preview`() { + val base = plan(time(1), time(2), PlannedPlanStatus.LAID_IN) + val newMissionId = UUID.randomUUID() + val windowStart = time(5) + val windowStop = time(6) + val chainTail = listOf(CalculatedPlan(spacecraftId, time(7), time(8), kppId)) + val fixture = fixture(base = base, cloneResult = newMissionId, projectedTail = chainTail, window = windowStart to windowStop) + + val response = fixture.useCase.createChainPlan( + CreateChainPlanRequest(spacecraftId, base.planId, windowStart, windowStop, kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + // Клон по окну (fork-forward-стиль), пустая миссия не создаётся. + verify(fixture.client).cloneMission(anyUuid(), anyNonNull()) + verify(fixture.client, never()).createBlankMission(anyNonNull()) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertEquals(base.planId, saved.basePlanId) + assertEquals(windowStart, saved.startTime) + assertEquals(windowStop, saved.endTime) + assertEquals(kppId, saved.kppId) + // Прогноз цепочки от окна вернулся. + assertEquals(1, response.projectedChain.size) + assertEquals(PlannedPlanStatus.PLANNED, response.projectedChain.first().status) + } + + @Test + fun `without base creates a blank mission and a DRAFT with null basePlanId`() { + val newMissionId = UUID.randomUUID() + val fixture = fixture(base = null, blankResult = newMissionId) + + val response = fixture.useCase.createChainPlan( + CreateChainPlanRequest(spacecraftId, basePlanId = null, time(5), time(6), kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + verify(fixture.client).createBlankMission(anyNonNull()) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertNull(saved.basePlanId) + } + + @Test + fun `window not matching any raw ZRV yields 422 and no mission is created`() { + val fixture = fixture(base = null, rawWindows = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId))) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).createBlankMission(anyNonNull()) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + @Test + fun `base belonging to another spacecraft yields 422`() { + val base = plan(time(1), time(2), PlannedPlanStatus.LAID_IN).apply { spacecraftId = "99999" } + val fixture = fixture(base = base) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, base.planId, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `windowStop not after windowStart yields 422`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(6), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `windowStart in the past yields 422`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(-1), time(1), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `unknown base plan yields 404`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, UUID.randomUUID(), time(5), time(6), kppId)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `non-numeric spacecraftId without base yields 422`() { + val fixture = fixture(base = null, spacecraftIdOverride = "SAT-A") + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest("SAT-A", null, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `duplicate window for the same spacecraft yields 409 and no mission is created`() { + val existing = plan(time(5), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base = null, existingDuplicate = existing) + val ex = assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId)) + } + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + verify(fixture.client, never()).createBlankMission(anyNonNull()) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + @Test + fun `a failed DRAFT save compensates by deleting the new mission and rethrows`() { + val newMissionId = UUID.randomUUID() + val fixture = fixture(base = null, blankResult = newMissionId, failSave = true) + assertThrows { + fixture.useCase.createChainPlan(CreateChainPlanRequest(spacecraftId, null, time(5), time(6), kppId)) + } + verify(fixture.client, times(1)).deleteMission(newMissionId) + } + + // --- helpers --- + + private class Fixture( + val useCase: CreateChainPlanUseCase, + val client: MissionPlaningClient, + private val savedHolder: Array + ) { + val lastSaved: PlannedPlanEntity? get() = savedHolder[0] + } + + private fun fixture( + base: PlannedPlanEntity?, + cloneResult: UUID = UUID.randomUUID(), + blankResult: UUID = UUID.randomUUID(), + rawWindows: List? = null, + projectedTail: List = emptyList(), + failSave: Boolean = false, + spacecraftIdOverride: String = spacecraftId, + existingDuplicate: PlannedPlanEntity? = null, + window: Pair = time(5) to time(6) + ): Fixture { + val repo = mock(PlannedPlanRepository::class.java) + `when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base)) + `when`( + repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus( + anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull() + ) + ).thenReturn(listOfNotNull(existingDuplicate)) + val saved = arrayOfNulls(1) + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + if (failSave) throw RuntimeException("DB down") + val e = inv.arguments[0] as PlannedPlanEntity + saved[0] = e + e + } + + val projectionService = mock(PlanProjectionService::class.java) + // По умолчанию сырые ЗРВ совпадают с запрашиваемым окном → валидно. + val defaultRaw = listOf(CalculatedPlan(spacecraftIdOverride, window.first, window.second, kppId)) + `when`(projectionService.rawZrvWindows(spacecraftIdOverride)).thenReturn(rawWindows ?: defaultRaw) + `when`(projectionService.projectAfterWindow(anyNonNull(), anyNonNull())).thenReturn(projectedTail) + + val client = mock(MissionPlaningClient::class.java) + `when`(client.cloneMission(anyUuid(), anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) + `when`(client.createBlankMission(anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = blankResult)) + + val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepo.findById(spacecraftIdOverride)) + .thenReturn(Optional.of(SpacecraftPlanningStateEntity(spacecraftId = spacecraftIdOverride, chainVersion = 0))) + + val useCase = CreateChainPlanUseCase(repo, projectionService, client, stateRepo) + return Fixture(useCase, client, saved) + } + + private fun plan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status + ) + + // Фиксированная база времени на инстанс теста: time() детерминирован, окно в запросе и в + // моке сырых ЗРВ совпадает байт-в-байт (без дрейфа now() между вызовами). + private val t0: LocalDateTime = LocalDateTime.now() + private fun time(hour: Int): LocalDateTime = t0.plusHours(hour.toLong()) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt new file mode 100644 index 0000000..6f95cb6 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/CreateDraftUseCaseTest.kt @@ -0,0 +1,297 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.CreateDraftRequest +import space.nstart.pcp_tgu_service.dto.DraftMode +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.integration.api.MissionCloneResponse +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class CreateDraftUseCaseTest { + + private val spacecraftId = "25544" + private val kppId = "KPP-1" + + // --- replace --- + + @Test + fun `replace creates DRAFT inheriting the source's base (predecessor), not the source itself`() { + // База исходного плана — это его предшественник в цепочке. Replace-черновик — коррекция, + // занимающая тот же слот, поэтому он наследует ИМЕННО предшественника, а не сам исходный план. + val predecessor = UUID.randomUUID() + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = predecessor) + val newMissionId = UUID.randomUUID() + val fixture = fixture(base, cloneResult = newMissionId) + + val response = fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(newMissionId, response.draftPlanId) + val saved = fixture.lastSaved!! + assertEquals(newMissionId, saved.planId) + assertEquals(PlannedPlanStatus.DRAFT, saved.status) + assertEquals(predecessor, saved.basePlanId) + assertEquals(base.startTime, saved.startTime) + assertEquals(base.endTime, saved.endTime) + assertEquals(base.kppId, saved.kppId) + } + + @Test + fun `replace of a chain-start plan inherits a null base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN, basePlanId = null) + val fixture = fixture(base) + + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(null, fixture.lastSaved!!.basePlanId) + } + + @Test + fun `draft snapshots the current chain version as its optimistic token`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val fixture = fixture( + base, + planningState = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 7) + ) + + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + + assertEquals(7L, fixture.lastSaved!!.chainVersion) + } + + @Test + fun `replace rejects a DRAFT base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `replace rejects a LAID_IN base`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `replace rejects a base inside the freeze window`() { + // startTime через 5 минут, заморозка 10 минут → точка невозврата пройдена. + val base = plan(time(0).plusMinutes(5), time(3), PlannedPlanStatus.ACCEPTED) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + // --- fork-forward --- + + @Test + fun `fork-forward creates DRAFT on a window matching the projection`() { + val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN) + val windowStart = time(5) + val windowStop = time(6) + val newMissionId = UUID.randomUUID() + val fixture = fixture( + base, + cloneResult = newMissionId, + projection = listOf(CalculatedPlan(spacecraftId, windowStart, windowStop, kppId)) + ) + + val response = fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = windowStart, windowStop = windowStop, windowKppId = kppId) + ) + + assertEquals(newMissionId, response.draftPlanId) + val saved = fixture.lastSaved!! + assertEquals(windowStart, saved.startTime) + assertEquals(windowStop, saved.endTime) + assertEquals(kppId, saved.kppId) + assertEquals(base.planId, saved.basePlanId) + } + + @Test + fun `fork-forward requires windowStart, windowStop and windowKppId`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.FORK_FORWARD)) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + @Test + fun `fork-forward rejects a windowStart not strictly in the future`() { + val base = plan(time(3), time(6), PlannedPlanStatus.LAID_IN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(-1), windowStop = time(1), windowKppId = kppId) + ) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + } + + @Test + fun `fork-forward rejects a window that matches no future ZRV in the projection`() { + val base = plan(time(0).plusMinutes(5), time(1), PlannedPlanStatus.LAID_IN) + val fixture = fixture( + base, + projection = listOf(CalculatedPlan(spacecraftId, time(9), time(10), kppId)) + ) + val ex = assertThrows { + fixture.useCase.createDraft( + base.planId, + CreateDraftRequest(mode = DraftMode.FORK_FORWARD, windowStart = time(5), windowStop = time(6), windowKppId = kppId) + ) + } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + // --- общие --- + + @Test + fun `unknown mode is rejected`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val fixture = fixture(base) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = "weird")) + } + assertEquals(HttpStatus.BAD_REQUEST, ex.statusCode) + } + + @Test + fun `unknown base plan yields 404`() { + val fixture = fixture(base = null) + val ex = assertThrows { + fixture.useCase.createDraft(UUID.randomUUID(), CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `an existing open DRAFT on the same slot (spacecraft + window) yields 409`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val existing = plan(time(3), time(6), PlannedPlanStatus.DRAFT) + val fixture = fixture(base, existingDrafts = listOf(existing)) + val ex = assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + verify(fixture.client, never()).cloneMission(anyUuid(), anyNonNull()) + } + + @Test + fun `a failed DRAFT save compensates by deleting the cloned mission and rethrows`() { + val base = plan(time(3), time(6), PlannedPlanStatus.AWAITING_LAYIN) + val newMissionId = UUID.randomUUID() + val fixture = fixture(base, cloneResult = newMissionId, failSave = true) + + assertThrows { + fixture.useCase.createDraft(base.planId, CreateDraftRequest(mode = DraftMode.REPLACE)) + } + verify(fixture.client, times(1)).deleteMission(newMissionId) + } + + // --- helpers --- + + private class Fixture( + val useCase: CreateDraftUseCase, + val client: MissionPlaningClient, + val savedHolder: Array + ) { + val lastSaved: PlannedPlanEntity? get() = savedHolder[0] + } + + private fun fixture( + base: PlannedPlanEntity?, + cloneResult: UUID = UUID.randomUUID(), + projection: List = emptyList(), + existingDrafts: List = emptyList(), + failSave: Boolean = false, + planningState: SpacecraftPlanningStateEntity? = null + ): Fixture { + val repo = mock(PlannedPlanRepository::class.java) + `when`(repo.findById(anyUuid())).thenReturn(Optional.ofNullable(base)) + `when`( + repo.findAllBySpacecraftIdAndStartTimeAndEndTimeAndStatus( + anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull() + ) + ).thenReturn(existingDrafts) + + val saved = arrayOfNulls(1) + `when`(repo.save(anyNonNull())).thenAnswer { inv -> + if (failSave) throw RuntimeException("DB down") + val e = inv.arguments[0] as PlannedPlanEntity + saved[0] = e + e + } + + val projectionService = mock(PlanProjectionService::class.java) + `when`(projectionService.project(spacecraftId)).thenReturn(projection) + + val client = mock(MissionPlaningClient::class.java) + `when`(client.cloneMission(anyUuid(), anyNonNull())) + .thenReturn(MissionCloneResponse(newMissionId = cloneResult)) + + val stateRepo = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepo.findById(spacecraftId)).thenReturn(Optional.ofNullable(planningState)) + + val useCase = CreateDraftUseCase(repo, projectionService, client, props(freeze = 10), stateRepo) + return Fixture(useCase, client, saved) + } + + private fun plan( + start: LocalDateTime, + end: LocalDateTime, + status: PlannedPlanStatus, + basePlanId: UUID? = null + ): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status, + basePlanId = basePlanId + ) + + private fun props(freeze: Long): PlanningProperties = + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, freeze) + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + + // Kotlin non-null матчеры: ArgumentMatchers.any() возвращает null и роняет NPE на non-null параметрах. + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt new file mode 100644 index 0000000..b959d4d --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DeleteDraftUseCaseTest.kt @@ -0,0 +1,90 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.integration.api.MissionPlaningClient +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class DeleteDraftUseCaseTest { + + private val store = linkedMapOf() + private val repo = mock(PlannedPlanRepository::class.java).also { repo -> + `when`(repo.findById(anyUuid())).thenAnswer { inv -> Optional.ofNullable(store[inv.arguments[0]]) } + doAnswer { inv -> store.remove((inv.arguments[0] as PlannedPlanEntity).planId); null } + .`when`(repo).delete(anyNonNull()) + } + private val client = mock(MissionPlaningClient::class.java) + private val useCase = DeleteDraftUseCase(repo, client) + + @Test + fun `deletes the DRAFT row and its cloned mission`() { + val draft = plan(PlannedPlanStatus.DRAFT) + store[draft.planId] = draft + + useCase.deleteDraft(draft.planId) + + assertFalse(store.containsKey(draft.planId)) + verify(client).deleteMission(draft.planId) + } + + @Test + fun `unknown plan yields 404`() { + val ex = assertThrows { useCase.deleteDraft(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + verify(client, never()).deleteMission(anyUuid()) + } + + @Test + fun `refuses to delete a non-DRAFT plan with 422 and touches nothing`() { + val live = plan(PlannedPlanStatus.AWAITING_LAYIN) + store[live.planId] = live + + val ex = assertThrows { useCase.deleteDraft(live.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(store.containsKey(live.planId)) + verify(client, never()).deleteMission(anyUuid()) + } + + @Test + fun `a failed mission delete still removes the DRAFT row (best-effort)`() { + val draft = plan(PlannedPlanStatus.DRAFT) + store[draft.planId] = draft + doThrow(RuntimeException("mission-planing down")).`when`(client).deleteMission(anyUuid()) + + useCase.deleteDraft(draft.planId) + + assertFalse(store.containsKey(draft.planId)) + } + + private fun plan(status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = "25544", + startTime = LocalDateTime.now().plusHours(3), + endTime = LocalDateTime.now().plusHours(4), + kppId = "KPP-1", + status = status + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCaseTest.kt new file mode 100644 index 0000000..5660e71 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/DismissPlanUseCaseTest.kt @@ -0,0 +1,126 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class DismissPlanUseCaseTest { + + private val store = linkedMapOf() + private val repo = mock(PlannedPlanRepository::class.java).also { repo -> + `when`(repo.findById(anyUuid())).thenAnswer { inv -> Optional.ofNullable(store[inv.arguments[0]]) } + doAnswer { inv -> (inv.arguments[0] as PlannedPlanEntity).also { store[it.planId] = it } } + .`when`(repo).save(anyNonNull()) + } + private val useCase = DismissPlanUseCase(repo) + + @Test + fun `dismiss stamps dismissedAt on a REJECTED plan without changing status`() { + val plan = plan(PlannedPlanStatus.REJECTED) + store[plan.planId] = plan + + useCase.dismiss(plan.planId) + + assertNotNull(store[plan.planId]?.dismissedAt) + assertEquals(PlannedPlanStatus.REJECTED, store[plan.planId]?.status) + } + + @Test + fun `dismiss is allowed for every terminal-negative status`() { + for (status in listOf( + PlannedPlanStatus.REJECTED, + PlannedPlanStatus.START_AMBIGUOUS, + PlannedPlanStatus.LAYIN_FAILED, + PlannedPlanStatus.NOT_ACCEPTED, + PlannedPlanStatus.SUPERSEDED + )) { + val plan = plan(status) + store[plan.planId] = plan + useCase.dismiss(plan.planId) + assertNotNull(store[plan.planId]?.dismissedAt, "expected $status to be dismissible") + } + } + + @Test + fun `dismiss refuses a live or successful plan with 422`() { + for (status in listOf( + PlannedPlanStatus.DRAFT, + PlannedPlanStatus.ISSUING, + PlannedPlanStatus.WAITING_DECISION, + PlannedPlanStatus.AWAITING_LAYIN, + PlannedPlanStatus.LAID_IN + )) { + val plan = plan(status) + store[plan.planId] = plan + val ex = assertThrows { useCase.dismiss(plan.planId) } + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode, "expected $status to be refused") + assertNull(store[plan.planId]?.dismissedAt) + } + } + + @Test + fun `dismiss of an unknown plan yields 404`() { + val ex = assertThrows { useCase.dismiss(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `dismiss is idempotent — second call does not re-save`() { + val plan = plan(PlannedPlanStatus.REJECTED) + store[plan.planId] = plan + useCase.dismiss(plan.planId) + val firstStamp = store[plan.planId]?.dismissedAt + + useCase.dismiss(plan.planId) + + // повторный вызов не трогает запись (save вызван ровно один раз) + verify(repo, org.mockito.Mockito.times(1)).save(anyNonNull()) + assertEquals(firstStamp, store[plan.planId]?.dismissedAt) + } + + @Test + fun `restore clears dismissedAt`() { + val plan = plan(PlannedPlanStatus.REJECTED).also { it.dismissedAt = LocalDateTime.now() } + store[plan.planId] = plan + + useCase.restore(plan.planId) + + assertNull(store[plan.planId]?.dismissedAt) + assertEquals(PlannedPlanStatus.REJECTED, store[plan.planId]?.status) + } + + @Test + fun `restore of an unknown plan yields 404`() { + val ex = assertThrows { useCase.restore(UUID.randomUUID()) } + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + private fun plan(status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = "25544", + startTime = LocalDateTime.now().plusHours(3), + endTime = LocalDateTime.now().plusHours(4), + kppId = "KPP-1", + status = status + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { ArgumentMatchers.any(); return null as T } + private fun anyUuid(): UUID { ArgumentMatchers.any(); return UUID.randomUUID() } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt new file mode 100644 index 0000000..6c4d131 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/HandleStationParamsChangedUseCaseTest.kt @@ -0,0 +1,52 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.dto.StationParamsChangedMessage +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +class HandleStationParamsChangedUseCaseTest { + + @Test + fun `enqueues visibility refresh for every active spacecraft once per event`() { + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf( + SpacecraftSnapshotEntity(spacecraftId = "1", noradId = 100L), + SpacecraftSnapshotEntity(spacecraftId = "2", noradId = 200L) + ) + ) + val refreshJobService = mock(VisibilityRefreshJobService::class.java) + val useCase = HandleStationParamsChangedUseCase(processedRepository, snapshotRepository, refreshJobService) + val message = message() + + useCase.handle(message) + useCase.handle(message) + + verify(refreshJobService, times(1)).enqueue("1", 100L, "STATION_PARAMS_CHANGED") + verify(refreshJobService, times(1)).enqueue("2", 200L, "STATION_PARAMS_CHANGED") + } + + private fun message(): StationParamsChangedMessage = + StationParamsChangedMessage( + eventId = UUID.randomUUID(), + stationId = "ST-7", + occurredAt = LocalDateTime.now(), + reason = "OUT_OF_SERVICE" + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt new file mode 100644 index 0000000..1c1a79d --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/IssueDuePlanWorkerOnDemandTest.kt @@ -0,0 +1,586 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.SimpleTransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.dto.ProcessStartResponse +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity +import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class IssueDuePlanWorkerOnDemandTest { + + private val spacecraftId = "25544" + + @Test + fun `materializes due projection head as ISSUING and starts one attempt`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + // issueNotBefore = start - 30m; start=now+10m → issueNotBefore in the past → due now. + val head = plan(now().plusMinutes(10), now().plusHours(3)) + val worker = worker(planStore, attemptStore, state, projection = listOf(head)) + + worker.issueDuePlans() + + assertEquals(1, planStore.size) + val materialized = planStore.values.single() + assertEquals(head.startTime, materialized.startTime) + // markStarted перевёл выданный план в WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, materialized.status) + assertNotNull(state.activeAttemptId) + assertEquals(1, attemptStore.size) + } + + @Test + fun `does not issue when projection head is not due yet`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + // start far in the future → issueNotBefore well after now → not due. + val head = plan(now().plusHours(10), now().plusHours(13)) + val worker = worker(planStore, attemptStore, state, projection = listOf(head)) + + worker.issueDuePlans() + + assertTrue(planStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `urgent reissue issues nearest window past the floor immediately and clears the flag`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val floor = now().plusMinutes(30) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, urgentReissueFrom = floor) + val beforeFloor = plan(now().plusMinutes(10), now().plusHours(3)) + val afterFloor = plan(now().plusMinutes(40), now().plusHours(4)) + val worker = worker(planStore, attemptStore, state, projection = listOf(beforeFloor, afterFloor)) + + worker.issueDuePlans() + + val materialized = planStore.values.single() + assertEquals(afterFloor.startTime, materialized.startTime) + assertNull(state.urgentReissueFrom) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `skips spacecraft that has an in-flight plan being issued`() { + val attemptStore = linkedMapOf() + // По КА есть план «в работе» (ISSUING) → авто-выдача следующего окна блокируется (до решения). + val inFlight = persistedPlan(PlannedPlanStatus.ISSUING, now().plusMinutes(10), now().plusHours(1)) + val planStore = linkedMapOf(inFlight.planId to inFlight) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val head = plan(now().plusMinutes(10), now().plusHours(3)) + val projection = mock(PlanProjectionService::class.java) + `when`(projection.project(spacecraftId)).thenReturn(listOf(head)) + val worker = worker(planStore, attemptStore, state, projectionService = projection) + + worker.issueDuePlans() + + // Новый план не материализован, проекция даже не запрашивалась (блокировка раньше). + assertEquals(1, planStore.size) + verify(projection, never()).project(spacecraftId) + } + + @Test + fun `advances chain past WAITING_DECISION plan once grace elapsed and issues next without accepting it`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Активный план висит в WAITING_DECISION; старт был 40 минут назад (grace=30 → истёк). + val unconfirmed = persistedPlan( + status = PlannedPlanStatus.WAITING_DECISION, + start = now().minusMinutes(40), + end = now().plusHours(1) + ) + planStore[unconfirmed.planId] = unconfirmed + val baseId = UUID.randomUUID() + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = baseId, + activePlanId = unconfirmed.planId, + activeAttemptId = UUID.randomUUID() + ) + val next = plan(now().plusMinutes(10), now().plusHours(3)) + val worker = worker(planStore, attemptStore, state, projection = listOf(next)) + + worker.issueDuePlans() + + // Незакрытый план НЕ помечается ACCEPTED — остаётся WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[unconfirmed.planId]!!.status) + // Базу/версию grace НЕ двигает (это делает только LAID_IN) — currentBasePlanId не меняется. + assertEquals(baseId, state.currentBasePlanId) + // Следующий план всё равно выдан с новым attempt (grace перестал блокировать). + assertEquals(2, planStore.size) + assertNotNull(state.activeAttemptId) + assertEquals(next.startTime, planStore.values.single { it.planId != unconfirmed.planId }.startTime) + } + + @Test + fun `keeps blocking on WAITING_DECISION plan before grace elapses`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт был всего 10 минут назад (grace=30 → ещё не истёк) → блокировка держится. + val unconfirmed = persistedPlan( + status = PlannedPlanStatus.WAITING_DECISION, + start = now().minusMinutes(10), + end = now().plusHours(1) + ) + planStore[unconfirmed.planId] = unconfirmed + val attemptId = UUID.randomUUID() + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + activePlanId = unconfirmed.planId, + activeAttemptId = attemptId + ) + val worker = worker(planStore, attemptStore, state, projection = listOf(plan(now().plusMinutes(10), now().plusHours(3)))) + + worker.issueDuePlans() + + assertEquals(1, planStore.size) + assertEquals(attemptId, state.activeAttemptId) + } + + // --- Ручная выдача issuePlanNow + гард «точки невозврата» (layinFreezeMinutes = 10) --- + + @Test + fun `issuePlanNow issues a future not-laid-in plan past the freeze threshold`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Принятый план в будущем (старт через 30 мин > порога 10 мин), активной выдачи нет. + val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[target.planId] = target + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + worker.issuePlanNow(target.planId) + + // Выдача стартовала: новая attempt, план ушёл в WAITING_DECISION (markStarted), КА залочен. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status) + assertEquals(1, attemptStore.size) + assertEquals(target.planId, state.activePlanId) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `issuePlanNow clears the paused-after-reject flag (operator intervened)`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[target.planId] = target + // Цепочка стояла на паузе после отказа; ручная выдача = осознанное вмешательство → пауза снимается. + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true) + val worker = worker(planStore, attemptStore, state) + + worker.issuePlanNow(target.planId) + + assertEquals(false, state.pausedAfterReject) + assertEquals(target.planId, state.activePlanId) + } + + @Test + fun `issuePlanNow rejects a plan inside the freeze window`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт через 5 мин (< порога 10 мин) → точка невозврата пройдена. + val target = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3)) + planStore[target.planId] = target + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.ACCEPTED, planStore[target.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `issuePlanNow rejects a laid-in plan`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // В будущем за порогом, но уже подтверждён как заложенный → только fork-forward. + val target = persistedPlan(PlannedPlanStatus.LAID_IN, now().plusMinutes(30), now().plusHours(3)) + planStore[target.planId] = target + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(target.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(attemptStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `issuePlanNow allows a parallel issue while another plan is in flight`() { + val attemptStore = linkedMapOf() + // По КА уже летит другой план (WAITING_DECISION) — это больше НЕ блокирует: несколько выдач + // по КА разом допустимы (исходный + коррекция), конкуренция разрешается на закладке. + val other = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(2)) + val target = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + val planStore = linkedMapOf(other.planId to other, target.planId to target) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + worker.issuePlanNow(target.planId) + + // Выдача target стартовала параллельно: новый attempt, target в WAITING_DECISION. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status) + assertEquals(1, attemptStore.size) + assertEquals(target.planId, state.activePlanId) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `issuePlanNow re-issues the same plan over its own in-flight attempt`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // План уже ушёл в авто-выдачу (attempt #1 в полёте), но до старта далеко (> порога). + val target = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3)) + planStore[target.planId] = target + val inFlight = PlanIssueAttemptEntity( + attemptId = UUID.randomUUID(), + planId = target.planId, + attemptNo = 1, + status = PlanIssueAttemptStatus.STARTED, + createdAt = now(), + updatedAt = now() + ) + attemptStore[inFlight.attemptId] = inFlight + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + activePlanId = target.planId, + activeAttemptId = inFlight.attemptId + ) + val worker = worker(planStore, attemptStore, state) + + worker.issuePlanNow(target.planId) + + // Новая attempt #2 замещает летящую #1; план снова WAITING_DECISION, КА указывает на новую attempt. + assertEquals(2, attemptStore.size) + assertEquals(target.planId, state.activePlanId) + assertNotEquals(inFlight.attemptId, state.activeAttemptId) + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[target.planId]!!.status) + } + + @Test + fun `issuePlanNow returns 404 for unknown plan`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.issuePlanNow(UUID.randomUUID()) } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + // --- Применить черновик: promoteDraftNow (Ф5, оптимистичный лок базы) --- + + @Test + fun `promoteDraftNow promotes a fresh draft into the chain and issues it`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Заменяемый базовый план (даёт окно для проверки валидности слота при replace). + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) + + worker.promoteDraftNow(draft.planId) + + // DRAFT ушёл в выдачу: markStarted перевёл его в WAITING_DECISION, КА указывает на новую attempt. + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status) + assertEquals(1, attemptStore.size) + assertEquals(draft.planId, state.activePlanId) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects with 409 when the chain moved since the draft was created`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val base = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[base.planId] = base + // Черновик снят на версии цепочки 0, а база уже уехала вперёд (accept/grace) → состояние v1. + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 1) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + assertNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects a non-draft plan with 422`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val live = persistedPlan(PlannedPlanStatus.AWAITING_LAYIN, now().plusMinutes(30), now().plusHours(3)) + planStore[live.planId] = live + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(live.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow returns 404 for unknown draft`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(UUID.randomUUID()) } + + assertEquals(HttpStatus.NOT_FOUND, ex.statusCode) + } + + @Test + fun `promoteDraftNow rejects a draft inside the freeze window with 422`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Старт через 5 мин (< порога 10 мин): слот валиден (база даёт окно), но точка невозврата пройдена. + val base = persistedPlan(PlannedPlanStatus.ACCEPTED, now().plusMinutes(5), now().plusHours(3)) + planStore[base.planId] = base + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + // Слот валиден (окно совпадает с ЗРВ) — отказ именно из-за порога заморозки, а не слота. + val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + } + + @Test + fun `promoteDraftNow promotes a correction while the base is still in flight`() { + val attemptStore = linkedMapOf() + // База того же окна ещё «в полёте» (WAITING_DECISION) — это больше НЕ блокирует promote + // коррекции: оба плана летят параллельно, конкуренция разрешается на закладке (LAID_IN). + val base = persistedPlan(PlannedPlanStatus.WAITING_DECISION, now().plusMinutes(30), now().plusHours(3)) + val draft = draftPlan(base.startTime, base.endTime, chainVersion = 0, basePlanId = base.planId) + val planStore = linkedMapOf(base.planId to base, draft.planId to draft) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state, rawWindows = listOf(rawWindowFor(draft))) + + worker.promoteDraftNow(draft.planId) + + assertEquals(PlannedPlanStatus.WAITING_DECISION, planStore[draft.planId]!!.status) + assertEquals(1, attemptStore.size) + assertEquals(draft.planId, state.activePlanId) + assertNotNull(state.activeAttemptId) + } + + @Test + fun `promoteDraftNow rejects with 422 when the draft window no longer matches any future ZRV`() { + val planStore = linkedMapOf() + val attemptStore = linkedMapOf() + // Нет базового плана и пустая проекция → окно слота не подтверждается (снапшот сместился). + val draft = draftPlan(now().plusMinutes(30), now().plusHours(3), chainVersion = 0, basePlanId = null) + planStore[draft.planId] = draft + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 0) + val worker = worker(planStore, attemptStore, state) + + val ex = assertThrows(ResponseStatusException::class.java) { worker.promoteDraftNow(draft.planId) } + + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, ex.statusCode) + assertEquals(PlannedPlanStatus.DRAFT, planStore[draft.planId]!!.status) + assertTrue(attemptStore.isEmpty()) + } + + private fun draftPlan( + start: LocalDateTime, + end: LocalDateTime, + chainVersion: Long, + basePlanId: UUID? + ): PlannedPlanEntity = + persistedPlan(PlannedPlanStatus.DRAFT, start, end).apply { + this.chainVersion = chainVersion + this.basePlanId = basePlanId + } + + private fun persistedPlan(status: PlannedPlanStatus, start: LocalDateTime, end: LocalDateTime): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status + ) + + private fun worker( + planStore: MutableMap, + attemptStore: MutableMap, + state: SpacecraftPlanningStateEntity, + projection: List = emptyList(), + rawWindows: List = emptyList(), + projectionService: PlanProjectionService = projectionReturning(projection, rawWindows) + ): IssueDuePlanWorker { + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L)) + ) + val starter = mock(BpmnPlanIssueProcessStarter::class.java) + `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val p = invocation.arguments[0] as PlannedPlanEntity + val a = invocation.arguments[1] as PlanIssueAttemptEntity + ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) + } + return IssueDuePlanWorker( + planningProperties = planningProperties(), + spacecraftSnapshotRepository = snapshotRepository, + plannedPlanRepository = plannedRepository(planStore), + planIssueAttemptRepository = attemptRepository(attemptStore), + spacecraftPlanningStateJpaRepository = stateRepository(state), + planProjectionService = projectionService, + bpmnPlanIssueProcessStarter = starter, + transactionTemplate = transactionTemplate() + ) + } + + private fun projectionReturning( + projection: List, + rawWindows: List = emptyList() + ): PlanProjectionService { + val service = mock(PlanProjectionService::class.java) + `when`(service.project(spacecraftId)).thenReturn(projection) + `when`(service.rawZrvWindows(spacecraftId)).thenReturn(rawWindows) + return service + } + + /** Сырое окно ЗРВ, совпадающее со слотом черновика (старт+КПП и конец) — слот проходит валидацию. */ + private fun rawWindowFor(plan: PlannedPlanEntity): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = plan.startTime, endTime = plan.endTime, kppId = plan.kppId) + + private fun plannedRepository(store: MutableMap): PlannedPlanRepository { + val repository = mock(PlannedPlanRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val plan = invocation.arguments[0] as PlannedPlanEntity + store[plan.planId] = plan + plan + } + `when`( + repository.insertIssuingPlan( + anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull(), + anyNonNull(), anyOrNull(), org.mockito.ArgumentMatchers.anyLong(), anyNonNull() + ) + ).thenAnswer { invocation -> + val entity = PlannedPlanEntity( + planId = invocation.arguments[0] as UUID, + spacecraftId = invocation.arguments[1] as String, + startTime = invocation.arguments[2] as LocalDateTime, + endTime = invocation.arguments[3] as LocalDateTime, + kppId = invocation.arguments[4] as String, + status = PlannedPlanStatus.ISSUING, + issueNotBefore = invocation.arguments[5] as LocalDateTime + ) + store[entity.planId] = entity + entity + } + `when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val sc = invocation.arguments[0] as String + @Suppress("UNCHECKED_CAST") + val statuses = invocation.arguments[1] as Collection + store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses } + } + return repository + } + + private fun attemptRepository(store: MutableMap): PlanIssueAttemptRepository { + val repository = mock(PlanIssueAttemptRepository::class.java) + `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> Optional.ofNullable(store[invocation.arguments[0]]) } + `when`(repository.findTopByPlanIdOrderByAttemptNoDesc(anyNonNull())).thenAnswer { invocation -> + store.values.filter { attempt -> attempt.planId == invocation.arguments[0] }.maxByOrNull { attempt -> attempt.attemptNo } + } + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> + val attempt = invocation.arguments[0] as PlanIssueAttemptEntity + store[attempt.attemptId] = attempt + attempt + } + return repository + } + + private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { + val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) + `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) + `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } + return repository + } + + private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + // Тот же UTC-эталон, что и у воркера (IssueDuePlanWorker берёт LocalDateTime.now()), + // иначе на не-UTC JVM подготовленные относительно "сейчас" окна расходятся с часами воркера на офсет. + private fun now(): LocalDateTime = LocalDateTime.now() + + private fun planningProperties(): PlanningProperties = + PlanningProperties(30, 180, 60, false, 10, 14, 0, 30_000, 5, 30, 300, 30, 10) + + private fun transactionTemplate(): TransactionTemplate = + TransactionTemplate( + object : PlatformTransactionManager { + override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() + override fun commit(status: TransactionStatus) = Unit + override fun rollback(status: TransactionStatus) = Unit + } + ) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + @Suppress("UNCHECKED_CAST") + private fun anyOrNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt deleted file mode 100644 index 506b63e..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/JobRerunFlowTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import org.springframework.data.jpa.repository.Query -import org.springframework.transaction.PlatformTransactionManager -import org.springframework.transaction.TransactionDefinition -import org.springframework.transaction.TransactionStatus -import org.springframework.transaction.support.SimpleTransactionStatus -import org.springframework.transaction.support.TransactionTemplate -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.entity.JobStatus -import space.nstart.pcp_tgu_service.entity.PlanRecalculationJobEntity -import space.nstart.pcp_tgu_service.entity.VisibilityRefreshJobEntity -import space.nstart.pcp_tgu_service.integration.api.ExternalPointsClient -import space.nstart.pcp_tgu_service.repository.PlanRecalculationJobRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository -import space.nstart.pcp_tgu_service.repository.VisibilityRefreshJobRepository -import java.time.LocalDateTime -import java.util.Optional - -class JobRerunFlowTest { - - @Test - fun `enqueue does not reset running visibility job lock`() { - val query = visibilityUpsertQuery() - - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.status")) - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_by")) - assertTrue(query.contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN visibility_refresh_job.locked_until")) - } - - @Test - fun `enqueue does not reset running recalculation job lock`() { - val query = recalculationUpsertQuery() - - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.status")) - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_by")) - assertTrue(query.contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN plan_recalculation_job.locked_until")) - } - - @Test - fun `enqueue running job sets rerunRequested to true`() { - assertTrue( - visibilityUpsertQuery() - .contains("WHEN visibility_refresh_job.status = 'RUNNING' THEN TRUE") - ) - assertTrue( - recalculationUpsertQuery() - .contains("WHEN plan_recalculation_job.status = 'RUNNING' THEN TRUE") - ) - } - - @Test - fun `visibility markDone with rerunRequested moves job to pending`() { - val spacecraftId = "SC-1" - val jobStore = linkedMapOf( - spacecraftId to VisibilityRefreshJobEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - status = JobStatus.PENDING, - rerunRequested = true - ) - ) - val repository = visibilityJobRepository(jobStore) - val externalPointsClient = mock(ExternalPointsClient::class.java) - val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) - `when`( - externalPointsClient.fetchRawVisibilityPayload( - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull(), - anyNonNull() - ) - ).thenReturn("[]") - val worker = VisibilityRefreshWorker( - planningProperties = planningProperties(), - visibilityRefreshJobRepository = repository, - spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), - externalPointsClient = externalPointsClient, - spacecraftVisibilitySnapshotService = snapshotService, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.PENDING, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `visibility markDone without rerunRequested moves job to done`() { - val spacecraftId = "SC-2" - val jobStore = linkedMapOf( - spacecraftId to VisibilityRefreshJobEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - status = JobStatus.PENDING - ) - ) - val repository = visibilityJobRepository(jobStore) - val externalPointsClient = mock(ExternalPointsClient::class.java) - val snapshotService = mock(SpacecraftVisibilitySnapshotService::class.java) - `when`( - externalPointsClient.fetchRawVisibilityPayload( - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull(), - anyNonNull() - ) - ).thenReturn("[]") - val worker = VisibilityRefreshWorker( - planningProperties = planningProperties(), - visibilityRefreshJobRepository = repository, - spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), - externalPointsClient = externalPointsClient, - spacecraftVisibilitySnapshotService = snapshotService, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.DONE, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `recalculation markDone with rerunRequested moves job to pending`() { - val spacecraftId = "SC-3" - val jobStore = linkedMapOf( - spacecraftId to PlanRecalculationJobEntity( - spacecraftId = spacecraftId, - status = JobStatus.PENDING, - rerunRequested = true - ) - ) - val repository = recalculationJobRepository(jobStore) - val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) - val worker = PlanRecalculationWorker( - planningProperties = planningProperties(), - planRecalculationJobRepository = repository, - rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.PENDING, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - @Test - fun `recalculation markDone without rerunRequested moves job to done`() { - val spacecraftId = "SC-4" - val jobStore = linkedMapOf( - spacecraftId to PlanRecalculationJobEntity( - spacecraftId = spacecraftId, - status = JobStatus.PENDING - ) - ) - val repository = recalculationJobRepository(jobStore) - val rebuildPlanScheduleUseCase = mock(RebuildPlanScheduleUseCase::class.java) - val worker = PlanRecalculationWorker( - planningProperties = planningProperties(), - planRecalculationJobRepository = repository, - rebuildPlanScheduleUseCase = rebuildPlanScheduleUseCase, - transactionTemplate = transactionTemplate() - ) - - worker.processPendingJobs() - - val job = jobStore.getValue(spacecraftId) - assertEquals(JobStatus.DONE, job.status) - assertEquals(false, job.rerunRequested) - assertNull(job.lockedBy) - assertNull(job.lockedUntil) - } - - private fun visibilityUpsertQuery(): String = - VisibilityRefreshJobRepository::class.java - .getMethod( - "upsertPending", - String::class.java, - java.lang.Long::class.java, - String::class.java, - LocalDateTime::class.java - ) - .getAnnotation(Query::class.java) - .value - - private fun recalculationUpsertQuery(): String = - PlanRecalculationJobRepository::class.java - .getMethod( - "upsertPending", - String::class.java, - String::class.java, - LocalDateTime::class.java - ) - .getAnnotation(Query::class.java) - .value - - private fun visibilityJobRepository( - store: MutableMap - ): VisibilityRefreshJobRepository { - val repository = mock(VisibilityRefreshJobRepository::class.java) - `when`( - repository.findPendingForUpdate( - anyNonNull(), - org.mockito.ArgumentMatchers.anyInt() - ) - ).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val limit = invocation.arguments[1] as Int - store.values - .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } - .sortedBy { job -> job.requestedAt } - .take(limit) - } - `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> - Optional.ofNullable(store[invocation.arguments[0]]) - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val job = invocation.arguments[0] as VisibilityRefreshJobEntity - store[job.spacecraftId] = job - job - } - `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> - val jobs = invocation.arguments[0] as Iterable - jobs.forEach { job -> store[job.spacecraftId] = job } - jobs.toList() - } - return repository - } - - private fun recalculationJobRepository( - store: MutableMap - ): PlanRecalculationJobRepository { - val repository = mock(PlanRecalculationJobRepository::class.java) - `when`( - repository.findPendingForUpdate( - anyNonNull(), - org.mockito.ArgumentMatchers.anyInt() - ) - ).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val limit = invocation.arguments[1] as Int - store.values - .filter { job -> job.status == JobStatus.PENDING || isExpiredRunning(job.status, job.lockedUntil, now) } - .sortedBy { job -> job.requestedAt } - .take(limit) - } - `when`(repository.findById(anyNonNull())).thenAnswer { invocation -> - Optional.ofNullable(store[invocation.arguments[0]]) - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val job = invocation.arguments[0] as PlanRecalculationJobEntity - store[job.spacecraftId] = job - job - } - `when`(repository.saveAll(anyNonNull())).thenAnswer { invocation -> - val jobs = invocation.arguments[0] as Iterable - jobs.forEach { job -> store[job.spacecraftId] = job } - jobs.toList() - } - return repository - } - - private fun isExpiredRunning(status: JobStatus, lockedUntil: LocalDateTime?, now: LocalDateTime): Boolean = - status == JobStatus.RUNNING && - lockedUntil != null && - lockedUntil.isBefore(now) - - private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) - - private fun transactionTemplate(): TransactionTemplate = - TransactionTemplate( - object : PlatformTransactionManager { - override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus() - override fun commit(status: TransactionStatus) = Unit - override fun rollback(status: TransactionStatus) = Unit - } - ) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - org.mockito.ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index 5bbba48..57ebfad 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -1,9 +1,11 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.domain.InsertionPoint import space.nstart.pcp_tgu_service.domain.ObservationWindow import space.nstart.pcp_tgu_service.domain.SpacecraftPoints @@ -29,16 +31,16 @@ class PlanCalculationServiceTest { ) ) - assertEquals(4, plans.size) + assertChainInvariant(plans) + assertEquals(3, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) assertEquals(start.plusHours(16), plans[1].startTime) - assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plans[1].endTime) + assertEquals(start.plusHours(40).plusMinutes(10), plans[1].endTime) assertEquals("KPP-3", plans[1].kppId) assertEquals(start.plusHours(32), plans[2].startTime) assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime) - assertEquals(start.plusHours(40), plans[3].startTime) - assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime) + assertEquals("KPP-6", plans[2].kppId) } @Test @@ -59,14 +61,46 @@ class PlanCalculationServiceTest { val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first()) - assertEquals(3, plansAfterFirst.size) + assertChainInvariant(plansAfterFirst) + assertEquals(2, plansAfterFirst.size) assertEquals(start.plusHours(16), plansAfterFirst[0].startTime) - assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plansAfterFirst[0].endTime) + assertEquals(start.plusHours(40).plusMinutes(10), plansAfterFirst[0].endTime) assertEquals("KPP-3", plansAfterFirst[0].kppId) assertEquals(start.plusHours(32), plansAfterFirst[1].startTime) assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime) - assertEquals(start.plusHours(40), plansAfterFirst[2].startTime) - assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[2].endTime) + // Хвост не должен заканчиваться раньше принятого базового плана. + assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime }) + } + + @Test + fun `calculatePlansAfter rebuilds a fresh chain when the base plan is entirely in the past`() { + // Регресс: при обновлении окна тянутся из [now, now+Nд], и принятый базовый план + // со временем уходит в прошлое за левый край окна. Тогда хвост нельзя сопоставить + // с базой — но расписание не должно схлопнуться в пустоту (иначе supersede погасил + // бы всё будущее). Должна строиться свежая цепочка с самого раннего окна. + val service = createService(targetMinutes = 24 * 60L, deltaMinutes = 20L) + val futureStart = LocalDateTime.of(2026, 3, 10, 10, 0) + val spacecraftPoints = spacecraftPoints( + window("KPP-1", futureStart), + window("KPP-2", futureStart.plusHours(8)), + window("KPP-3", futureStart.plusHours(16)), + window("KPP-4", futureStart.plusHours(23).plusMinutes(55), 15), + window("KPP-5", futureStart.plusHours(24).plusMinutes(10)) + ) + // Базовый план целиком раньше любого доступного окна (состарился и выпал из горизонта). + val agedOutBasePlan = CalculatedPlan( + spacecraftId = "25544", + startTime = futureStart.minusDays(2), + endTime = futureStart.minusDays(1), + kppId = "KPP-OLD" + ) + + val plans = service.calculatePlansAfter(spacecraftPoints, agedOutBasePlan) + + assertChainInvariant(plans) + assertTrue(plans.isNotEmpty(), "расписание не должно схлопнуться в пустоту: $plans") + assertEquals(service.calculatePlans(spacecraftPoints), plans, "должна строиться та же свежая цепочка") + assertEquals(futureStart, plans.first().startTime) } @Test @@ -96,6 +130,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) @@ -135,6 +170,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusMinutes(55), plans[1].startTime) @@ -153,6 +189,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime) @@ -174,6 +211,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime) } @@ -192,12 +230,102 @@ class PlanCalculationServiceTest { val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray())) + assertChainInvariant(plans) assertTrue(plans.isNotEmpty()) assertTrue(plans.size <= simultaneousWindows.size) assertEquals(start, plans.first().startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime) } + @Test + fun `calculatePlans never nests plans when several stations see the same passes (Emissio case)`() { + // Прод-конфигурация: цель 3 часа, допуск 1 час. + val service = createService(targetMinutes = 180L, deltaMinutes = 60L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + // Пролёты примерно раз в час, каждый виден тремя станциями почти одновременно + // (это и есть корень вложенности у Emissio): одинаковые/близкие старты, дублирующиеся ЗРВ. + val stations = listOf("ST-A", "ST-B", "ST-C") + val windows = buildList { + for (passIndex in 0..8) { + stations.forEachIndexed { stationOffset, stationId -> + add( + window( + pointId = "$stationId-$passIndex", + startTime = start.plusHours(passIndex.toLong()).plusMinutes(stationOffset.toLong()) + ) + ) + } + } + } + + val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray())) + + assertChainInvariant(plans) + assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}") + } + + @Test + fun `calculatePlans never leaves a gap between plans across a visibility gap`() { + val service = createService(targetMinutes = 180L, deltaMinutes = 60L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + // Воспроизводит форму с экрана Reflexio: раннее окно, затем большой провал + // видимости, затем далёкий кластер. Провал допустимо перекрыть одним длинным + // планом, но разрыва между планами быть не должно. + val gap = start.plusDays(3) + val windows = buildList { + for (minutes in 0..240 step 20) { + add(window("NEAR-$minutes", start.plusMinutes(minutes.toLong()))) + } + for (minutes in 0..240 step 20) { + add(window("FAR-$minutes", gap.plusMinutes(minutes.toLong()))) + } + } + + val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray())) + + // Главное требование: каждый следующий план стартует строго внутри предыдущего (без разрыва). + assertChainInvariant(plans) + // Провал перекрыт одним длинным планом: есть план, начинающийся до провала и кончающийся после. + assertTrue( + plans.any { plan -> plan.startTime < gap && plan.endTime > gap }, + "the visibility gap must be bridged by a single plan: $plans" + ) + // И цепочка действительно доходит до далёкого кластера, а не обрывается до провала. + assertTrue( + plans.any { plan -> plan.startTime >= gap }, + "the chain must reach the far cluster after the gap: $plans" + ) + } + + /** + * Проверяет главный инвариант цепочки: старт и конец строго возрастают, соседние планы + * строго перекрываются (без разрыва), и ни один план не лежит целиком внутри другого. + */ + private fun assertChainInvariant(plans: List) { + for (index in 1 until plans.size) { + assertTrue( + plans[index].startTime > plans[index - 1].startTime, + "start must strictly increase at index $index: $plans" + ) + assertTrue( + plans[index].endTime > plans[index - 1].endTime, + "end must strictly increase at index $index: $plans" + ) + assertTrue( + plans[index].startTime < plans[index - 1].endTime, + "plans must strictly overlap (no gap) at index $index: $plans" + ) + } + for (outer in plans.indices) { + for (inner in plans.indices) { + if (outer == inner) continue + val contains = plans[outer].startTime <= plans[inner].startTime && + plans[inner].endTime <= plans[outer].endTime + assertFalse(contains, "plan $inner must not be nested in plan $outer: $plans") + } + } + } + private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService = PlanCalculationService( PlanningProperties( @@ -210,7 +338,10 @@ class PlanCalculationServiceTest { externalUpdateFixedDelayMs = 0, notificationCheckFixedDelayMs = 30_000, minReissueLeadMinutes = 5, - workerLockTtlSeconds = 300 + reissuePrepMinutes = 30, + workerLockTtlSeconds = 300, + waitingDecisionGraceMinutes = 30, + layinFreezeMinutes = 10 ) ) diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt index 32fd2f4..382f53a 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanIssueAndDecisionFlowTest.kt @@ -1,13 +1,18 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionStatus @@ -16,6 +21,7 @@ import org.springframework.transaction.support.TransactionTemplate import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.PlanDecision import space.nstart.pcp_tgu_service.dto.PlanDecisionMessage +import space.nstart.pcp_tgu_service.dto.PlanExecutionFeedbackMessage import space.nstart.pcp_tgu_service.dto.ProcessStartResponse import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus @@ -26,6 +32,7 @@ import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository import space.nstart.pcp_tgu_service.repository.ProcessedPlanDecisionEventRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository import java.time.LocalDateTime import java.util.Optional import java.util.UUID @@ -33,258 +40,246 @@ import java.util.UUID class PlanIssueAndDecisionFlowTest { @Test - fun `issue worker creates one active attempt and repeated issue of same plan creates new attempt after state is cleared`() { + fun `repeated manual issue of the same plan supersedes its in-flight attempt`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) + // Старт через 12 часов — далеко за порогом заморозки, перевыдача того же плана разрешена. + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.PLANNED) val planStore = linkedMapOf(plan.planId to plan) val attemptStore = linkedMapOf() val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) - val plannedRepository = plannedRepository(planStore) - val attemptRepository = attemptRepository(attemptStore) - val stateRepository = stateRepository(state) - val starter = mock(BpmnPlanIssueProcessStarter::class.java) - `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> - val p = invocation.arguments[0] as PlannedPlanEntity - val a = invocation.arguments[1] as PlanIssueAttemptEntity - ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) - } - val worker = IssueDuePlanWorker( - planningProperties = planningProperties(), - plannedPlanRepository = plannedRepository, - planIssueAttemptRepository = attemptRepository, - spacecraftPlanningStateJpaRepository = stateRepository, - bpmnPlanIssueProcessStarter = starter, - transactionTemplate = transactionTemplate() - ) + val worker = issueWorker(planStore, attemptStore, state) val firstResponse = worker.issuePlanNow(plan.planId) - val blockedResponse = runCatching { worker.issuePlanNow(plan.planId) }.exceptionOrNull() - state.activePlanId = null - state.activeAttemptId = null - plan.status = PlannedPlanStatus.PLANNED + // Тот же план, attempt #1 ещё «в полёте» — ручная перевыдача не блокируется, а замещает её. val secondResponse = worker.issuePlanNow(plan.planId) - assertEquals(1, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.attemptNo) - assertEquals(2, attemptStore.values.first { it.attemptId == secondResponse.attemptId }.attemptNo) + assertEquals(1, attemptStore.getValue(firstResponse.attemptId).attemptNo) + assertEquals(2, attemptStore.getValue(secondResponse.attemptId).attemptNo) assertNotEquals(firstResponse.attemptId, secondResponse.attemptId) - assertEquals(PlanIssueAttemptStatus.STARTED, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.status) - assertEquals(firstResponse.processInstanceKey, attemptStore.values.first { it.attemptId == firstResponse.attemptId }.processInstanceKey) + assertEquals(secondResponse.attemptId, state.activeAttemptId) assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) - assertTrue(blockedResponse?.message?.startsWith("404 NOT_FOUND") == true) } @Test - fun `mark started does not roll accepted plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.ACCEPTED - planStore.getValue(response.planId).status = PlannedPlanStatus.ACCEPTED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `mark started does not roll rejected planned plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED - planStore.getValue(response.planId).status = PlannedPlanStatus.PLANNED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.PLANNED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `mark started does not roll rejected expired plan and attempt back after BPMN start race`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13)) - val planStore = linkedMapOf(plan.planId to plan) - val attemptStore = linkedMapOf() - val worker = issueWorker(planStore, attemptStore) { response -> - attemptStore.getValue(response.attemptId).status = PlanIssueAttemptStatus.REJECTED - planStore.getValue(response.planId).status = PlannedPlanStatus.EXPIRED - } - - val response = worker.issuePlanNow(plan.planId) - val attempt = attemptStore.getValue(response.attemptId) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.EXPIRED, plan.status) - assertEquals(response.processInstanceKey, attempt.processInstanceKey) - assertTrue(attempt.startedAt != null) - } - - @Test - fun `accepted decision sets base plan clears active ids and enqueues recalculation once`() { + fun `accepted decision moves plan to AWAITING_LAYIN without anchoring base or touching slots`() { val spacecraftId = "25544" val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase(plan, attempt, state, processedRepository, recalculationJobService, slotsUpdateJobService) + val useCase = decisionUseCase(plan, attempt, state, processedRepository, slotsUpdateJobService) val message = decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED) useCase.handle(message) useCase.handle(message) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) - assertEquals(plan.planId, state.currentBasePlanId) - assertEquals(null, state.activePlanId) - assertEquals(null, state.activeAttemptId) - verify(recalculationJobService).enqueue(spacecraftId, "PLAN_ACCEPTED") - verify(slotsUpdateJobService).enqueue(plan.planId) - } - - @Test - fun `rejected before cutoff reopens same plan without direct new attempt`() { - val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) - val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) - - useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) - - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(PlannedPlanStatus.PLANNED, plan.status) - assertEquals(null, state.activeAttemptId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) + // ACCEPT больше НЕ делает план базой и не двигает версию — это делает только LAID_IN. + assertNull(state.currentBasePlanId) + assertEquals(0, state.chainVersion) + // Слоты проставляются на закладке (LAID_IN), а не на ACCEPT. verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test - fun `rejected after cutoff expires plan and promotes next planned plan`() { + fun `layin confirmed makes plan the base bumps version and supersedes overlapping competitors`() { val spacecraftId = "25544" - val currentPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusMinutes(2), LocalDateTime.now().plusMinutes(20), PlannedPlanStatus.WAITING_DECISION) - val nextPlan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(1), LocalDateTime.now().plusHours(2), PlannedPlanStatus.PLANNED) - val attempt = attempt(currentPlan.planId, status = PlanIssueAttemptStatus.STARTED) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = currentPlan.planId, activeAttemptId = attempt.attemptId) - val planStore = linkedMapOf(currentPlan.planId to currentPlan, nextPlan.planId to nextPlan) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) - val useCase = decisionUseCase( - plan = currentPlan, - attempt = attempt, - state = state, - plannedRepository = plannedRepository(planStore), - recalculationJobService = recalculationJobService + val laidIn = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN) + // Конкурент-коррекция того же окна (пересекается) — должна уйти в SUPERSEDED. + val competitor = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) + // Несвязанное будущее окно — не трогаем. + val unrelated = plannedPlan(spacecraftId, time(20), time(21), PlannedPlanStatus.AWAITING_LAYIN) + val store = linkedMapOf(laidIn.planId to laidIn, competitor.planId to competitor, unrelated.planId to unrelated) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, chainVersion = 4) + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository, + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(store), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, + planningProperties = planningProperties() ) + val message = feedbackMessage(spacecraftId, laidIn.planId) - useCase.handle(decisionMessage(spacecraftId, currentPlan.planId, attempt.attemptId, PlanDecision.REJECTED)) + useCase.handleLayinConfirmed(message) + useCase.handleLayinConfirmed(message) // повтор того же eventId — no-op (идемпотентность) - assertEquals(PlannedPlanStatus.EXPIRED, currentPlan.status) - assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) - assertEquals(null, state.activeAttemptId) - assertEquals(true, nextPlan.issueNotBefore != null) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertEquals(PlannedPlanStatus.LAID_IN, laidIn.status) + assertEquals(laidIn.planId, state.currentBasePlanId) + assertEquals(5, state.chainVersion) + assertEquals(PlannedPlanStatus.SUPERSEDED, competitor.status) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, unrelated.status) + // Слоты проставляются по факту закладки — ровно один раз (второй вызов идемпотентен). + verify(slotsUpdateJobService).enqueue(laidIn.planId) } @Test - fun `stale decision records inactive attempt but does not change plan lifecycle or active state`() { + fun `layin confirmed rejected with 409 when another plan is already laid in within 10 minutes`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.WAITING_DECISION) - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.STARTED) - val activeAttemptId = UUID.randomUUID() - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = activeAttemptId) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) - val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService + val target = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN) + // Уже заложенный план со стартом на 5 минут позже — в пределах ±10 мин → подтвердить нельзя. + val alreadyLaidIn = plannedPlan(spacecraftId, time(12).plusMinutes(5), time(13), PlannedPlanStatus.LAID_IN) + val store = linkedMapOf(target.planId to target, alreadyLaidIn.planId to alreadyLaidIn) + val state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = alreadyLaidIn.planId, + chainVersion = 1 ) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository(), + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(store), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() + ) + + val ex = assertThrows(ResponseStatusException::class.java) { + useCase.handleLayinConfirmed(feedbackMessage(spacecraftId, target.planId)) + } + + assertEquals(HttpStatus.CONFLICT, ex.statusCode) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, target.status) // не заложен + assertEquals(1, state.chainVersion) // версия цепочки не двигалась + } + + @Test + fun `rejected decision marks plan REJECTED and pauses the chain without arming reissue`() { + val spacecraftId = "25544" + val plan = plannedPlan(spacecraftId, LocalDateTime.now().plusHours(2), LocalDateTime.now().plusHours(3), PlannedPlanStatus.WAITING_DECISION) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, activePlanId = plan.planId, activeAttemptId = attempt.attemptId) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) + + useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) + + assertEquals(PlanIssueAttemptStatus.REJECTED, attempt.status) + assertEquals(PlannedPlanStatus.REJECTED, plan.status) + // Активная выдача снята, перевыдача НЕ взведена, цепочка на паузе — ждёт ручного исправленного плана. + assertNull(state.activePlanId) + assertNull(state.activeAttemptId) + assertNull(state.urgentReissueFrom) + assertTrue(state.pausedAfterReject) + verify(slotsUpdateJobService, never()).enqueue(plan.planId) + } + + @Test + fun `layin failed on current base marks LAYIN_FAILED and arms urgent reissue idempotently`() { + assertNegativeFeedbackArmsReissue(PlannedPlanStatus.LAYIN_FAILED) { useCase, message -> + useCase.handleLayinFailed(message) + } + } + + @Test + fun `plan not accepted on current base marks NOT_ACCEPTED and arms urgent reissue idempotently`() { + assertNegativeFeedbackArmsReissue(PlannedPlanStatus.NOT_ACCEPTED) { useCase, message -> + useCase.handleNotAccepted(message) + } + } + + @Test + fun `negative feedback for a plan that is not the current base is ignored`() { + val spacecraftId = "25544" + val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN) + val stalePlan = plannedPlan(spacecraftId, time(2), time(3), PlannedPlanStatus.REJECTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository(), + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base, stalePlan.planId to stalePlan)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() + ) + + useCase.handleLayinFailed(feedbackMessage(spacecraftId, stalePlan.planId)) + + assertEquals(PlannedPlanStatus.REJECTED, stalePlan.status) + assertNull(state.urgentReissueFrom) + } + + @Test + fun `decision for a superseded plan records the attempt but does not move the chain`() { + val spacecraftId = "25544" + // План уже вытеснен заложенной коррекцией (SUPERSEDED, не in-flight) — запоздавшее решение + // по его attempt записывается, но цепочку не двигает и базу не трогает. + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.SUPERSEDED) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) + val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.ACCEPTED)) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(PlannedPlanStatus.WAITING_DECISION, plan.status) - assertEquals(plan.planId, state.activePlanId) - assertEquals(activeAttemptId, state.activeAttemptId) - assertEquals(null, state.currentBasePlanId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_ACCEPTED") + assertEquals(PlannedPlanStatus.SUPERSEDED, plan.status) + assertNull(state.currentBasePlanId) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } @Test fun `terminal attempt is not reprocessed by new decision event`() { val spacecraftId = "25544" - val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.ACCEPTED) - val oldEventId = UUID.randomUUID() - val attempt = attempt(plan.planId, status = PlanIssueAttemptStatus.ACCEPTED) - attempt.decisionEventId = oldEventId - val state = SpacecraftPlanningStateEntity( - spacecraftId = spacecraftId, - currentBasePlanId = plan.planId, - activePlanId = null, - activeAttemptId = null - ) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) + val plan = plannedPlan(spacecraftId, time(12), time(13), PlannedPlanStatus.AWAITING_LAYIN) + val attempt = attempt(plan.planId, PlanIssueAttemptStatus.ACCEPTED) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = plan.planId) val slotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) - val useCase = decisionUseCase( - plan = plan, - attempt = attempt, - state = state, - recalculationJobService = recalculationJobService, - slotsUpdateJobService = slotsUpdateJobService - ) + val useCase = decisionUseCase(plan, attempt, state, slotsUpdateJobService = slotsUpdateJobService) useCase.handle(decisionMessage(spacecraftId, plan.planId, attempt.attemptId, PlanDecision.REJECTED)) assertEquals(PlanIssueAttemptStatus.ACCEPTED, attempt.status) - assertEquals(oldEventId, attempt.decisionEventId) - assertEquals(PlannedPlanStatus.ACCEPTED, plan.status) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, plan.status) assertEquals(plan.planId, state.currentBasePlanId) - verify(recalculationJobService, never()).enqueue(spacecraftId, "PLAN_REJECTED_AFTER_CUTOFF") + assertNull(state.urgentReissueFrom) verify(slotsUpdateJobService, never()).enqueue(plan.planId) } + private fun assertNegativeFeedbackArmsReissue( + expectedStatus: PlannedPlanStatus, + invoke: (HandlePlanDecisionUseCase, PlanExecutionFeedbackMessage) -> Unit + ) { + val spacecraftId = "25544" + val base = plannedPlan(spacecraftId, time(20), time(44), PlannedPlanStatus.AWAITING_LAYIN) + val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId) + val processedRepository = mock(ProcessedPlanDecisionEventRepository::class.java) + `when`(processedRepository.insertIfAbsent(anyNonNull(), anyNonNull())).thenReturn(1).thenReturn(0) + val useCase = HandlePlanDecisionUseCase( + processedPlanDecisionEventRepository = processedRepository, + planIssueAttemptRepository = attemptRepository(linkedMapOf()), + plannedPlanRepository = plannedRepository(linkedMapOf(base.planId to base)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + acceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), + planningProperties = planningProperties() + ) + val message = feedbackMessage(spacecraftId, base.planId) + + invoke(useCase, message) + invoke(useCase, message) + + assertEquals(expectedStatus, base.status) + assertNotNull(state.urgentReissueFrom) + } + private fun decisionUseCase( plan: PlannedPlanEntity, attempt: PlanIssueAttemptEntity, state: SpacecraftPlanningStateEntity, processedRepository: ProcessedPlanDecisionEventRepository = processedRepository(), - recalculationJobService: PlanRecalculationJobService = mock(PlanRecalculationJobService::class.java), - slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java), - plannedRepository: PlannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)) + slotsUpdateJobService: AcceptedPlanSlotsUpdateJobService = mock(AcceptedPlanSlotsUpdateJobService::class.java) ): HandlePlanDecisionUseCase = HandlePlanDecisionUseCase( processedPlanDecisionEventRepository = processedRepository, planIssueAttemptRepository = attemptRepository(linkedMapOf(attempt.attemptId to attempt)), - plannedPlanRepository = plannedRepository, + plannedPlanRepository = plannedRepository(linkedMapOf(plan.planId to plan)), spacecraftPlanningStateJpaRepository = stateRepository(state), - planRecalculationJobService = recalculationJobService, acceptedPlanSlotsUpdateJobService = slotsUpdateJobService, planningProperties = planningProperties() ) @@ -292,20 +287,21 @@ class PlanIssueAndDecisionFlowTest { private fun issueWorker( planStore: MutableMap, attemptStore: MutableMap, - beforeStartReturns: (ProcessStartResponse) -> Unit + state: SpacecraftPlanningStateEntity ): IssueDuePlanWorker { val starter = mock(BpmnPlanIssueProcessStarter::class.java) `when`(starter.startProcess(anyNonNull(), anyNonNull())).thenAnswer { invocation -> val p = invocation.arguments[0] as PlannedPlanEntity val a = invocation.arguments[1] as PlanIssueAttemptEntity ProcessStartResponse(100L + a.attemptNo, 200L, "createSatelliteMission", 1, p.planId, a.attemptId) - .also(beforeStartReturns) } return IssueDuePlanWorker( planningProperties = planningProperties(), + spacecraftSnapshotRepository = mock(SpacecraftSnapshotRepository::class.java), plannedPlanRepository = plannedRepository(planStore), planIssueAttemptRepository = attemptRepository(attemptStore), - spacecraftPlanningStateJpaRepository = stateRepository(SpacecraftPlanningStateEntity(spacecraftId = planStore.values.first().spacecraftId)), + spacecraftPlanningStateJpaRepository = stateRepository(state), + planProjectionService = mock(PlanProjectionService::class.java), bpmnPlanIssueProcessStarter = starter, transactionTemplate = transactionTemplate() ) @@ -326,26 +322,18 @@ class PlanIssueAndDecisionFlowTest { store[plan.planId] = plan plan } - `when`(repository.findDueForUpdate(anyNonNull(), anyNonNull(), org.mockito.ArgumentMatchers.anyInt())).thenAnswer { invocation -> - val now = invocation.arguments[0] as LocalDateTime - val issueBefore = invocation.arguments[1] as LocalDateTime - store.values.filter { plan -> - plan.status == PlannedPlanStatus.PLANNED && - (plan.issueNotBefore == null || !plan.issueNotBefore!!.isAfter(now)) && - !plan.startTime.isAfter(issueBefore) && - plan.startTime.isAfter(now) - }.sortedBy { plan -> plan.startTime } + `when`(repository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + val sc = invocation.arguments[0] as String + @Suppress("UNCHECKED_CAST") + val statuses = invocation.arguments[1] as Collection + store.values.filter { plan -> plan.spacecraftId == sc && plan.status in statuses } + } + `when`(repository.saveAll(anyNonNull>())).thenAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + val plans = invocation.arguments[0] as Iterable + plans.forEach { plan -> store[plan.planId] = plan } + plans.toMutableList() } - `when`(repository.findTopBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values - .filter { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.status == invocation.arguments[1] && - plan.startTime > invocation.arguments[2] as LocalDateTime - } - .minByOrNull { plan -> plan.startTime } - } return repository } @@ -376,7 +364,7 @@ class PlanIssueAndDecisionFlowTest { spacecraftId: String, start: LocalDateTime, end: LocalDateTime, - status: PlannedPlanStatus = PlannedPlanStatus.PLANNED + status: PlannedPlanStatus ): PlannedPlanEntity = PlannedPlanEntity( planId = UUID.randomUUID(), @@ -412,10 +400,19 @@ class PlanIssueAndDecisionFlowTest { reason = null ) + private fun feedbackMessage(spacecraftId: String, planId: UUID): PlanExecutionFeedbackMessage = + PlanExecutionFeedbackMessage( + eventId = UUID.randomUUID(), + spacecraftId = spacecraftId, + planId = planId, + occurredAt = LocalDateTime.now(), + reason = null + ) + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) + PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 30, 300, 30, 10) private fun transactionTemplate(): TransactionTemplate = TransactionTemplate( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt new file mode 100644 index 0000000..995c445 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanProjectionServiceTest.kt @@ -0,0 +1,213 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +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 space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.domain.SpacecraftPoints +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity +import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity +import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID + +class PlanProjectionServiceTest { + + private val spacecraftId = "25544" + + @Test + fun `returns empty projection when there is no snapshot`() { + val calculation = mock(PlanCalculationService::class.java) + val service = service(snapshot = null, calculation = calculation) + + val result = service.project(spacecraftId) + + assertTrue(result.isEmpty()) + verify(calculation, never()).calculatePlans(anyNonNull()) + } + + @Test + fun `builds a fresh chain when there is no base plan`() { + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(1), time(4))) + `when`(calculation.calculatePlans(anyNonNull())).thenReturn(expected) + val service = service(state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId), calculation = calculation) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + verify(calculation).calculatePlans(anyNonNull()) + verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull()) + } + + @Test + fun `builds the tail after the current base plan`() { + val base = PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = time(0), + endTime = time(3), + kppId = "KPP-1", + status = PlannedPlanStatus.AWAITING_LAYIN + ) + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(2), time(5))) + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenReturn(expected) + val service = service( + state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, currentBasePlanId = base.planId), + basePlan = base, + calculation = calculation + ) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + verify(calculation).calculatePlansAfter(anyNonNull(), anyNonNull()) + verify(calculation, never()).calculatePlans(anyNonNull()) + } + + @Test + fun `anchors the tail after the in-flight issued plan, not the accepted base`() { + val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN) + val inFlight = persistedPlan(time(2), time(5), "KPP-inflight", PlannedPlanStatus.WAITING_DECISION) + val calculation = mock(PlanCalculationService::class.java) + val expected = listOf(plan(time(4), time(7))) + var anchor: CalculatedPlan? = null + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + anchor = invocation.getArgument(1) + expected + } + val service = service( + state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = base.planId, + activePlanId = inFlight.planId + ), + plansById = mapOf(base.planId to base, inFlight.planId to inFlight), + calculation = calculation + ) + + val result = service.project(spacecraftId) + + assertEquals(expected, result) + assertEquals(inFlight.startTime, anchor?.startTime) + assertEquals(inFlight.endTime, anchor?.endTime) + assertEquals(inFlight.kppId, anchor?.kppId) + } + + @Test + fun `anchors on the furthest accepted window among several not-yet-laid-in plans`() { + // Принят и базовый, и более поздняя коррекция (оба AWAITING_LAYIN) — якорь берём по крайнему + // окну (endTime), чтобы форвард-цепочка строилась после самого дальнего зафиксированного слота. + val base = persistedPlan(time(0), time(3), "KPP-base", PlannedPlanStatus.AWAITING_LAYIN) + val accepted = persistedPlan(time(2), time(5), "KPP-accepted", PlannedPlanStatus.AWAITING_LAYIN) + val calculation = mock(PlanCalculationService::class.java) + var anchor: CalculatedPlan? = null + `when`(calculation.calculatePlansAfter(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + anchor = invocation.getArgument(1) + emptyList() + } + val service = service( + state = SpacecraftPlanningStateEntity( + spacecraftId = spacecraftId, + currentBasePlanId = base.planId + ), + plansById = mapOf(base.planId to base, accepted.planId to accepted), + calculation = calculation + ) + + service.project(spacecraftId) + + assertEquals(accepted.startTime, anchor?.startTime) + assertEquals(accepted.kppId, anchor?.kppId) + } + + @Test + fun `returns empty projection when the chain is paused after reject`() { + val calculation = mock(PlanCalculationService::class.java) + val service = service( + state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId, pausedAfterReject = true), + calculation = calculation + ) + + val result = service.project(spacecraftId) + + assertTrue(result.isEmpty()) + verify(calculation, never()).calculatePlans(anyNonNull()) + verify(calculation, never()).calculatePlansAfter(anyNonNull(), anyNonNull()) + } + + private fun service( + snapshot: SpacecraftVisibilitySnapshotEntity? = snapshot(), + state: SpacecraftPlanningStateEntity? = null, + basePlan: PlannedPlanEntity? = null, + plansById: Map? = null, + calculation: PlanCalculationService + ): PlanProjectionService { + val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) + `when`(snapshotRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(snapshot)) + val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java) + `when`(stateRepository.findById(spacecraftId)).thenReturn(Optional.ofNullable(state)) + val plannedRepository = mock(PlannedPlanRepository::class.java) + if (plansById != null) { + `when`(plannedRepository.findById(anyNonNull())).thenAnswer { invocation -> + Optional.ofNullable(plansById[invocation.getArgument(0)]) + } + `when`(plannedRepository.findAllBySpacecraftIdAndStatusIn(anyNonNull(), anyNonNull())).thenAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + val statuses = invocation.getArgument>(1) + plansById.values.filter { plan -> plan.status in statuses } + } + } else { + `when`(plannedRepository.findById(anyNonNull())).thenReturn(Optional.ofNullable(basePlan)) + } + val parser = mock(VisibilityPayloadParser::class.java) + `when`(parser.parse(anyNonNull(), anyNonNull())).thenReturn(SpacecraftPoints(spacecraftId, emptyList())) + return PlanProjectionService(snapshotRepository, stateRepository, plannedRepository, parser, calculation) + } + + private fun persistedPlan( + start: LocalDateTime, + end: LocalDateTime, + kppId: String, + status: PlannedPlanStatus + ): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = kppId, + status = status + ) + + private fun snapshot(): SpacecraftVisibilitySnapshotEntity = + SpacecraftVisibilitySnapshotEntity( + spacecraftId = spacecraftId, + noradId = 25544L, + payloadJson = "[]", + payloadHash = "hash", + fetchedAt = LocalDateTime.now() + ) + + private fun plan(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt new file mode 100644 index 0000000..f78574d --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanQueryServiceTest.kt @@ -0,0 +1,127 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity +import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus +import space.nstart.pcp_tgu_service.entity.SpacecraftSnapshotEntity +import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.LocalDateTime +import java.util.UUID + +class PlanQueryServiceTest { + + private val spacecraftId = "25544" + + @Test + fun `getAllPlans merges persisted with projection and dedupes by business key`() { + val persisted = plannedPlan(time(1), time(4), PlannedPlanStatus.AWAITING_LAYIN) + // Та же бизнес-ключ-тройка, что у persisted — должна быть выкинута из проекции. + val duplicateProjected = calculated(persisted.startTime, persisted.endTime) + val freshProjected = calculated(time(5), time(8)) + + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(persisted)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn( + listOf(SpacecraftSnapshotEntity(spacecraftId = spacecraftId, noradId = 25544L)) + ) + val projection = mock(PlanProjectionService::class.java) + `when`(projection.project(spacecraftId)).thenReturn(listOf(duplicateProjected, freshProjected)) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertEquals(2, result.size) + val persistedResponse = result.first { it.startTime == persisted.startTime } + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, persistedResponse.status) + val projectedResponse = result.first { it.startTime == freshProjected.startTime } + assertEquals(PlannedPlanStatus.PLANNED, projectedResponse.status) + // Отсортировано по startTime. + assertTrue(result[0].startTime <= result[1].startTime) + } + + @Test + fun `getAllPlans drops persisted plans older than the history window`() { + val old = plannedPlan(time(-240), time(-239), PlannedPlanStatus.ACCEPTED) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(old)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(emptyList()) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertTrue(result.isEmpty()) + } + + @Test + fun `getAllPlans excludes DRAFT plans from the result`() { + val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT) + val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + `when`(snapshotRepository.findAllByActiveTrue()).thenReturn(emptyList()) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getAllPlans(historyDays = 7) + + assertEquals(1, result.size) + assertEquals(PlannedPlanStatus.AWAITING_LAYIN, result.single().status) + } + + @Test + fun `getDraftPlans returns only DRAFT plans`() { + val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT) + val active = plannedPlan(time(5), time(8), PlannedPlanStatus.AWAITING_LAYIN) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllByOrderByStartTime()).thenReturn(listOf(draft, active)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getDraftPlans(spacecraftId = null) + + assertEquals(1, result.size) + assertEquals(PlannedPlanStatus.DRAFT, result.single().status) + assertEquals(draft.planId, result.single().planId) + } + + @Test + fun `getDraftPlans filters by spacecraftId when provided`() { + val draft = plannedPlan(time(2), time(5), PlannedPlanStatus.DRAFT) + val plannedRepository = mock(PlannedPlanRepository::class.java) + `when`(plannedRepository.findAllBySpacecraftIdOrderByStartTime(spacecraftId)).thenReturn(listOf(draft)) + val snapshotRepository = mock(SpacecraftSnapshotRepository::class.java) + val projection = mock(PlanProjectionService::class.java) + + val service = PlanQueryService(plannedRepository, snapshotRepository, projection) + val result = service.getDraftPlans(spacecraftId = spacecraftId) + + assertEquals(1, result.size) + assertEquals(draft.planId, result.single().planId) + } + + private fun plannedPlan(start: LocalDateTime, end: LocalDateTime, status: PlannedPlanStatus): PlannedPlanEntity = + PlannedPlanEntity( + planId = UUID.randomUUID(), + spacecraftId = spacecraftId, + startTime = start, + endTime = end, + kppId = "KPP-1", + status = status + ) + + private fun calculated(start: LocalDateTime, end: LocalDateTime): CalculatedPlan = + CalculatedPlan(spacecraftId = spacecraftId, startTime = start, endTime = end, kppId = "KPP-1") + + private fun time(hour: Int): LocalDateTime = LocalDateTime.now().plusHours(hour.toLong()) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt deleted file mode 100644 index 3b3beea..0000000 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package space.nstart.pcp_tgu_service.service - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock -import org.mockito.Mockito.`when` -import space.nstart.pcp_tgu_service.config.PlanningProperties -import space.nstart.pcp_tgu_service.domain.CalculatedPlan -import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity -import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity -import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity -import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser -import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository -import java.time.LocalDateTime -import java.util.Optional -import java.util.UUID - -class RebuildPlanScheduleUseCaseTest { - - @Test - fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { - val spacecraftId = "25544" - val planStore = linkedMapOf() - val plannedRepository = plannedRepository(planStore) - val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) - val stateRepository = stateRepository(state) - val snapshotRepository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - `when`(snapshotRepository.findById(spacecraftId)).thenReturn( - Optional.of( - SpacecraftVisibilitySnapshotEntity( - spacecraftId = spacecraftId, - noradId = 25544L, - payloadJson = rawPayload(), - payloadHash = "hash", - fetchedAt = LocalDateTime.now() - ) - ) - ) - val calculationService = mock(PlanCalculationService::class.java) - val baseTime = LocalDateTime.now().plusHours(1) - val firstPlan = CalculatedPlan(spacecraftId, baseTime, baseTime.plusHours(2), "KPP-1") - val secondPlan = CalculatedPlan(spacecraftId, baseTime.plusHours(3), baseTime.plusHours(5), "KPP-2") - `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan, secondPlan)) - val service = RebuildPlanScheduleUseCase( - spacecraftVisibilitySnapshotRepository = snapshotRepository, - spacecraftPlanningStateJpaRepository = stateRepository, - plannedPlanRepository = plannedRepository, - visibilityPayloadParser = VisibilityPayloadParser(jacksonObjectMapper().findAndRegisterModules()), - planCalculationService = calculationService, - planningProperties = planningProperties() - ) - - service.rebuild(spacecraftId) - val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId - - `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) - service.rebuild(spacecraftId) - - assertEquals(firstPlanId, planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId) - assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.values.first { plan -> plan.kppId == "KPP-2" }.status) - assertNotEquals(planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId, planStore.values.first { plan -> plan.kppId == "KPP-2" }.planId) - assertEquals(2, state.chainVersion) - } - - private fun plannedRepository(store: MutableMap): PlannedPlanRepository { - val repository = mock(PlannedPlanRepository::class.java) - `when`( - repository.upsertPlannedByBusinessKey( - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - anyNonNull(), - org.mockito.ArgumentMatchers.anyLong(), - anyNonNull() - ) - ).thenAnswer { invocation -> - val planId = invocation.arguments[0] as UUID - val spacecraftId = invocation.arguments[1] as String - val startTime = invocation.arguments[2] as LocalDateTime - val endTime = invocation.arguments[3] as LocalDateTime - val kppId = invocation.arguments[4] as String - val issueNotBefore = invocation.arguments[5] as LocalDateTime - val basePlanId = invocation.arguments[6] as UUID? - val chainVersion = invocation.arguments[7] as Long - val now = invocation.arguments[8] as LocalDateTime - val existing = store.values.firstOrNull { plan -> - plan.spacecraftId == spacecraftId && - plan.startTime == startTime && - plan.endTime == endTime && - plan.kppId == kppId - } - - if (existing != null) { - if (existing.status == PlannedPlanStatus.SUPERSEDED || existing.status == PlannedPlanStatus.EXPIRED) { - existing.status = PlannedPlanStatus.PLANNED - } - if (existing.status == PlannedPlanStatus.PLANNED) { - existing.issueNotBefore = issueNotBefore - existing.basePlanId = basePlanId - existing.chainVersion = chainVersion - existing.updatedAt = now - } - existing - } else { - PlannedPlanEntity( - planId = planId, - spacecraftId = spacecraftId, - startTime = startTime, - endTime = endTime, - kppId = kppId, - status = PlannedPlanStatus.PLANNED, - issueNotBefore = issueNotBefore, - basePlanId = basePlanId, - chainVersion = chainVersion, - createdAt = now, - updatedAt = now - ).also { plan -> store[plan.planId] = plan } - } - } - `when`(repository.findBySpacecraftIdAndStartTimeAndEndTimeAndKppId(anyNonNull(), anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values.firstOrNull { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.startTime == invocation.arguments[1] && - plan.endTime == invocation.arguments[2] && - plan.kppId == invocation.arguments[3] - } - } - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> - val plan = invocation.arguments[0] as PlannedPlanEntity - store[plan.planId] = plan - plan - } - `when`(repository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) - .thenAnswer { invocation -> - store.values - .filter { plan -> - plan.spacecraftId == invocation.arguments[0] && - plan.status == invocation.arguments[1] && - plan.startTime > invocation.arguments[2] as LocalDateTime - } - .sortedBy { plan -> plan.startTime } - } - return repository - } - - private fun stateRepository(state: SpacecraftPlanningStateEntity): SpacecraftPlanningStateJpaRepository { - val repository = mock(SpacecraftPlanningStateJpaRepository::class.java) - `when`(repository.findById(state.spacecraftId)).thenReturn(Optional.of(state)) - `when`(repository.lockBySpacecraftId(state.spacecraftId)).thenReturn(state) - `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - return repository - } - - private fun rawPayload(): String = - """[{"noradId":25544,"stationId":7,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}]""" - - private fun planningProperties(): PlanningProperties = - PlanningProperties(30, 180, 60, false, 3, 14, 0, 30_000, 5, 300) - - @Suppress("UNCHECKED_CAST") - private fun anyNonNull(): T { - org.mockito.ArgumentMatchers.any() - return null as T - } -} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt new file mode 100644 index 0000000..5eaab3e --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SyncSpacecraftFromNsiUseCaseTest.kt @@ -0,0 +1,91 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.springframework.transaction.TransactionStatus +import org.springframework.transaction.support.TransactionTemplate +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.repository.SpacecraftSnapshotRepository +import java.time.Instant +import java.time.LocalDateTime +import java.util.TimeZone +import java.util.function.Consumer + +class SyncSpacecraftFromNsiUseCaseTest { + + private val originalTimeZone: TimeZone = TimeZone.getDefault() + + @AfterEach + fun restoreTimeZone() { + TimeZone.setDefault(originalTimeZone) + } + + @Test + fun `projects NSI validity instants into JVM default zone as naive local LocalDateTime`() { + // validFrom/validTo пишутся в naive-поля БД как настенные часы локальной зоны JVM (без UTC-семантики). + // Конвертация идёт через ZoneId.systemDefault(), поэтому на МСК (+3) инстант смещается на офсет. + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow")) + + val platformService = mock(PlatformService::class.java) + val repository = mock(SpacecraftSnapshotRepository::class.java) + val refreshJobService = mock(VisibilityRefreshJobService::class.java) + val transactionTemplate = mock(TransactionTemplate::class.java) + // executeWithoutResult просто прогоняет переданный блок без реальной транзакции. + doAnswer { invocation -> + invocation.getArgument>(0).accept(mock(TransactionStatus::class.java)) + null + }.`when`(transactionTemplate).executeWithoutResult(anyNonNull()) + + // loadAllowedPlatforms имеет дефолтный аргумент Instant.now(); матчер нужен, иначе stub не совпадёт. + `when`(platformService.loadAllowedPlatforms(anyNonNull())).thenReturn( + listOf( + PlatformCatalogItem( + nsiId = "nsi-1", + businessKey = "bk-1", + name = "SAT", + status = "OPERATIONAL", + noradId = 25544L, + mission = "obs", + validFrom = Instant.parse("2026-06-02T14:14:08Z"), + validTo = Instant.parse("2026-12-31T23:00:00Z") + ) + ) + ) + + SyncSpacecraftFromNsiUseCase(platformService, repository, refreshJobService, transactionTemplate) + .syncAllowedSpacecraft() + + val validFrom = ArgumentCaptor.forClass(LocalDateTime::class.java) + val validTo = ArgumentCaptor.forClass(LocalDateTime::class.java) + verify(repository).upsertFromNsi( + spacecraftId = anyNonNull(), + noradId = org.mockito.Mockito.anyLong(), + nsiId = anyNullable(), + businessKey = anyNullable(), + name = anyNullable(), + status = anyNullable(), + mission = anyNullable(), + validFrom = validFrom.capture(), + validTo = validTo.capture(), + now = anyNonNull() + ) + + // Инстанты спроецированы в локальную зону JVM (МСК, +3): 14:14Z → 17:14, 23:00Z → 02:00 следующих суток. + assertEquals(LocalDateTime.parse("2026-06-02T17:14:08"), validFrom.value) + assertEquals(LocalDateTime.parse("2027-01-01T02:00:00"), validTo.value) + } + + @Suppress("UNCHECKED_CAST") + private fun anyNonNull(): T { + org.mockito.ArgumentMatchers.any() + return null as T + } + + private fun anyNullable(): T? = org.mockito.ArgumentMatchers.any() +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt index 79ae4a6..c726186 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/TestPlanDecisionServiceTest.kt @@ -16,10 +16,8 @@ import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptEntity import space.nstart.pcp_tgu_service.entity.PlanIssueAttemptStatus import space.nstart.pcp_tgu_service.entity.PlannedPlanEntity import space.nstart.pcp_tgu_service.entity.PlannedPlanStatus -import space.nstart.pcp_tgu_service.entity.SpacecraftPlanningStateEntity import space.nstart.pcp_tgu_service.repository.PlanIssueAttemptRepository import space.nstart.pcp_tgu_service.repository.PlannedPlanRepository -import space.nstart.pcp_tgu_service.repository.SpacecraftPlanningStateJpaRepository import java.time.LocalDateTime import java.util.Optional import java.util.UUID @@ -27,16 +25,11 @@ import java.util.UUID class TestPlanDecisionServiceTest { @Test - fun `generate decision builds message for active attempt and delegates to use case`() { + fun `generate decision builds message for latest attempt and delegates to use case`() { val plan = plannedPlan() val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) - val state = SpacecraftPlanningStateEntity( - spacecraftId = plan.spacecraftId, - activePlanId = plan.planId, - activeAttemptId = attempt.attemptId - ) val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java) - val service = service(plan, attempt, state, handlePlanDecisionUseCase) + val service = service(plan, attempt, handlePlanDecisionUseCase) val message = service.generateDecision(plan.planId, PlanDecision.ACCEPTED, "manual accept") @@ -50,12 +43,10 @@ class TestPlanDecisionServiceTest { } @Test - fun `generate decision rejects plan without active attempt`() { + fun `generate decision rejects plan without any attempt`() { val plan = plannedPlan() - val attempt = attempt(plan.planId, PlanIssueAttemptStatus.STARTED) - val state = SpacecraftPlanningStateEntity(spacecraftId = plan.spacecraftId) val handlePlanDecisionUseCase = mock(HandlePlanDecisionUseCase::class.java) - val service = service(plan, attempt, state, handlePlanDecisionUseCase) + val service = service(plan, attempt = null, handlePlanDecisionUseCase) val exception = assertThrows(ResponseStatusException::class.java) { service.generateDecision(plan.planId, PlanDecision.REJECTED, null) @@ -67,22 +58,18 @@ class TestPlanDecisionServiceTest { private fun service( plan: PlannedPlanEntity, - attempt: PlanIssueAttemptEntity, - state: SpacecraftPlanningStateEntity, + attempt: PlanIssueAttemptEntity?, handlePlanDecisionUseCase: HandlePlanDecisionUseCase ): TestPlanDecisionService { val plannedPlanRepository = mock(PlannedPlanRepository::class.java) val planIssueAttemptRepository = mock(PlanIssueAttemptRepository::class.java) - val stateRepository = mock(SpacecraftPlanningStateJpaRepository::class.java) `when`(plannedPlanRepository.findById(plan.planId)).thenReturn(Optional.of(plan)) - `when`(planIssueAttemptRepository.findById(attempt.attemptId)).thenReturn(Optional.of(attempt)) - `when`(stateRepository.findById(plan.spacecraftId)).thenReturn(Optional.of(state)) + `when`(planIssueAttemptRepository.findTopByPlanIdOrderByAttemptNoDesc(plan.planId)).thenReturn(attempt) return TestPlanDecisionService( plannedPlanRepository = plannedPlanRepository, planIssueAttemptRepository = planIssueAttemptRepository, - spacecraftPlanningStateJpaRepository = stateRepository, handlePlanDecisionUseCase = handlePlanDecisionUseCase ) } diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt index 7ac41da..25ea45b 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/VisibilityFlowTest.kt @@ -13,9 +13,7 @@ import space.nstart.pcp_tgu_service.entity.SpacecraftVisibilitySnapshotEntity import space.nstart.pcp_tgu_service.integration.api.VisibilityPayloadParser import space.nstart.pcp_tgu_service.repository.SpacecraftVisibilitySnapshotRepository import java.security.MessageDigest -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId import java.util.Optional class VisibilityFlowTest { @@ -28,16 +26,16 @@ class VisibilityFlowTest { assertEquals("25544", points.spacecraftId) assertEquals(listOf("7", "9"), points.points.map { point -> point.pointId }) + // Парсер трактует UTC-инстант баллистики как UTC-стенку (naive LocalDateTime = UTC), без сдвига на пояс JVM. assertEquals( - Instant.parse("2026-05-26T10:00:00Z").atZone(ZoneId.systemDefault()).toLocalDateTime(), + LocalDateTime.parse("2026-05-26T10:00:00"), points.points[1].observationWindows.single().startTime ) } @Test - fun `same visibility payload updates fetchedAt but does not enqueue recalculation`() { + fun `same visibility payload updates fetchedAt and persists snapshot`() { val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) val payload = rawPayloadArray(stationId = 7) val existing = SpacecraftVisibilitySnapshotEntity( spacecraftId = "25544", @@ -48,27 +46,25 @@ class VisibilityFlowTest { ) `when`(repository.findById("25544")).thenReturn(Optional.of(existing)) `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val service = SpacecraftVisibilitySnapshotService(repository, parser()) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) assertEquals(LocalDateTime.of(2026, 5, 26, 11, 0), existing.fetchedAt) - verify(recalculationJobService, never()).enqueue("25544", "VISIBILITY_CHANGED") + verify(repository, times(1)).save(anyNonNull()) } @Test - fun `changed visibility payload stores raw payload and enqueues recalculation`() { + fun `new visibility payload stores raw snapshot`() { val repository = mock(SpacecraftVisibilitySnapshotRepository::class.java) - val recalculationJobService = mock(PlanRecalculationJobService::class.java) `when`(repository.findById("25544")).thenReturn(Optional.empty()) `when`(repository.save(anyNonNull())).thenAnswer { invocation -> invocation.arguments[0] } - val service = SpacecraftVisibilitySnapshotService(repository, parser(), recalculationJobService) + val service = SpacecraftVisibilitySnapshotService(repository, parser()) val payload = rawPayloadArray(stationId = 7) service.saveSnapshot("25544", 25544L, payload, LocalDateTime.of(2026, 5, 26, 11, 0)) verify(repository, times(1)).save(anyNonNull()) - verify(recalculationJobService).enqueue("25544", "VISIBILITY_CHANGED") } private fun parser(): VisibilityPayloadParser = diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt new file mode 100644 index 0000000..5d7f688 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/ZrvWindowMatcherTest.kt @@ -0,0 +1,66 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.domain.CalculatedPlan +import java.time.LocalDateTime + +class ZrvWindowMatcherTest { + + private val sc = "25544" + private val t0 = LocalDateTime.now().withNano(0) + private fun at(min: Long) = t0.plusMinutes(min) + + private val zrvA = CalculatedPlan(sc, at(0), at(10), "KPP-1") + private val zrvB = CalculatedPlan(sc, at(100), at(110), "KPP-2") + private val windows = listOf(zrvA, zrvB) + + @Test + fun `window spanning two different ZRV (start of A, end of B) matches`() { + assertTrue(ZrvWindowMatcher.matches(windows, at(0), at(110), "KPP-1")) + } + + @Test + fun `single-ZRV window (start and end of the same zone) matches`() { + assertTrue(ZrvWindowMatcher.matches(windows, at(0), at(10), "KPP-1")) + } + + @Test + fun `start not aligned to any ZRV start does not match`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(3), at(110), "KPP-1")) + } + + @Test + fun `start kpp mismatch does not match even if time aligns`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(110), "KPP-2")) + } + + @Test + fun `end not aligned to any ZRV end does not match`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(105), "KPP-1")) + } + + @Test + fun `stop not after start does not match`() { + assertFalse(ZrvWindowMatcher.matches(windows, at(0), at(0), "KPP-1")) + } + + @Test + fun `empty windows never match`() { + assertFalse(ZrvWindowMatcher.matches(emptyList(), at(0), at(110), "KPP-1")) + } + + @Test + fun `sub-second ZRV matches a window truncated to whole seconds`() { + // Фронт усекает ISO до целых секунд (.slice(0,19)); реальная ЗРВ имеет доли секунды. + // Расхождение строго меньше секунды должно совпадать (регрессия на Duration.toSeconds()). + val fractional = CalculatedPlan( + sc, + at(0).withNano(180_078_000), + at(10).withNano(19_687_000), + "KPP-1" + ) + assertTrue(ZrvWindowMatcher.matches(listOf(fractional), at(0), at(10), "KPP-1")) + } +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/CorsFilterComponent.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt similarity index 54% rename from services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/CorsFilterComponent.kt rename to services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt index 49da575..11631c8 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/CorsFilterComponent.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt @@ -7,50 +7,19 @@ import org.springframework.web.filter.CorsFilter import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer -import org.springframework.stereotype.Component -import org.springframework.web.server.ServerWebExchange -import org.springframework.web.server.WebFilter -import org.springframework.web.server.WebFilterChain -import reactor.core.publisher.Mono - - -@Component -class CorsFilterComponent : WebFilter { - - override fun filter(ctx: ServerWebExchange, chain: WebFilterChain): Mono { - if (ctx != null) { - ctx.response.headers.add("Access-Control-Allow-Origin", "*") - ctx.response.headers.add("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS") - ctx.response.headers.add( - "Access-Control-Allow-Headers", - "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range" - ) - ctx.response.headers.add( - "Access-Control-Expose-Headers", - "DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range" - ) - return chain?.filter(ctx) ?: Mono.empty() - - } else { - return chain?.filter(ctx) ?: Mono.empty() - } - } -} - - - @Configuration class WebConfig : WebMvcConfigurer { override fun addCorsMappings(registry: CorsRegistry) { + // Внутренний ops-инструмент: UI открывают с разных хостов/IP (localhost, + // LAN-адрес рабочей станции, прод-сборка). Любой явный список origin'ов рано + // или поздно не совпадает с фактическим адресом UI → POST/PUT/DELETE отбивается + // как «Invalid CORS request» (same-origin GET проходит, т.к. идёт без Origin). + // Поэтому разрешаем любой origin. allowedOriginPatterns("*") совместим с + // allowCredentials=true (в отличие от allowedOrigins("*")). registry.addMapping("/**") - .allowedOrigins( - "http://localhost:3000", - "http://localhost:8080", - "http://192.168.60.68:7008", - "http://192.168.60.68:8080" - ) + .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") .allowCredentials(true) @@ -63,11 +32,8 @@ class WebConfig : WebMvcConfigurer { val source = UrlBasedCorsConfigurationSource() val config = CorsConfiguration().apply { allowCredentials = true - // Разрешенные origin'ы - allowedOriginPatterns = listOf( - "http://localhost:*", - "http://192.168.60.68:*" - ) + // Любой origin — см. комментарий в addCorsMappings. + allowedOriginPatterns = listOf("*") // Разрешенные заголовки allowedHeaders = listOf( "Origin", diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt index 491903f..5f5e8af 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt @@ -47,6 +47,21 @@ class TguPlanningRestController( @RequestParam(required = false) reason: String? ) = tguPlanningService.sendDecision(planId, decision, reason) + /** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */ + @PostMapping("/plans/{planId}/dismiss") + fun dismissPlan(@PathVariable planId: UUID) = tguPlanningService.dismissPlan(planId) + + /** «Вернуть на учёт» план. */ + @PostMapping("/plans/{planId}/restore") + fun restorePlan(@PathVariable planId: UUID) = tguPlanningService.restorePlan(planId) + + /** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */ + @PostMapping("/plans/{planId}/confirm-layin") + fun confirmLayin( + @PathVariable planId: UUID, + @RequestParam(required = false) reason: String? + ) = tguPlanningService.confirmLayin(planId, reason) + @GetMapping("/spacecraft/{noradId}/flight-line") fun spacecraftFlightLine( @PathVariable noradId: Long, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt index e7e3e96..7ff167a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/bookings/BookingsDTO.kt @@ -6,6 +6,8 @@ import java.time.LocalDateTime /** * UI DTO for the bookings page. It intentionally lives in ui-service because the page combines * data from slots-service, request-service and satellite-catalog-service without changing their APIs. + * + * Времена ([LocalDateTime]) — naive ISO без зоны (локальное настенное время, без UTC-семантики). */ data class BookedSlotViewDTO( val bookedSlotId: Long, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt index ee50115..ab565bf 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/groupstate/GroupStateDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.groupstate import java.time.LocalDateTime +/* + * Времена ([LocalDateTime]) — naive ISO без зоны (локальное настенное время, без UTC-семантики). + */ + data class GroupStateAvailabilityDTO( val timeStart: LocalDateTime, val timeStop: LocalDateTime diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt index 41870ce..be840db 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/pdcm/SatellitePdcmDTO.kt @@ -3,6 +3,10 @@ package space.nstart.pcp.slots_service.dto.pdcm import space.nstart.pcp.pcp_types_lib.dto.ballistics.AscNodeDTO import java.time.LocalDateTime +/* + * Времена ([LocalDateTime]) — naive ISO без зоны (локальное настенное время, без UTC-семантики). + */ + data class SatellitePdcmAvailabilityDTO( val timeStart: LocalDateTime, val timeStop: LocalDateTime diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt index 42fb510..400076e 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -4,6 +4,11 @@ import java.time.Instant import java.time.LocalDateTime import java.util.UUID +/* + * Времена плана ([TguPlanResponse]) — naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service). + * `Instant` ([TguPlatformResponse.validFrom/validTo]) — с `Z` (как отдаёт НСИ-прокси). + */ + data class TguPlatformResponse( val id: String?, val businessKey: String?, @@ -21,7 +26,13 @@ data class TguPlanResponse( val startTime: LocalDateTime, val endTime: LocalDateTime, val kppId: String, - val status: String + val status: String, + /** Предшественник в цепочке (`null` — старт без предыстории). Проксируется из pcp-tgu-service. */ + val basePlanId: UUID? = null, + /** `true` — проекция будущего (не persisted). Проксируется из pcp-tgu-service. */ + val projected: Boolean = false, + /** Время «снятия с учёта» (`null` — план на учёте). Проксируется из pcp-tgu-service. */ + val dismissedAt: LocalDateTime? = null ) enum class TguPlanDecision { diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt index 1d55f43..925ab0a 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tleanalysis/TleAnalysisDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tleanalysis import java.time.LocalDateTime +/* + * Времена ([LocalDateTime], в т.ч. epoch) — naive ISO без зоны (локальное настенное время, без UTC-семантики). + */ + data class TleAnalysisSatelliteDTO( val id: Long, val catalogId: Long? = null, diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt index 238b312..7c43b06 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tlecomparison/TleComparisonDTO.kt @@ -2,6 +2,10 @@ package space.nstart.pcp.slots_service.dto.tlecomparison import java.time.LocalDateTime +/* + * Времена ([LocalDateTime], epoch/time) — naive ISO без зоны (локальное настенное время, без UTC-семантики). + */ + class TleTextRequestDTO( var tle: String = "" ) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt index 74620eb..d4c9d2d 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt @@ -87,6 +87,48 @@ class TguPlanningService(webClientBuilderProvider: ObjectProvider builder.buildAndExpand(planId) }) + .retrieve() + .onStatus({ status -> status.isError }, ::mapReadError) + .bodyToMono(Void::class.java) + .block() + } + + /** «Вернуть на учёт» план (pcp-tgu-service). */ + fun restorePlan(planId: UUID) { + webClientBuilder.build() + .post() + .uri(buildUri("/api/plans/{planId}/restore") { builder -> builder.buildAndExpand(planId) }) + .retrieve() + .onStatus({ status -> status.isError }, ::mapReadError) + .bodyToMono(Void::class.java) + .block() + } + + fun confirmLayin(planId: UUID, reason: String?) { + runCatching { + webClientBuilder.build() + .post() + .uri(buildUri("/test/tgu/plans/{planId}/confirm-layin") { builder -> + reason?.takeIf { it.isNotBlank() }?.let { builder.queryParam("reason", it) } + builder.buildAndExpand(planId) + }) + .retrieve() + .onStatus({ status -> status.isError }, ::mapReadError) + .bodyToMono(Void::class.java) + .block() + }.getOrElse { error -> + if (error is WebClientRequestException) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, TEST_CONTROLLER_UNAVAILABLE_MESSAGE, error) + } + throw error + } + } + private fun buildUri( path: String, customizer: (UriComponentsBuilder) -> Any = { builder -> builder.build() } diff --git a/services/pcp-ui-service/src/main/resources/application.yaml b/services/pcp-ui-service/src/main/resources/application.yaml index f4d3e2c..78562ba 100644 --- a/services/pcp-ui-service/src/main/resources/application.yaml +++ b/services/pcp-ui-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index ac6e271..d219125 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -210,8 +210,8 @@ class CatalogControllerTest { TguPlanResponse( planId = planId, spacecraftId = "56756", - startTime = LocalDateTime.of(2026, 5, 29, 10, 0), - endTime = LocalDateTime.of(2026, 5, 29, 10, 15), + startTime = LocalDateTime.of(2026, 5, 29, 10, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 10, 15, 0), kppId = "KPP-1", status = "WAITING_DECISION" ) diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt new file mode 100644 index 0000000..d166025 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDtoContractTest.kt @@ -0,0 +1,59 @@ +package space.nstart.pcp.slots_service.dto.tgu + +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.LocalDateTime +import java.util.UUID + +/** + * Контракт формата времени на проводе для DTO вкладки ТГУ в pcp-ui-service: + * времена плана ([TguPlanResponse]) — naive `LocalDateTime` без зоны (как отдаёт pcp-tgu-service), + * `Instant` ([TguPlatformResponse.validFrom]) — с `Z` (как отдаёт НСИ-прокси). + */ +class TguPlanningDtoContractTest { + + private val mapper = jacksonObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + @Test + fun `serializes plan times as naive ISO without zone`() { + val plan = TguPlanResponse( + planId = UUID.fromString("00000000-0000-0000-0000-000000000001"), + spacecraftId = "25544", + startTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0), + endTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0), + kppId = "KPP-1", + status = "PLANNED" + ) + + val node = mapper.readTree(mapper.writeValueAsString(plan)) + + assertEquals("2026-05-29T01:00:00", node.get("startTime").asText()) + assertEquals("2026-05-29T02:00:00", node.get("endTime").asText()) + assertFalse(node.get("startTime").asText().endsWith("Z"), "не ожидалась зона Z") + } + + @Test + fun `serializes platform validity instants with explicit Z zone`() { + val platform = TguPlatformResponse( + id = "25544", + businessKey = "bk-1", + name = "SAT", + status = "OPERATIONAL", + noradId = 25544L, + mission = "obs", + validFrom = Instant.parse("2026-05-29T01:00:00Z"), + validTo = Instant.parse("2026-12-31T23:00:00Z") + ) + + val node = mapper.readTree(mapper.writeValueAsString(platform)) + + assertEquals("2026-05-29T01:00:00Z", node.get("validFrom").asText()) + assertTrue(node.get("validTo").asText().endsWith("Z"), "ожидалась явная зона Z") + } +} diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt index 9cc4328..3fdd6df 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt @@ -116,7 +116,7 @@ class TguPlanningServiceTest { exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond( exchange, 200, - """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00","endTime":"2026-05-29T10:15:00","kppId":"KPP-1","status":"WAITING_DECISION"}]""" + """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00Z","endTime":"2026-05-29T10:15:00Z","kppId":"KPP-1","status":"WAITING_DECISION","basePlanId":null,"projected":false,"dismissedAt":null}]""" ) exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond( exchange, diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt index 7b02854..e753011 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/repository/SlotRepositoryImpl.kt @@ -33,8 +33,11 @@ class SlotRepositoryImpl( cycle = rs.getLong("cycle"), satelliteId = rs.getLong("satellite_id"), coveringType = rs.getInt("covering_type"), - tn = rs.getTimestamp("tn").toLocalDateTime(), - tk = rs.getTimestamp("tk").toLocalDateTime(), + // getObject(LocalDateTime) читает `timestamp without time zone` без участия + // зоны JVM. getTimestamp().toLocalDateTime() зоно-нейтрален лишь для фикс-офсета; + // на зонах с DST граничные значения сдвигались бы. Времена в системе — локальные (настенные часы, без UTC-семантики). + tn = rs.getObject("tn", java.time.LocalDateTime::class.java), + tk = rs.getObject("tk", java.time.LocalDateTime::class.java), roll = rs.getDouble("roll"), contour = rs.getString("contour_wkt"), revolution = rs.getLong("revolution"), diff --git a/services/slots-service/src/main/resources/application.yaml b/services/slots-service/src/main/resources/application.yaml index 1195f0d..c40df22 100644 --- a/services/slots-service/src/main/resources/application.yaml +++ b/services/slots-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev} diff --git a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt index 665db80..8541ad5 100644 --- a/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt +++ b/services/tle-monitoring-service/src/main/kotlin/space/nstart/pcp/tle_monitoring_service/entity/TLEEntity.kt @@ -2,7 +2,6 @@ package space.nstart.pcp.tle_monitoring_service.entity import jakarta.persistence.* import java.time.LocalDateTime -import java.time.ZoneOffset @Entity @@ -23,5 +22,5 @@ class TLEEntity ( val tle : String = "", var timeTle : LocalDateTime? = null, @Column(name = "time_rec", nullable = false) - val timeRec : LocalDateTime = LocalDateTime.now(ZoneOffset.UTC) + val timeRec : LocalDateTime = LocalDateTime.now() ) diff --git a/services/tle-monitoring-service/src/main/resources/application.yaml b/services/tle-monitoring-service/src/main/resources/application.yaml index d73e9a2..8a2dbbc 100644 --- a/services/tle-monitoring-service/src/main/resources/application.yaml +++ b/services/tle-monitoring-service/src/main/resources/application.yaml @@ -7,7 +7,7 @@ spring: import: "configserver:" cloud: config: - uri: ${CONFIG_SERVER_URI:http://192.168.60.201:8888} + uri: ${CONFIG_SERVER_URI:http://localhost:8888} fail-fast: ${CONFIG_SERVER_FAIL_FAST:true} profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}} label: ${SPRING_CLOUD_CONFIG_LABEL:dev}