arm-ka-docs/context/03-architecture.md
Andrey Epifancev 68bde14b5a docs(context): Срез 3b — §7/§9 по факту, контракт API форм, OQ (#48)
§7 (проверки в ядре, межформенная мягкая, акцепт), §9.3/§9.5 (формы ББ/ОФР, период,
правка до подтверждения, условия подтверждения, исходный файл и комментарии),
OQ-153 и пометки «допущение реализовано» у OQ-23/36/77/140/145, раздел 3b в
03-architecture, статусы срезов в 09-delivery-plan, dev/api-contract-slice-3b.md,
сценарии 6.9 в dev/qa-guide.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 13:30:02 +04:00

430 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.16)
## Компоненты (= репозитории, см. `00-workspace-layout.md`)
```
┌──────────────┐ JWT (email) ┌──────────────────────────────┐
браузер ──────▶│ React SPA │ ───────────────▶│ arm-core (FastAPI) │
└──────────────┘ │ REST /api/v1, policy-слой, │
│ единственный писатель в БД │
└───────┬──────────┬───────────┘
│ │
PostgreSQL │ │ S3 (uploads/ results/ exports/ archive/)
│ │
NATS (core) ◀─────────────────┘ outbox │
recognition.tabular.request │
recognition.image.request │
recognition.result ───────────────────▶ 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/`, публикует `recognition.result`. Внутри — `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/` с редакциями
форм), опубликовать `recognition.result`. Таймаут 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` | Схема |
|---|---|---|---|---|---|
| запрос | `recognition.tabular.request` / `recognition.image.request` | 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 | `recognition.result` | 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) |
| результат | `recognition.result` | 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` на `recognition.result`; сервис сразу по получении корректного запроса публикует 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`).
## Слои 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.16; имена таблиц/колонок/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`.
- `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.16.
### Инфраструктурные клиенты
- **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'ы (`recognition.*`) должны быть внесены в права этого JWT платформой; старый АРМ и `armka_file_worker`
работают на семействе `files.*` (`files.tabular_uploaded`, `files.tabular_parsed`, `files.img_parsed`).
- **SMB** (горячие папки ИС Сканирование) — `smbprotocol`/`smbclient`, сессия регистрируется в lifespan; нужен только `scan-gateway`.
- **Внешняя аутентификация** — пакет `raisa_fastapi_protected_api` из внутреннего Nexus (`AuthorizationMiddleware`,
`ProtectedSettings`, …), импортируется только при `DEBUG=false`; фронтовая пара — `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 `recognition.*`; аутентификация 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`).