pcp-request-service: аудит-фиксы RS1/RS3/RS6/RS7

RS1 (#29): планировщик истечения заявок — RequestExpiryService
(@Scheduled + FOR UPDATE SKIP LOCKED, мультиинстанс-safe), refreshStatus
вызывается и при создании заявки.
RS7 (#35): единый топик pcp.request.status.v1 → geoportal (COMPLETED/EXPIRED
в payload), замена orphan pcp.request.completed.v1; эмиссия из matching и
expiry через тот же outbox; backoff/next_attempt_at/cap для outbox FAILED.
RS3 (#31): DefaultErrorHandler + DeadLetterPublishingRecoverer
(DLQ pcp.route.georeference-dlq.v1), bounded retry, контрактные ошибки в DLQ.
RS6 (#34): проброс coverageRequiredPercent из API, разведение терминов
покрытие/важность.

Доки: бэклог, аудит сервиса заданий, карты взаимодействий.
RS2 (#30) отложено до #28, RS5 (#33) — до роста требований к точности.
This commit is contained in:
Дмитрий Соловьев
2026-06-06 23:47:19 +03:00
parent 2499aebf2d
commit c1259c3105
33 changed files with 1600 additions and 139 deletions
+4 -4
View File
@@ -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` в рантайме.
+2 -2
View File
@@ -55,7 +55,7 @@
| `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.completed.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | *(в репозитории нет консьюмера)* | заявка завершена (REQUEST_COMPLETED) |
| `pcp.request.status.v1` | `pcp-request-service` (`OutboxPublisherService`, outbox) | **geoportal** (внешний, в репо нет) | терминальный статус заявки `COMPLETED`/`EXPIRED` (`REQUEST_TERMINAL_STATUS`) |
| `pcp.request.in.v1` | — | *(объявлен в конфиге `pcp-request-service`, активного слушателя нет)* | входящие заявки (legacy/зарезервировано) |
### Поток данных (упрощённо)
@@ -76,7 +76,7 @@ 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.completed.v1 (outbox)─────> (нет консьюмера в репо)
request-service ──pcp.request.status.v1 (outbox)────────> geoportal (внешний)
```
### ТГУ-топики (`pcp.tgu.*`)
@@ -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<RoutePassportDto>`, контракт из `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 и тесты; покрытие — отношение площадей).
+41 -1
View File
@@ -10,7 +10,10 @@
У каждой задачи есть стабильный номер `#N` — он не меняется при правках списка, на него удобно ссылаться. Новые задачи получают следующий свободный номер.
_Обновлено: 2026-06-05_
> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C).
_Обновлено: 2026-06-06_
---
@@ -31,12 +34,49 @@ _Обновлено: 2026-06-05_
### Бэкенд / инфраструктура
- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu).
### Проверка сервисов
- [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности.
- [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой.
### Баги
- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы».
### Аудит — надёжность и бэкенд
_Источник и рекомендации: [аудит-проекта-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._