# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.19) ## Компоненты (= репозитории, см. `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` (контракт v2.0 — статусы строк, `needs_review`, `document.status` и структурные предупреждения, change `unified-statement-normalization`; до перехода сервис пишет v1.2: `DISCLOSURE` в `document_type`, `statementDocument.checks`, границы периода ОСВ `period_start/period_end/is_from_year_start`). Правила распознавания заголовков/группировок — `rules/*.yaml`. - **scan-gateway** — адаптер ИС Сканирование: положить файл в горячую папку `Input/.` (расширение — суффикс `original_name` в нижнем регистре; файл пишется сразу в `Input` с перезаписью существующего, без временного файла и `Tmp` — как в старом АРМ, решение владельца 09.10.2026, scan-gateway #26), дождаться набора `Output//*.json` (по одному json на документ; набор готов, когда имена и размеры не менялись `SCAN_SETTLE_SECONDS`), сохранить склеенный сырой ответ в S3 `raw/{job_id}.json` (в БД не регистрируется), очистить `Output//` и входящий файл — **только под флагом `SCAN_CLEANUP`, по умолчанию выключен** (права на удаление на SMB-шаре контура не подтверждены, OQ-144), нормализовать в тот же контракт результата (`OcrNormalizer` по `05-domain-rules.md` §11/§11.1: один `statementDocument` на таблицу ответа, справочники `api/rules/` с редакциями форм), опубликовать `files.tabular_parsed`. Таймаут 20 мин → `TIMEOUT` (`SCAN_TIMEOUT_SECONDS` и срок ответа OCR в ядре, решение владельца 09.10.2026). Раскладка папок — как у проверенного в контуре PDFWorker старого АРМ (`RSHB/armka_file_worker`, решение владельца 06.10.2026, OQ-137), очистка под флагом. Эмулятор ИС Сканирование (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` | `:` | [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::` | [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::` | [message](../contracts/recognition-result-message.schema.json); файл в S3 — [result](../contracts/recognition-result.schema.json) | Имена `files.*` на схеме и в таблице выше — умолчания: subject'ы являются настройками окружения `OPENBAO__NATS__NATS_{TABULAR_REQUEST,IMAGE_REQUEST,RESULT}_SUBJECT` (`files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`), одинаковыми у всех трёх сервисов. Queue group'ы — константы. `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`: запрос `:`, ack `ack::`, результат `result::`. 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`, сводка, экспорт) блокировок не берёт. ### Общий код нормализации ББ/ОФР — `arm-statements` (change `unified-statement-normalization`, целевое) Шаги 2–8 нормализации форм (раскладка, статусы строк, значения, код и наименование, сборка документа, проверки §7, `needs_review`, статус и предупреждения) и их словари живут в отдельном репозитории `arm-statements` пакетом `arm_statements/` (Р-1: платформа не собирает Python-пакеты). В tabular-parser, scan-gateway и arm-core пакет лежит **копией** (`api/arm_statements/`, маркер версии — `api/arm_statements/VERSION`) с тестами, импортируется по имени `arm_statements`; копию раскладывает и проверяет `sync-statements` в docs (`--check`: 0 — синхронно, 10 — отстала, 20 — прямая правка, 2 — нет checkout; вызывается из `make check-full`, не из `make check`; механика — `dev/checks.md`). Упорядоченные перечни кодов форм — данные `contracts/statement-code-orders.yaml`, в пакете — их байтовая копия. Сервисы держат только адаптер шага 1 (json ИС / лист Excel → `FormCandidate`); ядро считает тем же кодом после правок аналитика и не держит своих копий словаря §7. Словарь «код → наименование» — только в ядре. Правила — `05-domain-rules.md` §11; маркер версии копии — `service.statements_version` результата → `recognition_job.statements_version`. Порядок выкладки — «сначала ядро» (ядро принимает 1.x и 2.0). Пункты ниже описывают поведение до перехода (Срез 3b). ### Формы ББ/ОФР, проверки §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.19; имена таблиц/колонок/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`). **Документы подстраиваются под шаблон, а не наоборот.** Ниже — что шаблон задаёт и что мы к нему добавляем. Один и тот же текст действует для всех трёх репозиториев; отличия сервисов — в разделе «Что вырезано». ### Раскладка репозитория ``` / 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, NATS_TABULAR_REQUEST_SUBJECT, NATS_IMAGE_REQUEST_SUBJECT, NATS_RESULT_SUBJECT}` (во всех трёх сервисах; `NATS_SEED` и `NATS_JWT` — `SecretStr`, три subject'а — настройки с умолчаниями `files.*`), `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/`, имя файла стандартное `_.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`. - PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.19. ### Инфраструктурные клиенты - **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'ах у нас с ним не будет. Прод и тест — свои наборы: JWT, seed и три subject'а окружения задаются одинаково всем трём сервисам; JWT окружения должен давать publish/subscribe на эти subject'ы. Разъехавшиеся настройки — тишина на шине (dlq по NO_ACK); сверять `/healthcheck.subjects` ядра и сервисов (решение владельца 12.10.2026). - **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-` (у FileWorker — `fastapi-dkb-armka-pdf-worker`); наши сервисы держат `ROOT_PATH = /` по умолчанию и получают префикс из окружения. Healthcheck `GET /healthcheck` отвечает `503`, если NATS отключён или воркер остановлен — тот же принцип, что у нас. - Горячие папки ИС Сканирование: эталон — раскладка FileWorker (`Input/.`, `Output//*.json`, очистка после чтения). Уточнение 09.10.2026: входящий файл пишется прямо в `Input` с перезаписью, как в старом АРМ (временный `.part` в `Tmp` от 06.10.2026 отменён), очистка — под `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`).