# Интеграция «Заказ съёмки» (Альтум) ↔ Ordinis НСИ

**Status:** схемы согласованы, ожидаем service-account Keycloak и сидинг.
**Owner со стороны Ordinis:** zimin.an@nstart.space.
**Owner со стороны Альтума:** altum-backend (FastAPI BFF).

## 1. Зачем

В форме «Заказ съёмки» (greenfield на стороне Альтума — поля mission /
instrument / processing_level / data_format пока отсутствуют в БД и UI) поля
будут читаться из Ordinis НСИ — единый источник истины. Когда оператор НСИ
заводит новый КА «Ресурс-П 4» — он сразу появляется в выпадашке заказа без
релиза Альтума.

**Польза:** новый спутник / станция / прибор появляется в заказе через 5 минут
после ввода в админке Ordinis вместо релизного цикла Альтума.

## 2. Контракт API (Ordinis → Альтум)

**Base URL:**

- staging: `https://ordinis.k8s.265.nstart.cloud/api/v1`
- prod: `https://ordinis.k8s.264.nstart.cloud/api/v1` (после prod stand-up)

**Авторизация:**

- На staging пока выключена (`ORDINIS_AUTH_REQUIRED=false`), на prod включится.
- Header `Authorization: Bearer <jwt>` от Keycloak (`auth.nstart.space/realms/nstart`).
- Альтум использует **service account (m2m client)** в реалме `nstart`, роль
  `ordinis-public`. JWT issued через `client_credentials` flow, рефрешится в
  altum-backend перед TTL.
- Ordinis маппит роль `ordinis-public` → DataScope.PUBLIC. Маппинг — см.
  `ordinis-auth/.../ScopeContext.java`.

**Endpoints (read-only):**

> ⚠️ **Read-api живёт без префикса `/dictionaries/`** — это admin-write путь.
> Для чтения используйте `/api/v1/{name}/records` напрямую.

```
GET /api/v1/dictionaries
  → список доступных справочников (только метаданные + JSON Schema).

GET /api/v1/{name}/records?lang=ru-RU&at=2026-05-04T12:00:00Z
  → активные записи на момент `at` (default: now).
  ?lang=ru-RU / ?lang=en-US — i18n поля сворачиваются в строку на этом языке.
  Без ?lang — i18n остаётся объектом {ru-RU, en-US}, BFF может flatten сам.

GET /api/v1/{name}/records/{businessKey}?lang=ru-RU&at=...
  → одна запись по businessKey.

GET /api/v1/{name}/records/{businessKey}/history
  → все версии записи (закрытые + текущая).
```

**Пример (есть на staging прямо сейчас):**

```bash
curl https://ordinis.k8s.265.nstart.cloud/api/v1/spacecraft/records?lang=ru-RU
```

```json
[
  {
    "id": "72a15bec-56f6-4fb6-8e85-d91a492c707e",
    "businessKey": "1999-068A",
    "data": {
      "name": "Terra (EOS AM-1)",
      "country": "US",
      "operator": "НАСА",
      "norad_id": 25994,
      "designator": "1999-068A",
      "launch_date": "1999-12-18",
      "status": "ACTIVE",
      "satellite_type_code": "OPT_MIDRES_MS",
      "spectrum_bands": ["VIS", "NIR", "SWIR", "TIR"]
    },
    "validFrom": "2026-05-03T20:49:09Z",
    "validTo": "9999-12-31T23:59:59Z"
  }
]
```

**businessKey формат:** для существующих spacecraft — COSPAR designator
(например `2016-016A` = Ресурс-П № 3). Для остальных справочников — code-style
(`RESURS_P`, `MSI-S2`, `GEOTIFF`, `L1A`).

## 3. Согласованные схемы 4 справочников

Все справочники имеют общую базовую структуру: `code` (string, businessKey),
`status` (enum: active/archived). Поля name/description — i18n localized.
Дальше описаны специфические поля.

### `mission` — программа / семейство КА

| поле                  | тип                | пример                                | примечание                            |
|-----------------------|--------------------|---------------------------------------|---------------------------------------|
| `code`                | string             | `RESURS`                              | businessKey, x-id-source              |
| `name`                | i18n               | `Ресурс` / `Resurs`                   | для UI                                |
| `description`         | i18n               | `Российская программа оптического ДЗЗ среднего разрешения` | tooltip       |
| `spacecraft_codes`    | array of string    | `["RESURS-P-3","RESURS-P-4"]`         | businessKeys из spacecraft            |
| `status`              | enum               | `active` / `archived`                 |                                       |

### `instrument` — конкретный сенсор на борту

| поле                       | тип             | пример                                  | примечание                             |
|----------------------------|-----------------|-----------------------------------------|----------------------------------------|
| `code`                     | string          | `GEOTON-L1`                             | businessKey, x-id-source               |
| `name`                     | i18n            | `Геотон-Л1`                             |                                        |
| `spacecraft_codes`         | array of string | `["RESURS-P-3","RESURS-P-4"]`           | many-to-many                           |
| `type`                     | enum            | `optical` / `sar` / `infrared` / `multispectral` / `hyperspectral` | UI-фильтр |
| `spatial_resolution_m`     | number          | `1.0`                                   | для сортировки/фильтра                 |
| `swath_km`                 | number          | `38`                                    |                                        |
| `spectral_bands`           | array of string | `["PAN","B","G","R","NIR"]`             | опц.                                   |
| `supported_format_codes`   | array of string | `["GEOTIFF","NITF"]`                    | businessKeys из data_format            |
| `supported_level_codes`    | array of string | `["L1A","L2"]`                          | businessKeys из processing_level       |
| `status`                   | enum            | `active` / `archived`                   |                                        |

Связи instrument → format/level в самом instrument'е — даёт фронту cascading
select без отдельного эндпоинта совместимости.

### `processing_level` — уровень обработки

| поле           | тип    | пример                                          |
|----------------|--------|-------------------------------------------------|
| `code`         | string | `L1A` (businessKey, x-id-source)                |
| `name`         | i18n   | `Радиометрически скорректированный`             |
| `description`  | i18n   | (детали обработки)                              |
| `sort_order`   | integer | `20` (для упорядочивания L0→L1→L2)             |
| `status`       | enum   | `active` / `archived`                           |

### `data_format` — формат файлов

| поле               | тип    | пример                                  |
|--------------------|--------|-----------------------------------------|
| `code`             | string | `GEOTIFF` (businessKey, x-id-source)    |
| `name`             | i18n   | `GeoTIFF`                               |
| `mime_type`        | string | `image/tiff`                            |
| `file_extension`   | string | `.tif`                                  |
| `description`      | i18n   | опц.                                    |
| `status`           | enum   | `active` / `archived`                   |

## 4. Что делает команда Альтум

### Backend (altum-backend, FastAPI, единая точка интеграций)

- `backend/app/clients/ordinis.py` — httpx-клиент с retry + cache:
  - retry 3× с exponential backoff
  - timeout 5 сек
  - кэш с TTL 5 мин per `(dictionary, lang)` (in-process LRU + ETag когда
    появится; пока без webhook → достаточно TTL)
  - на 401 — refresh service-account JWT через Keycloak
  - на 5xx/таймаут — stale cache + warn в лог. Не падать.
- `backend/app/routers/reference.py` — единая точка для фронта:
  ```
  GET /api/reference/spacecraft
  GET /api/reference/spacecraft/{business_key}
  GET /api/reference/instrument
  GET /api/reference/mission
  GET /api/reference/processing_level
  GET /api/reference/data_format
  ```
  Каждый внутри дёргает Ordinis read-api с кэшом. Авторизация JWT-инжекта на
  стороне backend — фронт ничего не знает про Keycloak Ordinis.
- Таблица `orders` получает новые колонки:
  ```
  spacecraft_business_key   VARCHAR(256)
  instrument_business_key   VARCHAR(256)
  processing_level_code     VARCHAR(256)
  data_format_code          VARCHAR(256)
  reference_snapshot        JSONB
  ```
- Снэпшот на момент создания заказа — копия всех использованных справочных
  записей (для аудита: «через 2 года КА вывели из эксплуатации, но мы должны
  помнить какой был на момент заказа»):
  ```json
  {
    "reference_snapshot": {
      "spacecraft": {"RESURS-P-3": {"version": 2, "data": {...}}},
      "instrument": {"GEOTON-L1": {"version": 1, "data": {...}}}
    },
    "captured_at": "2026-05-15T10:30:00Z"
  }
  ```

### Frontend (TanStack Query)

```ts
const { data: spacecrafts } = useQuery({
  queryKey: ['reference', 'spacecraft', lang],
  queryFn: () => fetch(`/api/reference/spacecraft?lang=${lang}`).then(r => r.json()),
  staleTime: 5 * 60_000,
})

<Select options={spacecrafts.map(s => ({ value: s.businessKey, label: s.data.name }))} />
```

- Skeleton при первой загрузке.
- Fallback с retry-кнопкой при недоступности backend.
- `POST /api/orders` — отправляет businessKey'и (не локальные id).
- Каскадные селекты: instrument → отфильтрованы по выбранному spacecraft через
  `instrument.spacecraft_codes`; processing_level + data_format → по
  `instrument.supported_level_codes` / `supported_format_codes`.

### TTL vs webhook

5 минут TTL достаточно для v1 — справочники меняются редко (новый КА — раз в
месяцы). На v2, если потребуется realtime: подписка altum-backend через
`aiokafka` на топик **`nsi.dictionary.changed`** (Ordinis опубликует) с
payload `{name, businessKey, action: created|updated|closed}`. Не блокер.

### CORS

Фронт ходит через altum-backend, прямой CORS не нужен. Для отладки добавим в
allowlist:

- `https://altum.nstart.cloud`
- `https://altum.k8s.264.nstart.cloud`

## 5. Что делает команда Ordinis (todo)

- [ ] Сидинг 4 справочников (`mission`, `instrument`, `processing_level`,
      `data_format`) согласно §3 в bundle `cuod`. Отдельный changelog для
      добавления схем + начальные записи.
- [ ] Service account `altum-backend` в Keycloak realm `nstart` с ролью
      `ordinis-public`. Передать `ORDINIS_CLIENT_ID` / `ORDINIS_CLIENT_SECRET`
      команде Альтума.
- [ ] CORS allowlist для отладочных доменов Альтума на ingress.
- [ ] Включить `ORDINIS_AUTH_REQUIRED=true` на staging когда altum-backend
      будет готов тестировать с m2m JWT.
- [ ] Swagger URL read-api в `docs/tech/api-integration.md` (создать).
- [ ] (v2) Kafka топик `nsi.dictionary.changed` с change events.

## 6. Acceptance criteria

- [ ] Оператор НСИ заводит новый КА в Ordinis admin — через ≤5 минут он
      появляется в выпадашке заказа Альтума без редеплоя.
- [ ] Оператор закрывает КА (`PUT /records/{key}/close`) — через ≤5 минут
      пропадает из выпадашки активных, но в существующих заказах (snapshot)
      всё видно.
- [ ] Заказ съёмки PDF/выгрузка — берёт name КА из snapshot, не из live read-api.
- [ ] При недоступности Ordinis Альтум продолжает работать со stale-кэшом,
      выводит баннер «справочники могут быть устаревшими».
- [ ] Lang switching — оператор переключает на EN, КА называются «Resurs-P № 3».
- [ ] Cascading select работает: выбираешь инструмент — список форматов и
      уровней обработки сужается.

## 7. Сроки

- Ordinis: сидинг + Keycloak service account + CORS — **1 день после ответа на §5**.
- Альтум: BFF + 4 ref-эндпоинта + миграция БД (`reference_snapshot`) + UI с 4
  каскадными селектами + тесты — **2-3 дня** после готовности Ordinis.

---

**Repo cross-refs:**

- Read-api routing: `ordinis-read-api/`
- Auth/scope: `ordinis-auth/.../{ScopeContext, SecurityConfig}.java`
- Bitemporal model: `ordinis-domain/.../record/DictionaryRecord.java`
- Audit log: `ordinis-rest-api/.../audit/AuditLogger.java` (фиксирует все
  изменения справочников — Альтум по trace_id может проследить «когда оператор
  закрыл КА»).
