Решение владельца 08.10.2026: запросы — files.tabular_uploaded / files.img_uploaded
(было recognition.{tabular|image}.request), ack и результаты обоих сервисов —
files.tabular_parsed (было recognition.result); files.img_parsed не используется,
старый АРМ гасится. Тела сообщений, Nats-Msg-Id, queue group'ы и schema_version
не меняются.
Change nats-files-subjects: дельта recognition-contract (3 требования), Purpose
спеки; описания subject'ов в contracts/*.schema.json; 03-architecture (схема,
таблица потоков, NATS платформы), 09-delivery-plan, dev/README, dev/qa-guide,
repo-templates. DBML v5.17 не поднимается (R-3, deferred).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
438 lines
52 KiB
Markdown
438 lines
52 KiB
Markdown
# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.17)
|
||
|
||
## Компоненты (= репозитории, см. `00-workspace-layout.md`)
|
||
|
||
```
|
||
┌──────────────┐ JWT (email) ┌──────────────────────────────┐
|
||
браузер ──────▶│ React SPA │ ───────────────▶│ arm-core (FastAPI) │
|
||
└──────────────┘ │ REST /api/v1, policy-слой, │
|
||
│ единственный писатель в БД │
|
||
└───────┬──────────┬───────────┘
|
||
│ │
|
||
PostgreSQL │ │ S3 (uploads/ results/ exports/ archive/)
|
||
│ │
|
||
NATS (core) ◀─────────────────┘ outbox │
|
||
files.tabular_uploaded │
|
||
files.img_uploaded │
|
||
files.tabular_parsed ─────────────────────▶ arm-core (inbox, дедуп по msg_id)
|
||
│
|
||
┌───────────────┴───────────────┐
|
||
┌────────▼────────┐ ┌─────────▼────────┐ ┌──────────────────┐
|
||
│ tabular-parser │ │ scan-gateway │ ◀─────▶│ ИС Сканирование │ (внешняя, горячие папки)
|
||
│ XLS/XLSX/XLSB │ │ PDF/PNG/JPG │ └──────────────────┘
|
||
│ stateless, без БД│ │ stateless, без БД│
|
||
└─────────────────┘ └──────────────────┘
|
||
```
|
||
|
||
- **arm-core** — монолитное ядро: справочники, файлы/документы, маппинг результатов распознавания, консолидация
|
||
(вычисления), согласование, ЕХ, аудит, права. Один Alembic, одна схема.
|
||
- **tabular-parser** — читает файл из S3 `uploads/`, находит на листах ОСВ (§1) и формы ББ/ОФР (§12) **по содержимому**,
|
||
пишет нормализованный JSON в S3 `results/`, публикует `files.tabular_parsed`. Внутри — `BookParser` (оркестратор книги:
|
||
лимиты, чтение, дедлайн, вид каждого листа): листы ОСВ идут в конвейер `osv.py`, листы форм — в конвейер форм
|
||
`statements/` (блоки форм, колонки, шапка, строки, соотношения §7, склейка продолжений); документы результата — сначала
|
||
ОСВ, затем формы. Контракт результата —
|
||
`docs/contracts/recognition-result.schema.json` (контракт v1.2: `DISCLOSURE` в `document_type`, `statementDocument.checks`,
|
||
границы периода ОСВ `period_start/period_end/is_from_year_start`). Правила распознавания
|
||
заголовков/группировок — `rules/*.yaml`.
|
||
- **scan-gateway** — адаптер ИС Сканирование: положить файл в горячую папку `Input/<job_id>.<ext>` (расширение —
|
||
суффикс `original_name` в нижнем регистре; временный `.part` пишется в `Tmp`, а не в `Input` (решение владельца
|
||
06.10.2026) и публикуется во входящий каталог без замены; публикация сама снимает `.part` независимо от флага очистки,
|
||
в `Tmp` остаются только осиротевшие `.part` и дубли),
|
||
дождаться набора `Output/<job_id>/*.json` (по одному json на документ; набор готов, когда имена и размеры не менялись
|
||
`SCAN_SETTLE_SECONDS`), сохранить склеенный сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить
|
||
`Output/<job_id>/` и входящий файл — **только под флагом `SCAN_CLEANUP`, по умолчанию выключен** (права на удаление
|
||
на SMB-шаре контура не подтверждены, OQ-144), нормализовать в тот же контракт результата (`OcrNormalizer` по
|
||
`05-domain-rules.md` §11/§11.1: один `statementDocument` на таблицу ответа, справочники `api/rules/` с редакциями
|
||
форм), опубликовать `files.tabular_parsed`. Таймаут 5 мин → `TIMEOUT`. Раскладка папок — как у проверенного в контуре
|
||
PDFWorker старого АРМ (`RSHB/armka_file_worker`, решение владельца 06.10.2026, OQ-137) с двумя отличиями: временный
|
||
файл лежит в `Tmp`, а не рядом с назначением, очистка под флагом. Эмулятор ИС Сканирование (dev) ищет пару PDF + JSON
|
||
по sha256 содержимого входящего файла, а не по имени. Реализация — arm-scan-gateway #6 (change
|
||
`slice-3a-ocr-normalizer`).
|
||
- **Frontend** — React + TS по шаблону команды; экраны по макету `dkb_24.09.26` (справочник ГК, консолидации,
|
||
первичные документы, проверка распознавания, сводная ОСВ, шахматка, корректировки, гудвилл, КФО, ЕХ, журнал, роли).
|
||
|
||
## Контракт обмена через NATS (DECISION-4)
|
||
|
||
Схемы — `docs/contracts/` (v1.0), JSON Schema 2020-12: [`recognition-request.schema.json`](../contracts/recognition-request.schema.json)
|
||
и [`recognition-result-message.schema.json`](../contracts/recognition-result-message.schema.json) — тела сообщений,
|
||
[`recognition-result.schema.json`](../contracts/recognition-result.schema.json) — файл результата в S3; примеры —
|
||
[`docs/contracts/examples/`](../contracts/examples/). Примеры проверяются схемами: `python scripts/validate_contracts.py`
|
||
(из корня репозитория docs). Примеры — иллюстрации формы сообщений, а не готовые к исполнению задания:
|
||
схема проверяет у `reply_deadline` только формат `date-time` и не сравнивает срок с текущим временем, поэтому
|
||
`request-image.json` проходит схему при `reply_deadline` в прошлом. Срок проверяет сервис: `scan-gateway` отвечает
|
||
`error TIMEOUT`, если `reply_deadline` истёк до отправки файла в ИС Сканирование; `tabular-parser` срок не проверяет.
|
||
Файлы `examples/` копируются в сервисы побайтно, поэтому пример не правится ради «свежей» даты.
|
||
|
||
| Поток | Subject | Кто пишет | Полезная нагрузка | `Nats-Msg-Id` | Схема |
|
||
|---|---|---|---|---|---|
|
||
| запрос | `files.tabular_uploaded` / `files.img_uploaded` | arm-core (outbox из `recognition_job`) | `schema_version`, `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, `s3 {bucket, key}` исходника, `reply_deadline`, `attempt` | `<job_id>:<attempt>` | [request](../contracts/recognition-request.schema.json) |
|
||
| ack | `files.tabular_parsed` | tabular-parser / scan-gateway | конверт со `schema_version`, `status = processing`, `job_id`, `file_id`, `service {name, version}` | `ack:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json) |
|
||
| результат | `files.tabular_parsed` | tabular-parser / scan-gateway | `schema_version`, `job_id`, `file_id`, `status` (done / needs_check / error), `service {name, version}`, `metrics` (опц.), `raw_s3_key` (опц., только scan-gateway); при `done` / `needs_check` обязательны `result_s3 {bucket, key}` и `result_schema_version`, при `error` — `error {code, message}` | `result:<job_id>:<attempt>` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) |
|
||
|
||
`group_schema` в конверт не входит — ядро берёт его из `documents[].group_schema` файла результата при маппинге.
|
||
|
||
**На платформе NATS без JetStream** (ответ 03.10.2026; подтверждено 06.10.2026 кодом `RSHB/armka_file_worker`, который
|
||
работает в контуре на `subscribe`/`publish` + `flush` без `jetstream()`): нет durable consumer'ов, серверного дедупа и redelivery.
|
||
Поэтому гарантии доставки — на уровне приложения: сервисы подписаны queue group'ами (`tabular-parser`, `scan-gateway`),
|
||
ядро — queue group `arm-core` на `files.tabular_parsed`; сервис сразу по получении корректного запроса публикует ack `processing`;
|
||
ядро, не получив ack за `ACK_TIMEOUT_SECONDS` (30), публикует запрос повторно с тем же `Nats-Msg-Id`, не более
|
||
`OUTBOX_MAX_REPUBLISH` (3) раз, затем `dlq`. Сервис держит process-local множество `job_id` в работе и на повторный
|
||
запрос отвечает ack'ом без второй обработки. Публикация ack и результата сервисом при временной ошибке повторяется
|
||
с тем же `Nats-Msg-Id`: первая попытка плюс `PUBLISH_RETRIES` повторов (по умолчанию 3, всего до 4 попыток); повторы
|
||
исчерпаны — error-лог, задание закроет ядро (по `reply_deadline`). Появится JetStream — меняются только publisher/subscriber.
|
||
|
||
Правила:
|
||
- `recognition_job` создаётся в статусе `queued` **в одной транзакции** со `stored_file`; фоновый публикатор переводит в
|
||
`published` (`request_msg_id` = `Nats-Msg-Id`), ack сервиса — в `processing` (`started_at`). Повтор публикации — по
|
||
`queued` старше N секунд и по `published` без ack.
|
||
- Результат принимается подпиской queue group; `result_msg_id UNIQUE` = дедуп. Нет ack после повторов → `dlq`;
|
||
потерянный результат → `reply_deadline` → `timeout`; replay = сброс в `queued` с `attempt + 1`.
|
||
- Результат одного файла содержит **N документов**; ядро на каждый создаёт `document` и маппит данные.
|
||
- Три формы `Nats-Msg-Id`: запрос `<job_id>:<attempt>`, ack `ack:<job_id>:<attempt>`, результат `result:<job_id>:<attempt>`.
|
||
Ack (`processing`) сервис публикует **до чтения файла из S3**; повторная публикация запроса ядром идёт с тем же
|
||
`Nats-Msg-Id` и тем же `attempt`. Дубликат результата ядро определяет по `recognition_job.result_msg_id`.
|
||
- `schema_version` — `MAJOR.MINOR`; ядро и сервисы принимают тот же MAJOR и любой MINOR. Сервис отвергает запрос с
|
||
неизвестным MAJOR (как и запрос не по схеме) результатом `error` с кодом `SCHEMA_MISMATCH` **без предшествующего ack
|
||
`processing`**: финальный конверт сам закрывает задание в ядре (`published → error`), повторной доставки не будет.
|
||
Конверт публикуется, только если из тела извлекаются корректные `job_id` и `file_id`; для `job_id`, уже находящегося в
|
||
работе или обработанного, он не публикуется (его `Nats-Msg-Id` совпал бы с настоящим результатом). Нечитаемый запрос
|
||
(не JSON, нет `job_id`) остаётся без ответа: сервис пишет лог и отбрасывает его, ядро не получает ack, повторяет
|
||
публикацию `OUTBOX_MAX_REPUBLISH` раз и закрывает задание в `dlq` с `error_code = NO_ACK`. Новый MINOR раскатывается сначала на потребителей
|
||
(ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR, например 1.2), потому что закрытые enum отвергают
|
||
незнакомые значения.
|
||
- Сервисы не знают про БД и про бизнес-смысл: они возвращают структуру файла; нормализация счетов, резолв
|
||
контрагентов, проверки — в ядре.
|
||
|
||
### Цикл маппинга результата и порядок блокировок (Срез 2c)
|
||
|
||
Обработка результата разделена на два шага, чтобы подписка NATS не ждала S3 и тяжёлые вставки (иначе ack следующих
|
||
сообщений не успевает за `ACK_TIMEOUT_SECONDS`, OQ-69):
|
||
|
||
- **Inbox** (`handle_message`, в callback подписки) — быстрый: дедуп по `result_msg_id`, регистрация файла результата в
|
||
`s3_object`, статус задания (`done` / `needs_check` / `error`), статус файла по заданию (`PROCESSING`/`ERROR`). Файл результата **не читает**
|
||
и документов не создаёт.
|
||
- **Цикл маппинга** (`services/result_mapping.py:run_mapping_loop`, фоновая задача lifespan наравне с публикатором outbox и
|
||
вахтёром сроков — всего **три цикла**; период `MAPPING_POLL_SECONDS` = 5) — берёт кандидата `done`/`needs_check`, чей файл
|
||
`PROCESSING` и для которого это текущее задание (`FOR UPDATE SKIP LOCKED`), проверяет ключ результата (бакет хранилища и
|
||
префикс `results/{file_id}/{job_id}/`, OQ-70), читает объект из S3 **вне транзакции**, валидирует `RecognitionResult` v1.x
|
||
и в одной транзакции создаёт документы и строки, переводит файл в `PROCESSED` и выполняет автозамену. Повторяемые
|
||
сбои — до `MAPPING_MAX_ATTEMPTS` (счётчик в `recognition_job.metrics.mapping_attempts`), затем файл `ERROR`
|
||
(`metrics.mapping_error`). Алгоритм — `05-domain-rules.md` §9.5; чистые функции — `domain/osv_mapping.py`.
|
||
|
||
**Статус файла** (`stored_file.status`) меняет только код, проверивший `current_recognition_job_id == job.id`
|
||
(`services/file_status.py`); результат устаревшего задания файл не трогает; `PROCESSED`/`SUPERSEDED` обменом не
|
||
перезаписываются.
|
||
|
||
**Порядок блокировок: задание → файл → документы.** Его соблюдают все участники: inbox, вахтёр, публикатор (`dlq`), цикл
|
||
маппинга (после выбора кандидата блокирует файл), `confirm` (файл, затем документ, оба перечитываются под `FOR UPDATE`) и
|
||
`DELETE /files/{id}` (все задания файла по `id`, затем файл и документы). Автозамена одного ЮЛ дополнительно берёт
|
||
`pg_advisory_xact_lock` по ЮЛ (иначе два параллельно смапленных файла с одним документом не увидят друг друга), загрузка —
|
||
advisory-lock по `(ЮЛ, sha256)` (двойной клик не проскакивает дедуп).
|
||
|
||
### Консолидации и сводная ОСВ (Срез 4)
|
||
|
||
Версия консолидации — единица работы аналитика: периметр (`consolidation_member`), комплект документов
|
||
(`consolidation_document`, явная привязка), статус, блокировка «взять в работу». Сводная ОСВ, чек-лист, признак контрагента и
|
||
пробелы — **чистые функции** от версии (DECISION-5, ничего не кэшируется в БД и в процессе).
|
||
|
||
- **Чистый домен свода (D-1).** `repositories/consolidations.py:load_osv_rows` одним запросом отдаёт строки привязанных
|
||
ОСВ-документов; `domain/consolidated_osv.py:build` и `domain/consolidation_periods.py:select_sources` (без БД и сессий,
|
||
mypy strict) выбирают источники, склеивают кварталы, находят пробелы, резолвят контрагента, применяют фильтры.
|
||
`services/consolidated_osv.py:_build` — единственная сборка, которую делят страница (`GET …/osv`), сводка, кандидаты,
|
||
ручное сопоставление (ответ) и экспорт xlsx (`services/osv_export.py`). Весь свод версии строится в памяти на запрос
|
||
(замер синтетического теста `test_large_version_is_fast_without_n_plus_one`: 5,4 тыс. исходных строк → 1,8 тыс. строк свода —
|
||
0,27 с, 8 запросов; на фикстурах РБ — 1385 строк свода, 22 документа; порог для кэша — OQ-121).
|
||
- **Пакет консолидаций.** `api/endpoints/consolidations/` (periods, versions, locks, status, members, checklist, osv, export,
|
||
mapping); сервисы `consolidations` (создание, список, карточка, периметр), `consolidation_guard` (общий пролог правок:
|
||
версия `FOR UPDATE` → видимость → статус «в работе» → своя блокировка), `consolidation_locks`, `consolidation_status`,
|
||
`consolidation_documents` (привязка/отвязка), `consolidated_osv`, `counterparty_mapping`, `osv_export`; домен — `policy`
|
||
(`ensure_version_editable`), `checklist`, `consolidation_periods`, `consolidated_osv`.
|
||
- **Единственное место смены статуса** — `consolidation_status.transition`: ручной `POST …/status` и автоматические
|
||
переходы (загрузка с версией, маппинг, подтверждение документа) зовут её же, поэтому история и аудит одинаковы. Версия к
|
||
моменту вызова уже под `FOR UPDATE` у вызывающего.
|
||
- **Порядок блокировок: файл → документ → advisory-lock ГК → версии (по возрастанию `id`).** Подтверждение документа берёт файл,
|
||
документ и затем все версии «в работе» ГК ЮЛ (`lock_versions_for_entity` блокирует надмножество — член ИЛИ та же ГК — и
|
||
только потом отбирает версии, где ЮЛ член, иначе confirm ∥ `PUT …/members` теряет привязку: R-2); создание версии берёт
|
||
`pg_advisory_xact_lock` по ГК; правка версии начинается с `consolidation_version FOR UPDATE`. Документы блокируются
|
||
`FOR NO KEY UPDATE` (R-3): вставка `consolidation_document` берёт `FOR KEY SHARE` на документ и конфликтовала бы с
|
||
`FOR UPDATE`. Ручное сопоставление: версия → advisory-lock по нормализованному алиасу (его берёт и `aliases.add`
|
||
справочника) → алиасы → строки `osv_entry` с тем же именем.
|
||
- **Нагрузка и транзакции.** Переход версии после загрузки выполняется в отдельной короткой транзакции
|
||
(`session_transaction`), сбой не отменяет принятые файлы (R-10). Чтение (`GET …/osv`, сводка, экспорт) блокировок не берёт.
|
||
|
||
### Формы ББ/ОФР, проверки §7 и API экрана проверки (Срез 3b)
|
||
|
||
Формы (`statementDocument` контракта v1.2) приходят от обоих сервисов распознавания в одном формате; ядро — единственное
|
||
место, где они становятся строками БД и где считаются проверки §7 (самопроверку `checks` кладёт в результат только tabular-parser, scan-gateway — нет; ядро её игнорирует).
|
||
|
||
- **Маппинг форм — ветка `map_file`, а не отдельный цикл.** После листов ОСВ тот же вызов `map_file` создаёт документы
|
||
`BALANCE`/`INCOME` и `financial_statement_meta/column/line/value` в той же транзакции (порядок: очистка прежних данных
|
||
файла, включая `financial_statement_*` его документов → документы ОСВ и форм → строки → статус файла → автозамена →
|
||
`on_documents_mapped`). Отдельный цикл делил бы файл, его статус и автозамену между двумя транзакциями. `MappingFatal` —
|
||
только при пустом `documents`. Чистые функции — `domain/statement_mapping.py` (черновики документа, шапки, колонок,
|
||
строк и значений; период по дате отчёта либо по самой поздней дате колонок; статус документа §9.3); правила — `05-domain-rules.md`
|
||
§9.5 п. 2.
|
||
- **Проверки §7 — чистые функции без БД.** `domain/statement_checks.py` (`run_checks`, `cross_form_check`) — перенос
|
||
логики `tabular-parser/api/src/parser/statements/checks.py`; словарь — `arm-core/api/rules/statement_checks.yaml`,
|
||
**копия** `tabular-parser/api/rules/statement_checks.yaml` с пометкой источника. Теста идентичности копий между
|
||
репозиториями нет намеренно (урок arm-core #33: тест, читающий соседний репозиторий, ломал main). **Риск:** копии могут
|
||
разойтись незаметно; при изменении словаря в tabular-parser правку нужно повторять в ядре вручную и наоборот. Результат не
|
||
хранится: считается по текущим суммам на каждый `GET …/statement`, правку и подтверждение; хранится только акцепт
|
||
(`financial_statement_meta.checks_accepted_by/at/comment`).
|
||
- **Слои API.** `api/endpoints/files/` — `statements.py` (`GET`/`PATCH /documents/{id}/statement`, `POST …/statement/checks/accept`),
|
||
`comments.py` (`POST/GET /documents/{id}/comments`), `read.py` (`GET /files/{id}/content`); сервис `services/statements.py`
|
||
(чтение, правка, акцепт, пересчёт статуса), `services/documents.py` (подтверждение: `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`),
|
||
`repositories/statements.py`; схемы — `schemas/statements.py`, `schemas/comments.py`; `GET /users/brief` — `api/endpoints/users.py`.
|
||
Правка и акцепт берут блокировки в общем порядке «файл `FOR UPDATE` → документ `FOR NO KEY UPDATE`» и перечитывают шапку
|
||
под блокировкой.
|
||
- **Исходный файл** отдаётся ядром потоком из S3 (`miniopy-async`, чтение частями, без загрузки целиком в память):
|
||
прямая ссылка на MinIO не используется, потому что в контуре клиент может не видеть хранилище, а через ядро действует
|
||
видимость по ЮЛ. Объекта нет в хранилище (убран ретеншном S3 или пропал) — 404 `FILE_CONTENT_NOT_FOUND`; S3 не подключено — 503 `STORAGE_UNAVAILABLE`.
|
||
- **Документы Среза 3b не меняют модель:** DBML v5.16, миграций и значений enum нет; контракт `docs/contracts/` не менялся
|
||
(редакция формы `66n`/`fsbu4` выводится ядром из даты, OQ-145).
|
||
|
||
## Схема ключей S3
|
||
|
||
**Один бакет** (`OPENBAO__MINIO__MINIO_BUCKET` шаблона); роль объекта — первый сегмент ключа:
|
||
`{bucket_role}/{file_id}/{job_id}/{name}`. Роли: `uploads` (исходники, срок — весь срок хранения), `results`
|
||
(нормализованные JSON; ретеншн — ARCH-010, открыт), `exports` (7 дней), `archive` (data lake). Все объекты регистрируются
|
||
в `s3_object` (`bucket_role`, `bucket`, `key`, sha256, size, expires_at). Сырой ответ OCR `raw/{job_id}.json` — по
|
||
конвенции, без записи в БД. Lifecycle-правила — по префиксам.
|
||
|
||
Срез 2c: ядро кладёт исходник по ключу `uploads/{file_id}/{job_id}/{безопасное имя}` (`job_id` задаётся заранее — ключ
|
||
известен до записи в БД; имя очищается от путей и управляющих символов); `results/{file_id}/{job_id}/…` пишет сервис
|
||
распознавания, а ядро перед чтением проверяет, что ключ лежит именно в папке задания (OQ-70). При удалении файла объектам
|
||
`uploads` и `results` проставляется `s3_object.expires_at = now()`, физически они не стираются (ретеншн — OQ-111, ждёт
|
||
lifecycle-правил платформы); объект, записанный в S3 до сбоя БД, остаётся сиротой (то же правило).
|
||
|
||
## Аутентификация и авторизация
|
||
|
||
1. SPA получает JWT от РАИСА; каждый запрос несёт `Authorization: Bearer`.
|
||
2. arm-core валидирует подпись/срок, извлекает email, находит `app_user` (`is_active`); нет пользователя — 403.
|
||
3. Права: `effective_permissions = ∪ role_permission(roles(user))`. Проверка в зависимости FastAPI
|
||
`require(permission_code)`.
|
||
4. **Policy-слой** поверх прав (отдельный модуль, чистые функции): видимость по филиалу (`regional_office`),
|
||
по ЗО (`business_segment`), допустимость действия по статусу версии (`consolidation_status`) и по активной
|
||
блокировке (`consolidation_lock`). Право говорит «может», policy — «эту ли и сейчас ли».
|
||
5. Локальная разработка: заглушка-эмитент JWT с теми же claims (feature-flag `AUTH_DEV_ISSUER`).
|
||
6. **Контур банка — временно тоже на заглушке** (`DEBUG=true` + `AUTH_DEV_ISSUER=true`, вход по email через
|
||
`POST /api/v1/dev/token`): параметры JWT РАИСА неизвестны (OQ-54), платформенной аутентификации в контуре пока нет.
|
||
Подробности и последствия — `dev/contour-transfer-guide.md`, раздел 6.3.
|
||
|
||
## Слои arm-core (по раскладке шаблона команды)
|
||
|
||
Структура — раскладка шаблона `api/src/` (см. «Соглашения шаблона» ниже); `tabular-parser` и `scan-gateway` — те же
|
||
каталоги без `web/`, `db/`, `models/`, `alembic/`. Соответствие прежним названиям этого документа: `backend/app/` →
|
||
`api/src/`, `integrations/` → `core/`, `migrations/` → `api/alembic/`, `frontend/` → `web/`.
|
||
|
||
```
|
||
api/src/
|
||
api/endpoints/ # роутеры FastAPI по контекстам: me, directories, files, documents, consolidation, kfo, ex, audit, users
|
||
api/dependencies/ # фабрики Depends: сессия, сервисы, current_user, require(permission)
|
||
domain/ # чистая предметная логика без I/O: правила ОСВ, периоды, матчинг, элиминация, проверки, пороги,
|
||
# машины статусов, policy — pydantic-модели на входе и выходе
|
||
services/ # use-cases: оркестрация domain + repositories + outbox + audit_event
|
||
repositories/ # SQLAlchemy-запросы, в т. ч. запросы-вычисления (сводная ОСВ, шахматка)
|
||
models/ # ORM 1:1 с DBML v5.17; имена таблиц/колонок/enum — как в DBML
|
||
schemas/ # pydantic DTO API /api/v1
|
||
core/ # config, clients (S3; NATS outbox-publisher и result-consumer), auth (JWT РАИСА / dev-эмитент),
|
||
# logger, exception_handlers
|
||
db/ # engine, сессия
|
||
api/alembic/ # миграции
|
||
api/scripts/ # check_dbml_vs_orm.py, seed
|
||
api/tests/ # фикстуры — из ../fixtures (ARM_KA_FIXTURES)
|
||
web/ # SPA
|
||
openspec/
|
||
```
|
||
|
||
Принципы: `domain/` тестируется без БД на фикстурах из `../fixtures`; вычисляемые объекты консолидации возвращаются
|
||
как pydantic-модели и **не** имеют ORM-таблиц; все мутации идут через `services/` с записью `audit_event`.
|
||
|
||
## Статусы и машины состояний
|
||
|
||
Три независимые машины: `file_status` (техника файла), `document_status` (проверка аналитиком), `consolidation_status`
|
||
(маршрут КФО) + `recognition_status` (жизненный цикл задания). Переходы — `05-domain-rules.md` §9. Реализовать как
|
||
явные таблицы переходов в `domain/`, а не `if`-ы по роутерам.
|
||
|
||
## Соглашения шаблона команды
|
||
|
||
Три сервисных репозитория развёрнуты из нулевого коммита репозитория команды `RSHB/DKB_ARMKA` (`ba172f8`, «Шаблон
|
||
FastAPI» платформы РАИСА/aurora): дерево импортировано как есть (`arm-core@ba92222`), доменный код удалён, каркас
|
||
оставлен (`8e1f423`). **Документы подстраиваются под шаблон, а не наоборот.** Ниже — что шаблон задаёт и что мы к нему
|
||
добавляем. Один и тот же текст действует для всех трёх репозиториев; отличия сервисов — в разделе «Что вырезано».
|
||
|
||
### Раскладка репозитория
|
||
|
||
```
|
||
<repo>/
|
||
README.md описание шаблона (в Срезе 0 заменяется описанием сервиса)
|
||
raisa_config.yml профиль ресурсов платформы (cpu/ram); читает платформа при деплое
|
||
setup/ бутстрап разработчика: setup.sh (venv + pip + npm i); pip.conf и .npmrc → внутренний Nexus банка
|
||
api/ backend. РАБОЧИЙ КАТАЛОГ для всех команд: от него ищутся .env, pytest.ini и web/ для раздачи SPA
|
||
run_server.py точка входа платформы: uvicorn, host/port из SERVER__HOST / SERVER__PORT, lifespan=on
|
||
requirements.txt прод-зависимости, pinned (fastapi 0.135, SQLAlchemy 2.0.36, alembic 1.14, asyncpg, pydantic 2.13,
|
||
pydantic-settings, nats-py 2.14, miniopy-async, smbprotocol, loguru, sentry-sdk, raisa_fastapi_protected_api)
|
||
requirements-dev.txt dev-зависимости (pytest-asyncio, pytest-env, openpyxl, …) — пины рассинхронизированы с requirements.txt
|
||
pytest.ini asyncio_mode=auto, loop scope session, env APP_ENV=test / ENV_FILE=.env.test, testpaths=tests
|
||
alembic.ini, alembic/ env.py берёт URL из настроек приложения, схема dkb, compare_type=True, include_schemas
|
||
src/
|
||
__init__.py экспортирует app и get_settings
|
||
app.py FastAPI: lifespan (SMB-сессия, MinIO, NATS), middleware (gzip, proxy headers, CORS, РАИСА при DEBUG=false),
|
||
GET /healthcheck, include_router, раздача SPA из api/web/ (SPAStaticFiles, fallback index.html)
|
||
api/router.py агрегирующий APIRouter; api/endpoints/* — роутеры; api/dependencies/* — фабрики Depends
|
||
core/config.py pydantic-settings (см. «Конфигурация»); модуль создаёт settings при импорте
|
||
core/clients.py клиенты инфраструктуры (S3Storage поверх miniopy-async)
|
||
core/nats_callback.py обработчики сообщений NATS
|
||
core/logger.py loguru: setup_logger, перехват std logging и uvicorn, request_id, шумовые пути
|
||
core/exception_handlers.py ошибки → JSON {"detail": …}
|
||
common/exceptions.py доменные исключения (NotFoundError, CouldntCreateError)
|
||
db/db.py async engine (asyncpg), search_path=dkb, get_async_session
|
||
models/base.py DeclarativeBase с MetaData(schema="dkb"), UUIDPrimaryMixin (uuid4 на стороне приложения)
|
||
schemas/ pydantic DTO и enum'ы API
|
||
repositories/ запросы: класс на таблицу, принимает AsyncSession
|
||
services/ use-cases
|
||
tests/conftest.py
|
||
web/ frontend (остаётся только в arm-core): React 18 + TS, rsbuild, antd, Redux Toolkit, biome
|
||
```
|
||
|
||
Наши дополнения к `api/src/`, не меняющие существующих каталогов: `domain/` — чистая предметная логика без I/O
|
||
(правила §1–§10, машины статусов, policy); `core/auth.py` — JWT РАИСА и dev-эмитент; `api/scripts/` — служебные скрипты
|
||
(`check_dbml_vs_orm.py`, seed). Пути в документах вида `rules/*.yaml`, `scripts/…`, `tests/…` отсчитываются от `api/`.
|
||
|
||
### Конфигурация
|
||
|
||
- `pydantic-settings`, файл `api/.env`, вложенность через `__`, лишние переменные игнорируются. Все инфраструктурные
|
||
секреты — под префиксом `OPENBAO` (в проде заполняет OpenBao): `OPENBAO__DATABASE__DATABASE_URL`,
|
||
`OPENBAO__MINIO__{MINIO_URL, MINIO_ADMIN_LOGIN, MINIO_ADMIN_PASSWORD, MINIO_BUCKET}`,
|
||
`OPENBAO__NATS__{NATS_URL, NATS_SEED, NATS_JWT}`, `OPENBAO__SMB__{SMB_HOST, SMB_PATH, SMB_LOGIN, SMB_PASSWORD}`.
|
||
Прочее: `DEBUG`, `ROOT_PATH`, `VERSION`, `SERVER__HOST`, `SERVER__PORT`.
|
||
- Платформа РАИСА передаёт в под только секреты OpenBao, поэтому любая настройка верхнего уровня `Settings` в контуре
|
||
задаётся как `OPENBAO__APP__<ИМЯ>` (приоритет: обычный env → `OPENBAO__APP__*` → `.env` → умолчание; неизвестное
|
||
имя — ошибка старта; `OPENBAO__APP__OPENBAO__…` запрещено). Контурные значения — `dev/contour-transfer-guide.md`, раздел 6.
|
||
- `settings` создаётся при импорте (`core/config.py`, `db/db.py`, `app.py`) — импорт почти любого модуля требует
|
||
полного окружения; тесты получают его из `.env.test`.
|
||
- `DEBUG=true` — режим разработки шаблона: NATS не подключается, middleware РАИСА не импортируется. Наши флаги
|
||
разработки (`AUTH_DEV_ISSUER`, `SCAN_EMULATOR`, режим горячих папок) добавляются так же — отдельными полями `Settings`,
|
||
по умолчанию выключены; в проде ни один не включается.
|
||
|
||
### База данных и миграции
|
||
|
||
- SQLAlchemy 2.0 async + asyncpg. Одна схема PostgreSQL **`dkb`** — зашита в шаблоне (MetaData, `search_path`,
|
||
`version_table_schema` и `include_name` Alembic). Следуем шаблону, не `public`.
|
||
- Alembic берёт URL из настроек, не из `alembic.ini`; ревизии — `api/alembic/versions/`, имя файла стандартное
|
||
`<rev>_<slug>.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`.
|
||
- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.17.
|
||
|
||
### Инфраструктурные клиенты
|
||
|
||
- **S3** — `miniopy-async` (`Minio`, `cert_check=False`), **один бакет** `MINIO_BUCKET`; роли `s3_object.bucket_role`
|
||
становятся первым сегментом ключа (`uploads/…`, `results/…`, `raw/…`, `exports/…`, `archive/…`).
|
||
- **NATS** — `nats-py`; аутентификация nkeys-seed + JWT (`NATS_SEED`, `NATS_JWT`) через `user_jwt_cb`/`signature_cb`;
|
||
соединение и подписки создаются в lifespan и живут в `app.state.nats` / `app.state.nats_subs`. Core NATS без
|
||
JetStream: подписки с queue group, заголовки `Nats-Msg-Id` ставим сами, ack и повторы — на уровне приложения.
|
||
Учётные данные одни на все сервисы АРМ: один JWT с правами по subject'ам (ответ владельца 06.10.2026).
|
||
Subject'ы — **как в старом АРМ** (решение владельца 08.10.2026): на проде заведены `files.tabular_uploaded`,
|
||
`files.img_uploaded`, `files.tabular_parsed`, `files.img_parsed`; используем три — запросы в `files.tabular_uploaded`
|
||
и `files.img_uploaded`, ack и результаты обоих сервисов в общий `files.tabular_parsed`; `files.img_parsed` не
|
||
используется. Старый АРМ гасится, общих подписчиков на этих subject'ах у нас с ним не будет.
|
||
- **SMB** (горячие папки ИС Сканирование) — `smbprotocol`/`smbclient`, сессия регистрируется в lifespan; нужен только `scan-gateway`.
|
||
- **Внешняя аутентификация** — пакет `raisa_fastapi_protected_api` из внутреннего Nexus (`AuthorizationMiddleware`,
|
||
`ProtectedSettings`, …), импортируется только при `DEBUG=false` (в контуре временно `DEBUG=true`, см. «Аутентификация и авторизация», п. 6); фронтовая пара — `raisa-fastapi-protected-web`.
|
||
Вне сети банка оба пакета недоступны (см. вопросы Среза 0).
|
||
|
||
### DI, слои, ошибки, логи
|
||
|
||
- Контейнера DI нет: ручные фабрики `Depends` в `api/dependencies/` — фабрика получает `AsyncSession` из
|
||
`get_async_session`, собирает репозитории и сервис; инфраструктурные клиенты берёт из `request.app.state` (`storage`, `nats`).
|
||
- Поток запроса: `api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`. Доменные исключения из
|
||
`common/exceptions.py` маппятся в HTTP в `core/exception_handlers.py` (RequestValidationError → 422, IntegrityError → 409,
|
||
SQLAlchemyError → 500, HTTPException как есть, прочее → 500); тело всегда `{"detail": …}`, тексты по-русски.
|
||
- Логи — loguru (`core/logger.setup_logger`): перехват std logging и uvicorn, `request_id` в extra, успешные обращения к
|
||
`/healthcheck`, `/docs`, статике не логируются.
|
||
|
||
### Тесты
|
||
|
||
- pytest + pytest-asyncio (`asyncio_mode=auto`, loop scope `session`) + pytest-env; запуск из `api/`; тесты в `api/tests/`.
|
||
- Тестовую БД шаблон не задаёт. Решение: тесты, которым нужна БД, идут на PostgreSQL из docker-compose (enum'ы,
|
||
partial unique, jsonb, `NULLS NOT DISTINCT` в SQLite не воспроизводятся); `domain/` тестируется без БД.
|
||
|
||
### Линтеры и типизация
|
||
|
||
- Backend: шаблон декларирует black / isort / pylint (бейджи README), конфигов не содержит. Принимаем `ruff`
|
||
(format — совместим с black; lint с правилами isort `I` и подмножеством pylint `PL`) и `mypy`; конфиг — `api/pyproject.toml`
|
||
(решение Среза 0; откат на black/isort/pylint — замена одного файла конфига и шага CI).
|
||
- Frontend: `biome` 1.9 (`npx biome check src`): ширина строки 140, одинарные кавычки, `bracketSameLine`,
|
||
`noExplicitAny` warn, `useAwait`/`noEmptyBlockStatements` error; типы — `npx tsc --noEmit` (strict). ESLint/Prettier нет.
|
||
|
||
### Сборка и деплой
|
||
|
||
- Платформа РАИСА запускает `api/run_server.py` с `SERVER__HOST`/`SERVER__PORT` из окружения; ресурсы — `raisa_config.yml`.
|
||
Python на платформе — **3.11** (ответ 03.10.2026); версия Node неизвестна (локально `node:22`).
|
||
- `Dockerfile`, `docker-compose.yml`, `.dockerignore` шаблон держит в `.gitignore` («Custom ignore»): образ собирает
|
||
платформа своим CI/CD, в который мы не лезем. Для локальной разработки: compose — на уровне workspace
|
||
(`docs/dev/docker-compose.yml`), `Dockerfile` (`python:3.11-slim`) — в корне каждого сервиса, строка `Dockerfile`
|
||
убирается из `.gitignore` (единственная правка шаблона; решение Среза 0).
|
||
- Фронт: `npm run build` (rsbuild) → `web/dist/` → копируется в `api/web/`, откуда FastAPI раздаёт SPA; роутеры
|
||
подключаются **до** mount'а `/`.
|
||
- Зеркала пакетов: pip и npm настроены на `nexus.dev.raisa.go.rshbank.ru` (`setup/pip.conf`, `setup/.npmrc`). Вне банка
|
||
Nexus недоступен до прода: dev-установка ставит `requirements.txt` без строк `raisa_*` (`make venv`), в
|
||
`web/package.json` пакеты `raisa-*` переносятся в `optionalDependencies`; импорты `raisa_fastapi_protected_api` условные
|
||
(`DEBUG=false`), как в шаблоне.
|
||
- CI в Gitea нет и не планируется; конфигураций CI в репозиториях нет. Проверки качества — одинаковые цели `Makefile`
|
||
во всех репозиториях: `make check` (ruff, mypy, pytest без Docker, идентичность контракта) и `make check-full`
|
||
(+ интеграционные тесты против compose, для arm-core — цикл миграций и `check_dbml_vs_orm.py`); `make check-full`
|
||
обязателен перед PR. Описание — `docs/dev/checks.md`.
|
||
|
||
### Frontend (только arm-core)
|
||
|
||
- React 18.3 + TypeScript 5.7 (strict), сборка rsbuild 1.6 (`npm run start | build | preview`); основной UI-кит — antd 6
|
||
(+ `@ant-design/icons`); установлены также tailwind 3 + daisyui 4 (тема `rshb`), `react-daisyui`, `raisa-ui` — точечно;
|
||
состояние — Redux Toolkit 2 (`store/store.ts`, слайсы в `store/slices/`, типизированные `useAppDispatch`/`useAppSelector`);
|
||
маршрутизация — react-router-dom 7 (`BrowserRouter basename=Config.APP_ADDRESS`, маршруты в `App.tsx`); `pdfjs-dist` — PDF.
|
||
- Структура `web/src/`: `layouts/` — страницы, `widgets/` — составные блоки, `components/` — примитивы, `store/`, `conf/`;
|
||
стили — CSS-модули (`styles.module.css`) и токены antd (`ConfigProvider` в `index.tsx`).
|
||
- Переменные окружения только с префиксом `REACT_APP_` (rsbuild `loadEnv`), читаются через `conf/config.ts`: все запросы к
|
||
бэку — `${Config.API_URL}/api/v1/…` (в сборке относительный путь); `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка
|
||
(по умолчанию `/aurora/apps/fastapi-dkb-armka`) задаёт `base`/`assetPrefix` rsbuild и `basename` роутера.
|
||
|
||
### Что вырезано в tabular-parser и scan-gateway
|
||
|
||
Сервисы распознавания — тот же шаблон без фронта и без БД (DECISION-4). На момент Среза 0 оба репозитория — полные копии
|
||
каркаса `arm-core`; change `slice-0-skeleton` каждого сервиса удаляет: `web/` целиком; `setup/.npmrc` и npm-шаги
|
||
`setup/setup.sh`; из `app.py` — mount SPA (`SPAStaticFiles`, `os.makedirs("web")`), CORS, импорт
|
||
`raisa_fastapi_protected_api` (внешнего API нет — только `/healthcheck`); `db/`, `models/`, `repositories/`, `alembic/`,
|
||
`alembic.ini` и `OPENBAO.DATABASE` из настроек; из `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `argon2-cffi`,
|
||
`raisa_fastapi_protected_api`; шаблонный `README.md` заменяется описанием сервиса. `tabular-parser` дополнительно без SMB
|
||
(`smbprotocol`, `OPENBAO.SMB`, регистрация сессии в lifespan). Остаются: `run_server.py`, `raisa_config.yml`, `setup/setup.sh`
|
||
+ `pip.conf`, `pytest.ini`, настройки `OPENBAO__MINIO__*` / `OPENBAO__NATS__*` / `DEBUG`, `core/config.py`, `core/clients.py`,
|
||
`core/nats_callback.py`, `core/logger.py`, `core/exception_handlers.py`, `common/exceptions.py`. Точный список — в
|
||
`CLAUDE.md` сервиса и в `tasks.md` его change'а.
|
||
|
||
## Что ожидаем от платформы (для запроса ресурсов)
|
||
|
||
По ответам платформы (03.10.2026): PostgreSQL **16** (одна БД, схема `dkb` — создавать схемы можно), NATS **без
|
||
JetStream** (3 subject `files.*` — как в старом АРМ, решение владельца 08.10.2026; аутентификация nkeys/JWT, как в шаблоне), S3-совместимое хранилище (один бакет;
|
||
префиксы-роли + lifecycle по префиксам), Python 3.11 в базовом образе, Nexus (pip/npm, пакеты `raisa_*`) — только внутри
|
||
банка, CI/CD — пайплайн платформы (мы отдаём команды `make check` / `make check-full`). Секреты — через переменные
|
||
окружения (`OPENBAO__*`). Открыто: версия Node, параметры JWT РАИСА.
|
||
|
||
Дополнено 06.10.2026 по репозиторию `RSHB/armka_file_worker` (PDFWorker старого АРМ, проверен в контуре банка) и ответам
|
||
владельца:
|
||
- NATS: core NATS без JetStream подтверждён кодом, работающим в контуре (`nats-py[nkeys] 2.14`, `nats.connect` с
|
||
`user_jwt_cb`/`signature_cb`, `max_reconnect_attempts=-1`; учётные данные опциональны — оба пустых означают анонимное
|
||
подключение). Один JWT на все сервисы АРМ, права по subject'ам заданы в JWT. Можно ли включить JetStream — по-прежнему
|
||
OQ-85.
|
||
- Переменные окружения те же, что в шаблоне: `OPENBAO__NATS__{NATS_URL, NATS_JWT, NATS_SEED}`, `OPENBAO__MINIO__*`,
|
||
`OPENBAO__SMB__*`, вложенность через `__`.
|
||
- Приложения на платформе Aurora живут под префиксом `ROOT_PATH = /aurora/apps/fastapi-dkb-armka-<service>` (у
|
||
FileWorker — `fastapi-dkb-armka-pdf-worker`); наши сервисы держат `ROOT_PATH = /` по умолчанию и получают префикс из
|
||
окружения. Healthcheck `GET /healthcheck` отвечает `503`, если NATS отключён или воркер остановлен — тот же принцип,
|
||
что у нас.
|
||
- Горячие папки ИС Сканирование: эталон — раскладка FileWorker (`Input/<job_id>.<ext>`, `Output/<job_id>/*.json`,
|
||
очистка после чтения). Уточнение 06.10.2026: временный `.part` пишется в `Tmp`, а не в `Input` (публикация снимает
|
||
его сама, остаются осиротевшие и дубли), очистка — под `SCAN_CLEANUP` (по умолчанию выключена); см. описание scan-gateway выше, OQ-137, OQ-144.
|
||
- FileWorker **не** реализует наш контракт DECISION-4: subject'ы `files.*`, конверт `task_id`/`file_id`/`source_s3_key`/
|
||
`expires_at` → `status ready|timeout|error` + `error_code`, без ack `processing`, без `Nats-Msg-Id`, без queue group
|
||
(один экземпляр), дедуп только в памяти процесса — доставка at-most-once. Это параллельный сервис старого АРМ, а не
|
||
замена scan-gateway; из него берём только инфраструктурные решения (как и из `reference-old-arm`).
|