arm-ka-docs/context/03-architecture.md
2026-10-13 13:38:50 +04:00

53 KiB
Raw Blame History

03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.18)

Компоненты (= репозитории, см. 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 в нижнем регистре; файл пишется сразу в Input с перезаписью существующего, без временного файла и Tmp — как в старом АРМ, решение владельца 09.10.2026, scan-gateway #26), дождаться набора 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. Таймаут 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 и recognition-result-message.schema.json — тела сообщений, recognition-result.schema.json — файл результата в S3; примеры — docs/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
ack files.tabular_parsed (умолчание) tabular-parser / scan-gateway конверт со schema_version, status = processing, job_id, file_id, service {name, version} ack:<job_id>:<attempt> message
результат 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; файл в S3 — result

Имена 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: запрос <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.18; имена таблиц/колонок/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, 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/, имя файла стандартное <rev>_<slug>.py. Автогенерация — только черновик: итоговую ревизию сверяет с DBML api/scripts/check_dbml_vs_orm.py.
  • PK везде uuid через UUIDPrimaryMixin; имена таблиц, колонок и enum'ов — ровно как в DBML v5.18.

Инфраструктурные клиенты

  • 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-<service> (у FileWorker — fastapi-dkb-armka-pdf-worker); наши сервисы держат ROOT_PATH = / по умолчанию и получают префикс из окружения. Healthcheck GET /healthcheck отвечает 503, если NATS отключён или воркер остановлен — тот же принцип, что у нас.
  • Горячие папки ИС Сканирование: эталон — раскладка FileWorker (Input/<job_id>.<ext>, Output/<job_id>/*.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).