Andrey Epifancev 397a1a7aef fix(counter-pairs): правки финального ревью (I-1, I-2, I-3, M-1, M-2, M-3, M-5)
Подпись пары без служебных слов, тест пары на фикстурах РБ, модалка при уходе с «ОСВ» на любую вкладку,
без «Экспорт» в режиме пар, README и ARCHITECTURE, таблица решений design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:22:25 +04:00

arm-core — ядро АРМ КА

Ядро автоматизированного рабочего места кредитного аналитика Департамента крупного бизнеса (АРМ КА, АО «Россельхозбанк»): справочники, файлы и документы, приём результатов распознавания, вычисления консолидации (КФО ГСК), маршрут согласования, Единое хранилище (ЕХ), аудит и права. Единственный писатель в PostgreSQL: сервисы распознавания (tabular-parser, scan-gateway) видят только NATS и S3.

Монорепозиторий по шаблону команды «Шаблон FastAPI»: api/ — бэкенд (FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16, core NATS без JetStream), web/ — фронтенд (React + TypeScript, rsbuild).

Этот репозиторий — часть workspace АРМ КА. Общий контекст, модель данных, правила предметной области и контракты — в соседних репозиториях:

  • ../CLAUDE.md — инварианты и процесс работы всего workspace;
  • ../docs/ — контекст (context/), модель данных (model/), контракты (contracts/), окружение (dev/);
  • CLAUDE.md — соглашения этого репозитория.

Срез 0 («скелет ядра»): схема БД по DBML, справочники (seed), аутентификация и права, GET /api/v1/me, /healthcheck. Спецификация — openspec/changes/slice-0-skeleton/.

Срез 1 («справочники»): схема по DBML v5.14, API ГК / ЮЛ / ГСК/ГСЗ с политикой видимости, импорт из ЕЦП CRM, администрирование пользователей и ролей, экран /admin, аудит. Спецификация — openspec/changes/slice-1-directories/; разделы ниже: «API справочников», «Импорт из ЕЦП CRM», «Администрирование и экран /admin», «Ошибки API».

Срез 2c («загрузка ОСВ и маппинг»): схема по DBML v5.15, загрузка файлов POST /legal-entities/{id}/files, статусы файла по заданию распознавания, цикл маппинга результата парсера в document и osv_entry, автозамена версий, API файлов, документов и строк, подтверждение и удаление. Спецификация — openspec/changes/slice-2c-osv-ingest/; разделы ниже: «Первичные документы: загрузка», «Статусы файла и документа», «Цикл маппинга», «API файлов и документов», «Удаление и замена», «Ограничения Среза 2c», «Сквозной прогон (e2e)».

Срез 3b («формы ББ/ОФР в ядре и API до сводной ОСВ»): маппинг форм ББ/ОФР из результата парсера в ядро, форма для экрана «Проверка распознавания» с проверками §7, правка формы до подтверждения и акцепт проверок, условия подтверждения формы, комментарии к документам, исходный файл потоком, имена пользователей, documents_count у ЮЛ, CSV чек-листа. Спецификация — openspec/changes/archive/2026-10-07-slice-3b-statements-api/; разделы ниже: «API файлов и документов», «Формы ББ/ОФР (Срез 3b)», «Ошибки API».

Срез 4 («консолидация»): схема по DBML v5.17, версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы, привязка подтверждённых документов к версии, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx. Спецификация — openspec/changes/slice-4-consolidation/; раздел ниже: «Консолидация (Срез 4)».

Повторная обработка и уточнение ОСВ (AURORA-1442, DBML v5.20–v5.21)

Все маршруты ниже требуют DOCUMENT_EDIT и видимость ЮЛ; чужой объект отвечает тем же 404, что несуществующий.

Запрос (префикс /api/v1) Успех Назначение
POST /files/{file_id}/retry 202 FileOut Новая попытка обработки одного файла
POST /consolidations/versions/{version_id}/files/retry 202 {accepted, skipped} Подходящие файлы консолидации по периметру выбранной версии
PATCH /documents/{document_id}/osv-header 200 DocumentOut Уточнение периода и счёта ОСВ

Ручной повтор разрешён для файла ERROR, только если его текущее задание tabular завершилось timeout/TIMEOUT или dlq/NO_ACK, документов нет и attempt < max_attempts. Создаётся новый job queued, attempt + 1; файл становится UPLOADED, исходный S3-объект сохраняется, старый job остаётся в истории. NATS не требуется в момент запроса: публикация выполняется существующим outbox после commit. OCR, error сервиса и ошибки маппинга не повторяются. Ошибки: 409 FILE_NOT_RETRYABLE, MAX_ATTEMPTS_REACHED, VERSION_NOT_EDITABLE.

Пакет включает только файлы ERROR с stored_file.consolidation_id выбранной консолидации и ЮЛ из периметра выбранной версии; файлы другой консолидации и загрузки без контекста не включаются. Версия должна быть «в работе» (INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION), пользовательская блокировка не нужна. Версии одной консолидации могут видеть общий файл. Другие статусы файлов не блокируются и не попадают в ответ. Неподходящие ERROR-файлы пропускаются: accepted: [{file_id, job_id, attempt}], skipped: [{file_id, code}]. Пустой пакет также отвечает 202. Для одиночного файла консолидации требуется хотя бы одна рабочая версия с ЮЛ файла в периметре. Повтор не меняет статусы версий. Два конкурентных запроса не создают две новые попытки.

PATCH …/osv-header принимает любые из четырёх полей; отсутствующие не меняются. Пример:

{"period_year": 2025, "period_quarter": "Q3", "is_cumulative": true, "osv_account_code": "60.1"}

Типы тела строгие: period_year — integer, period_quarter и osv_account_code — string, is_cumulative — boolean; приведения строк и чисел нет. Неверный тип — стандартный 422 валидации до доменных проверок. Явный null передаётся в домен, отсутствующие поля не меняются. Счёт нормализуется (60.1 → 60.01). Если передано поле периода, итоговые три поля периода обязательны: год 1900–2099, квартал Q1–Q4, boolean; у Q1 is_cumulative=true. Правка только счёта неполный период не трогает. Новые значения сохраняются в прежнем document; перед первой фактической правкой все четыре исходных значения сохраняются в original_values, неизменном при следующих правках. is_modified показывает отличие текущих значений от оригинала, modified_by/at — последнюю правку. Возврат к оригиналу снимает признак, но сохраняет снимок. Файл, JSON результата, file_ordinal и строки osv_entry не меняются.

Правка CONFIRMED сбрасывает статус в NEEDS_CHECK и очищает confirmed_by/at; привязки к рабочим версиям снимаются, CANCELLED сохраняются, статусы версий не меняются. Нужно новое ручное подтверждение; автоподтверждения и автозамены файлов нет. Колонка «ОСВ», подходящесть версиям и чек-лист читают актуальные поля; period_check/counterparty_check не меняются. Флаг CONSOLIDATION_PERIOD_FROM_VERSION остаётся. Запрос без фактических изменений — 200 без аудита, снимка и сброса подтверждения.

Ошибки PATCH по порядку: 409 DOCUMENT_NOT_OSV, FILE_SUPERSEDED; 422 INVALID_PERIOD, INVALID_ACCOUNT_CODE; 409 DUPLICATE_ACCOUNT (detail.document_id); 409 VERSION_NOT_EDITABLE (detail.version_ids). Последняя означает привязку к версии вне рабочих статусов и CANCELLED.

Аудит: FILE_REPROCESS, раздел PRIMARY_DOCUMENTS, объект stored_file, на каждый принятый файл. before_value={job_id, attempt, status, error_code} описывает прежнее задание; after_value={job_id, attempt} — новое; для пакета добавляется consolidation_id. Пропущенные файлы и автоматические republish таких событий не пишут. Правка ОСВ — одно RECOGNIZED_DATA_EDIT (RECOGNITION, document) с изменёнными полями, сброшенным подтверждением и after_value.unbound_version_ids. Аудит и мутация фиксируются вместе.

Поставка: сначала 0008_dbml_v5_20 (четыре поля document), затем 0009_dbml_v5_21 (только enum FILE_REPROCESS), затем приложение. Итоговая сверка — DBML v5.21. Откат 0009 → 0008 запрещён при наличии FILE_REPROCESS в аудите; откат 0008 → 0007 — при document.is_modified=true. При возврате к исходным значениям признак false, откат v5.20 удалит снимок; файл/JSON в S3 и аудит сохраняются. UI повторной обработки и правки ОСВ поставляется отдельными задачами.

Предварительные требования

  • Python 3.11 (версия платформы) и uv — make venv создаёт окружение и ставит зависимости через uv; без него цель не работает. Системный python3 другой версии не подойдёт: uv сам подберёт интерпретатор 3.11.
  • Docker и compose v2 — для PostgreSQL, NATS и MinIO (compose лежит в ../docs/dev/).
  • Node.js и npm — только для web/. Для запуска бэкенда не нужны.
  • make, curl.

Репозитории workspace должны лежать рядом: arm-core/ и docs/ — соседние каталоги (так же устроено зеркало .wt/<срез>/). Пути ../docs/... ниже и в Makefile рассчитаны на это.

Быстрый старт

Путь «с нуля до зелёного /api/v1/me». Команды make выполняются из корня репозитория.

1. Виртуальное окружение

make venv

Создаёт api/.venv (Python 3.11) и ставит api/requirements.txt и api/requirements-dev.txt. Пакеты raisa_* из Nexus банка вне банка недоступны, поэтому цель пропускает строки raisa_* (подробнее — «Работа без Nexus»). Повторный запуск безопасен.

2. Инфраструктура (PostgreSQL, NATS, MinIO)

Файл .env compose — отдельный от api/.env; он создаётся один раз и в git не попадает:

cp ../docs/dev/.env.example ../docs/dev/.env
make up          # docker compose -f ../docs/dev/docker-compose.yml up -d

make up поднимает postgres (5432), nats (4222, мониторинг 8222), minio (9000, консоль 9001) и одноразовый minio-init, который создаёт бакет arm-ka и завершается с кодом 0. Команда возвращается сразу, а PostgreSQL становится готовым примерно через 6 секунд: дождитесь healthy у трёх долгоживущих сервисов, прежде чем выполнять make migrate:

docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio   # возвращается, когда все healthy
docker compose -f ../docs/dev/docker-compose.yml ps                                 # проверка статуса

(голый --wait без списка сервисов возвращает код 1 из-за завершающегося minio-init; поэтому make up для бакета и --wait со списком — вместе). Подробности, смена портов, сброс — ../docs/dev/README.md.

3. Настройки приложения

cp api/.env.example api/.env

Значения из api/.env.example уже совпадают с ../docs/dev/.env.example (стандартные порты и учётные данные compose), менять ничего не нужно. Менять api/.env придётся, только если вы изменили порты или пароли в ../docs/dev/.env (правьте обе части). api/.env в git не попадает.

4. Миграции и справочники

make migrate     # alembic upgrade head: схема `DB_SCHEMA` (по умолчанию dkb), 27 enum-типов, 43 таблицы
make seed-dev    # справочники продукта + dev-пользователи (по одному на каждую роль) + dev-ГК под документы fixtures

make seed загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без dev-пользователей и dev-ГК. make seed-dev дополнительно создаёт три dev-филиала и группы компаний под документы из fixtures (api/seed/dev/company_groups.yaml): ГК «Русский Бисквит» ровно как в выгрузке ЕЦП CRM (20 ЮЛ/ИП, ГСК из РБ и Счастливочной, алиасы под имена контрагентов из ОСВ) и ГК владельцев форм пакета ДКБ — ОСВ и формы из fixtures после этого загружаются на «свои» ЮЛ без импорта справочников. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав role_permission он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав (правки администратора не перезаписываются, см. «Администрирование»). make seed-dev (--dev) работает только при DEBUG=true в api/.env; при DEBUG=false загрузчик отказывает с кодом 2 («--dev доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД.

5. Запуск

cd api && .venv/bin/python run_server.py

Сервер слушает 127.0.0.1:8000 (SERVER__HOST, SERVER__PORT из api/.env). Для dev-запуска оставляйте loopback: при DEBUG=true и AUTH_DEV_ISSUER=true эндпоинт dev-токенов выдаёт токен любому, кто знает email пользователя, — не открывайте его в сеть. Дальнейшие команды — из другого терминала.

6. Проверка

curl -s localhost:8000/healthcheck
{"uptime":"2026-10-03T18:37:15.121394+00:00","version":"1.0.0","nats":"ok","subjects":{"tabular_request":"files.tabular_uploaded","image_request":"files.img_uploaded","result":"files.tabular_parsed"}}

nats: ok — подключён, down — включён (NATS_ENABLED=true), но связи нет, off — выключен.

Dev-токен по email любого dev-пользователя и профиль текущего пользователя:

curl -s -X POST localhost:8000/api/v1/dev/token \
  -H 'Content-Type: application/json' \
  -d '{"email":"ka_rf@armka.local"}'
# {"access_token":"<JWT>","token_type":"bearer"}

curl -s localhost:8000/api/v1/me -H "Authorization: Bearer <JWT>"
{
  "id": "b8d7595a-787f-4bcb-8e93-7612cc8afba3",
  "email": "ka_rf@armka.local",
  "full_name": "Dev: Аналитик РФ",
  "regional_office": {
    "id": "3b38e823-e09a-47ce-a296-061948a200b0",
    "name": "Ростовский региональный филиал",
    "is_head_office": false
  },
  "business_segment": "DKB",
  "roles": ["KA_RF"],
  "permissions": [
    "CONSOLIDATION_EDIT", "CONSOLIDATION_VIEW", "DIRECTORY_EDIT", "DIRECTORY_VIEW", "DOCUMENT_EDIT",
    "DOCUMENT_VIEW", "EXPORT", "EX_VIEW", "KFO_EXPORT", "KFO_VIEW"
  ]
}

(идентификаторы в вашей БД будут другими). Токен действует 8 часов. Интерактивная документация API — /docs (Swagger UI), схема — /openapi.json; обе — только при DEBUG=true (в проде их нет, 404).

Dev-пользователи (api/seed/dev/users.yaml, создаются make seed-dev):

Email Роль Филиал
ka_rf@armka.local KA_RF Ростовский региональный филиал
rka@armka.local RKA Ростовский региональный филиал
client_manager@armka.local CLIENT_MANAGER Краснодарский региональный филиал
ka_go@armka.local KA_GO Головной офис
curator_go@armka.local CURATOR_GO Головной офис
expert@armka.local EXPERT Головной офис
admin@armka.local ADMIN Головной офис

Права не зашиты в код роли: effective_permissions(user) — объединение прав ролей пользователя (таблицы permission, role_permission).

Что вы увидите в логе при запуске

Старт занимает около секунды; трейсбэков в логе нет. SMB (горячие папки ИС Сканирование из шаблона) необязателен и по умолчанию выключен: в .env.example OPENBAO__SMB__SMB_HOST пуст, сессия SMB не регистрируется (сообщение об этом — только на уровне DEBUG). Если SMB_HOST задан, регистрация выполняется в отдельном потоке (цикл событий не блокируется; старт ждёт её завершения не дольше тайм-аута smbclient, 60 с); ошибка подключения логируется трейсбэком Ошибка при регистрации сессии SMB, приложение продолжает работу.

Строки Бакет проверен, Подключение к NATS установлено и Подписка NATS активна. subject=files.tabular_parsed, queue=arm-core означают, что S3 и NATS из compose подключены (MinIO из compose слушает http, поэтому в .env задано OPENBAO__MINIO__MINIO_SECURE=false).

Недоступность S3 или NATS при старте приложение не роняет и не задерживает (недоступный SMB, если он настроен, — не роняет): ошибка логируется, состояние NATS видно в /healthcheck. К NATS ядро подключается в фоне и повторяет попытки само (nats=down, пока связи нет; warning в логе — на первую ошибку и дальше не чаще раза в минуту); после разрыва nats-py переподключается и восстанавливает подписку.

При остановке ресурсы закрываются по шагам: фоновые задачи обмена → NATS → S3 → SMB → пул соединений БД. Ошибка одного шага логируется (Ошибка при остановке приложения. step=...) и не отменяет следующие.

Предупреждение «Включён dev-эмитент токенов … только для разработки» появляется не при старте, а при первом запросе к защищённому эндпоинту (/api/v1/me): проверяющий токены создаётся лениво (штатно, см. «Безопасность dev-режима»).

Остановка и сброс

docker compose -f ../docs/dev/docker-compose.yml down        # остановить, данные (тома) сохраняются
docker compose -f ../docs/dev/docker-compose.yml down -v     # полный сброс: БД и бакет с нуля (затем снова migrate и seed-dev)

Имя проекта compose фиксировано (arm-ka), тома общие для всех worktree.

API справочников

Срез 1: справочники и их политика видимости. Все маршруты — под /api/v1, нужен токен (Authorization: Bearer …), схема — /openapi.json (интерактивно /docs); состав маршрутов проверяет api/tests/test_openapi_routes.py. Права — данные (permission × role_permission), код роли в логике не проверяется. Видимость записей решает src/domain/policy.py: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — 404 с тем же телом, что у несуществующего (не 403).

Филиал в ответах ЮЛ и ГК приходит объектом рядом с *_id: regional_office (ЮЛ, их история) и base_regional_office (ГК, их история) — {id, name, is_head_office} или null; неактивный филиал тоже отдаётся (имя нужно для истории). Имя ГК в ответах ЮЛ (список, карточка, POST/PATCH/move/withdraw; не в истории) — company_group_name рядом с company_group_id: текущее имя ГК, архивной тоже; у монозаёмщика null (arm-core#67).

Метод и путь Право Что делает
GET /directories/regional-offices DIRECTORY_VIEW филиалы (включая Головной офис)
GET /directories/company-groups DIRECTORY_VIEW видимые ГК: q (имя/crm_id/код), is_archived, limit/offset
POST /directories/company-groups DIRECTORY_EDIT создать ГК (пользователь филиала закрепляет только за своим филиалом, иначе OFFICE_NOT_ALLOWED)
GET /directories/company-groups/{group_id} DIRECTORY_VIEW карточка ГК
PATCH /directories/company-groups/{group_id} DIRECTORY_EDIT изменить ГК (в т.ч. перезакрепление за филиалом)
POST /directories/company-groups/{group_id}/archive, /unarchive DIRECTORY_EDIT архивировать / вернуть из архива
GET /directories/company-groups/{group_id}/history DIRECTORY_VIEW история ГК, новые первыми
GET, POST /directories/company-groups/{company_group_id}/related-groups DIRECTORY_VIEW / DIRECTORY_EDIT ГСК/ГСЗ группы компаний (простой массив) / создать
GET /directories/related-groups/{group_id} DIRECTORY_VIEW карточка ГСК/ГСЗ с текущими членами
PATCH /directories/related-groups/{group_id} DIRECTORY_EDIT изменить ГСК/ГСЗ
DELETE /directories/related-groups/{group_id} DIRECTORY_DELETE удалить пустую ГСК/ГСЗ (GROUP_NOT_EMPTY)
POST /directories/related-groups/{group_id}/members DIRECTORY_EDIT добавить ЮЛ (только из той же ГК)
DELETE /directories/related-groups/{group_id}/members/{legal_entity_id} DIRECTORY_EDIT исключить ЮЛ (членство закрывается, не удаляется)
GET /directories/legal-entities DIRECTORY_VIEW видимые ЮЛ/ИП: company_group_id, outside_group, inn, q (подстрока имени, ИНН или КПП), closed, limit/offset; в каждой записи documents_count
POST /directories/legal-entities DIRECTORY_EDIT создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12; kpp необязателен, 9 символов, у ИП запрещён — INVALID_KPP); форма целиком — aliases, gsk_id, gsz_id в той же транзакции; ответ — карточка
GET, PATCH /directories/legal-entities/{entity_id} DIRECTORY_VIEW / DIRECTORY_EDIT карточка (с членствами, алиасами и documents_count) / изменить (в т.ч. kpp; null очищает; прежнее значение уходит в историю; aliases — итоговый набор, удаление — с DIRECTORY_DELETE; gsk_id/gsz_id — итоговые ГСК/ГСЗ, null — выйти; ответ — карточка)
POST /directories/legal-entities/{entity_id}/move DIRECTORY_EDIT перевести в другую ГК с датой effective_date ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются
POST /directories/legal-entities/{entity_id}/withdraw DIRECTORY_EDIT вывести из ГК: CLOSE (с close_date) или REMOVE_FROM_GROUP
GET /directories/legal-entities/{entity_id}/history DIRECTORY_VIEW история ЮЛ/ИП, новые первыми
POST /directories/legal-entities/{entity_id}/aliases DIRECTORY_EDIT добавить алиас (normalized_alias по §3)
DELETE /directories/legal-entities/{entity_id}/aliases/{alias_id} DIRECTORY_DELETE удалить алиас
POST /directories/import DIRECTORY_ADMIN импорт выгрузки ЕЦП CRM (см. ниже)
GET /admin/users, POST /admin/users, PATCH /admin/users/{user_id}, PUT /admin/users/{user_id}/roles USERS_ADMIN пользователи и их роли (см. «Администрирование»)
GET /admin/roles, GET /admin/permissions, PUT /admin/roles/{code}/permissions USERS_ADMIN матрица «роль × право»
GET /users/brief?ids=<uuid>,<uuid> любой аутентифицированный (права не нужны) [{id, full_name, is_active}] для найденных пользователей (в т. ч. неактивных; неизвестные id пропускаются) — чтобы фронт показывал имена вместо UUID (confirmed_by, locked_by, авторы комментариев); до 200 id, больше — TOO_MANY_IDS, некорректный UUID — INVALID_IDS

documents_count у ЮЛ — число документов его файлов, кроме файлов SUPERSEDED (подсчёт одним запросом на страницу).

Списки верхнего уровня (ГК, ЮЛ, пользователи) отдают страницу {items, total, limit, offset} (limit 1–200, по умолчанию 50); список ГСК/ГСЗ одной ГК — простой массив. Каждая мутация пишет audit_event в той же транзакции, что и сама правка; повтор того же состояния ничего не пишет. Примеры:

TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
  -d '{"email":"ka_go@armka.local"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
curl -s 'localhost:8000/api/v1/directories/company-groups?q=бисквит' -H "Authorization: Bearer $TOKEN"

Импорт из ЕЦП CRM

POST /api/v1/directories/import (multipart) загружает справочник из выгрузки ЕЦП CRM: ГК, ЮЛ/ИП, филиалы и членство в ГСК/ГСЗ. Нужно право DIRECTORY_ADMIN (seed: ADMIN). Алгоритм — ../docs/context/05-domain-rules.md §13 (§13.1 формат файла, §13.2 «строка → решение», §13.3 транзакция и отчёт), чистое ядро — src/domain/directory_import.py.

Порядок работы.

  1. Сначала пробный прогон: dry_run=true (это значение по умолчанию). Прогон идёт тем же кодом, но транзакция откатывается; в БД, аудит и S3 ничего не попадает, отчёт тот же, что будет при записи.
  2. Прочитайте отчёт: счётчики и построчный список lines (row, inn, code, message). Пропущенные строки (errors) исправляются в файле и прогоняются заново.
  3. Когда отчёт устраивает — тот же файл с dry_run=false. Один импорт — одна транзакция; результат — отчёт в ответе, audit_event(DIRECTORY_IMPORT) с отчётом в after_value и файл в S3 (uploads/imports/{audit_event_id}/{имя}).
  4. Повторный импорт того же файла при on_existing=skip идемпотентен: все ранее принятые строки получают коды *_EXISTS; ранее пропущенные (ошибочные) строки остаются ошибками.
# шаг 1: пробный прогон (dry_run по умолчанию true, on_existing по умолчанию skip)
curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN_TOKEN" \
  -F file=@ecp-export.xlsx -F dry_run=true -F on_existing=skip
# шаг 3: запись
curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN_TOKEN" \
  -F file=@ecp-export.xlsx -F dry_run=false -F on_existing=update

Формат файла (§13.1). xlsx (первый лист) или csv (UTF-8, разделитель ; или ,); формат выбирается по расширению имени. Заголовки — первая непустая строка, сопоставляются без регистра и пробелов, ё = е; порядок колонок не важен, лишние игнорируются.

Колонка Обязательна Смысл
ИНН да пробелы убираются; ЮЛ — 10 цифр, ИП — 12
iD ГК да company_group.crm_id
Группа компаний да имя ГК
Организация да имя ЮЛ/ИП
Тип да ЮЛ или ИП (без регистра)
КПП нет v5.17: 9 символов (4 цифры, 2 цифры/латинские A–Z, 3 цифры); пусто допустимо; у ИП непустой КПП — INVALID_KPP, строка пропускается; колонки нет — не ошибка
Базовый РФ ГК в CRM нет филиал по точному имени
ГСК, ГСЗ нет флаги «входит в ГСК» / «входит в ГСЗ»

Нет любой обязательной колонки — файл отвергается целиком (422 MISSING_COLUMNS с перечнем в поле missing).

Параметр on_existing. skip (по умолчанию) — существующие ГК/ЮЛ не меняются; update — обновляются имя и филиал ГК и имя/тип/КПП ЮЛ (непустой КПП из файла; пустая ячейка существующий КПП не стирает; со снимками в историю и аудитом).

Коды отчёта (§13.2). У каждой строки ровно один итоговый код, плюс сопутствующие события той же строки. Пропуск строки (учитывается в errors): UNSUPPORTED_TYPE, INVALID_INN, INVALID_KPP, DUPLICATE_IN_FILE, LE_IN_OTHER_GROUP, GK_NAME_CONFLICT (разные имена одной ГК в файле или пустой iD ГК). Итоговые: LE_CREATED, LE_UPDATED, LE_EXISTS. Сопутствующие: RF_CREATED, GK_CREATED, GK_UPDATED, GK_EXISTS, GSK_CREATED, GSZ_CREATED, MEMBER_ADDED, UNKNOWN_FLAG.

Допущения (открытые вопросы, в коде помечены ASSUMPTION(OQ-…), список — ../docs/context/07-open-questions.md).

  • OQ-87: типы вне {ЮЛ, ИП} (нерез., ФЛ, нет инн, пусто) пропускаются — UNSUPPORTED_TYPE.
  • OQ-88: ИНН проверяется только по длине и составу (ASCII-цифры), контрольные разряды не считаются; нечисловые значения (LV…, РНН…) — INVALID_INN.
  • OQ-89: филиала из файла нет в справочнике — он создаётся как обычный (is_head_office=false), в том числе «ЦКБ» и «Головная организация» (RF_CREATED).
  • OQ-90: ЮЛ, которое уже состоит в другой ГК (или вне ГК), импорт не переносит: LE_IN_OTHER_GROUP, перевод — только POST …/move. Сообщение называет имя этого ЮЛ из другой группы — импорт доступен только DIRECTORY_ADMIN, который видит весь справочник.
  • OQ-91: флаги ГСК/ГСЗ создают не более одной ГСК и одной ГСЗ на ГК с именем ГК; отсутствие флага членство не закрывает.

Особенности, о которых стоит знать.

  • ИНН — только текстом. Число из ячейки xlsx теряет ведущий ноль (0123456789 → 123456789) и получает INVALID_INN; в Excel задавайте текстовый формат колонки (или используйте csv).
  • Пустые «Организация» или «Группа компаний» при непустом iD ГК не отвергаются: создаётся запись с пустым именем (допущение, правила на пустое имя в §13 нет).
  • У существующей ГК при skip новый филиал из файла всё равно создаётся (RF_CREATED), но ГК не меняется.
  • Архивные ГК входят в снимок справочника; update не возвращает ГК из архива.
  • Пустые строки пропускаются, но входят в счётчик строк MAX_IMPORT_ROWS.

Пределы.

Предел Значение Результат при превышении
IMPORT_MAX_BYTES (настройка) 20 МиБ 413, файл не читается в память целиком
MAX_IMPORT_ROWS 100 000 строк листа/записей csv, включая пустые 422 UNSUPPORTED_FILE
MAX_UNCOMPRESSED_BYTES 200 МиБ суммарно по частям xlsx (zip), проверяется до разбора 422 UNSUPPORTED_FILE

Требование к ingress / reverse proxy (M-1). FastAPI/Starlette разбирают multipart-тело до аутентификации: файл сбрасывается во временный файл на диске ещё до ответов 401/403 и до проверки IMPORT_MAX_BYTES. Поэтому лимит тела запроса должен ставить ingress: client_max_body_size (или аналог) не меньше 20 МиБ — только для пути /api/v1/directories/import, а для остальных путей — небольшой (например, 1 МиБ).

Остальные причины UNSUPPORTED_FILE: расширение не xlsx/csv, повреждённый xlsx, csv не в UTF-8.

Администрирование и экран /admin

Бэкенд — /api/v1/admin/*, все маршруты требуют USERS_ADMIN (без права — 403 до любых проверок тела и существования объектов). Фронтенд — страница /admin («Пользователи и роли» в боковом меню; пункт пока виден всем ролям): таблица пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает «Нет доступа» и запросов к /admin/* не делает (настоящую защиту обеспечивает сервер).

Форма создания пользователя берёт список филиалов из GET /directories/regional-offices (право DIRECTORY_VIEW). Матрица прав на этом же экране позволяет снять DIRECTORY_VIEW с роли администратора — тогда форма получит пустой список / «недостаточно прав»; держите DIRECTORY_VIEW у роли администратора.

Как запустить.

make migrate && make seed-dev       # схема, справочники и dev-пользователи
make up                             # если инфраструктура ещё не поднята
cd api && .venv/bin/python run_server.py   # бэкенд, :8000 (в api/.env — DEBUG=true и AUTH_DEV_ISSUER=true)
cd web && npm install && REACT_APP_ROOT_PATH=/ npm run start   # фронтенд (rsbuild dev, :3000), откроется браузер

Вход — dev-форма «Вход для разработки» (web/src/auth/DevLogin.tsx): введите admin@armka.local; форма вызывает POST /api/v1/dev/token, токен хранится в sessionStorage вкладки. В production-сборке формы нет — вход через SSO РАИСА (параметры JWT — открытый вопрос).

Что нужно знать.

  • Роли и права — данные. Права роли правятся матрицей (PUT /admin/roles/{code}/permissions), набор ролей пользователя — PUT /admin/users/{user_id}/roles; код роли нигде в логике не проверяется. Новых прав и ролей через API создать нельзя: неизвестный код — 422 UNKNOWN_PERMISSION / UNKNOWN_ROLE (неактивная роль не назначается).
  • LAST_ADMIN (409). После операции хотя бы у одного активного пользователя должно остаться право USERS_ADMIN через активную роль. Защищены: деактивация пользователя (в том числе себя), замена его ролей и снятие права с роли; параллельные запросы двух администраторов идут по очереди (блокировка строки права).
  • Email. В теле запроса — EmailStr, поэтому адрес с зарезервированным доменом *.local (x@corp.local) через API и UI отклоняется (422); dev-пользователи *@armka.local создаёт make seed-dev напрямую в БД. Для ручной проверки заводите пользователей с example.com. Email уникален без учёта регистра (409 EMAIL_EXISTS), хранится как введён.
  • Seed не трогает заполненную матрицу прав. Связи «роль × право» из api/seed/core/role_permissions.yaml make seed / make seed-dev загружают целиком только в пустую role_permission (первый запуск на новой БД). Если связи уже есть, seed добавляет лишь пары, у которых роль или право впервые созданы этим же запуском (новая роль или новое право в файлах seed — их администратор снять не мог); остальные пары не трогаются — в отчёте role_permission: пропущено — матрица прав уже заполнена… (или число добавленных пар новых ролей и прав). Право, снятое администратором с существующей роли, не возвращается, добавленное сверх файла не отзывается; новые пары существующих ролей и прав из файла выдаёт администратор (PUT /admin/roles/{code}/permissions). Роли и права (role, permission) по-прежнему приводятся к файлам seed. Пограничный случай: если администратор снял все связи, таблица пуста, и следующий seed зальёт матрицу файла заново.
  • Профиль /me не перечитывается после того, как администратор правит собственные роли или права: перезагрузите страницу или войдите заново.
  • Имя и филиал ГК одним PATCH пишут одно событие аудита ASSIGNMENT_CHANGE с обоими полями в before/after (решение №35): выборка журнала по GROUP_CHANGE такое переименование не найдёт.
  • Один выбор роли — один запрос. Мульти-выбор ролей в строке таблицы отправляет по одному PUT …/roles на каждое нажатие, поэтому каждое нажатие — отдельное событие audit_event(USER_ROLE_CHANGE).
  • Показываются первые 200 пользователей (limit=200, максимум страницы); постраничного просмотра в UI пока нет — остальных ищет GET /admin/users?q=… (по email/ФИО).
  • Аудит. USER_CHANGE (создание, правка полей, деактивация) и USER_ROLE_CHANGE — раздел USERS; ROLE_PERMISSION_CHANGE — раздел ACCESS; before_value/after_value — прежнее и новое состояние.

Первичные документы: загрузка

Срез 2c: файл первичной отчётности (ОСВ) попадает в ядро, уходит на распознавание, а результат разбирается в документы и строки ОСВ. Тип документа и период при загрузке не спрашиваются (макет 40): их определяет парсер по содержимому файла, ядро только раскладывает результат. Путь файла:

POST /legal-entities/{id}/files → S3 uploads/… + stored_file(UPLOADED) + recognition_job(queued) + audit FILE_UPLOAD
  (`consolidation_id` NULL; загрузка в консолидацию — маршрут консолидации)
  → публикатор (outbox) → NATS files.tabular_uploaded → tabular-parser (читает S3, пишет results/…)
  → files.tabular_parsed → inbox (задание done, файл PROCESSING) → цикл маппинга (document + osv_entry, файл PROCESSED)
  → автозамена прежних версий (SUPERSEDED) → GET /files/{id}, GET /documents/{id}/entries → POST /documents/{id}/confirm (только для NEEDS_CHECK и форм после правки)

Чистый документ подтверждается самим маппингом (автоподтверждение, OQ-157); POST /documents/{id}/confirm нужен для NEEDS_CHECK и для форм ББ/ОФР после правки.

Загрузка — multipart/form-data, одно или несколько полей files; право DOCUMENT_EDIT, ЮЛ в области видимости пользователя (чужое — 404, как и несуществующее). Поддерживаются xls (BIFF8), xlsx, xlsb, pdf, png, jpg (.jpeg — как jpg): тип определяется по расширению и сверяется с сигнатурой содержимого. Таблицы (xls/xlsx/xlsb) идут в конвейер tabular (3 попытки), изображения и PDF — в image (1 попытка). Файл больше UPLOAD_MAX_BYTES (50 МиБ) отклоняется, тот же sha256 в пределах одного ЮЛ — тоже (DUPLICATE_FILE, в ответе file_id прежнего файла; заменённый SUPERSEDED файл дублем не считается, у другого ЮЛ тот же файл принимается).

Файлы обрабатываются по одному (в SAVEPOINT одной транзакции запроса), ответ — частичный успех: 201, если принят хотя бы один, иначе 422. Объект кладётся в S3 по ключу uploads/{file_id}/{job_id}/{безопасное имя}; имя файла очищается от путей и управляющих символов.

# ЮЛ — любое видимое пользователю (создайте через POST /directories/legal-entities или импортом ЕЦП, см. выше);
# название должно совпадать с шапкой ОСВ, иначе counterparty_check = MISMATCH и документ NEEDS_CHECK
TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
  -d '{"email":"ka_go@armka.local"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')
LE=$(curl -s 'localhost:8000/api/v1/directories/legal-entities?q=бисквит' -H "Authorization: Bearer $TOKEN" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["items"][0]["id"])')
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "Authorization: Bearer $TOKEN" \
  -F 'files=@../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls'
# {"accepted":[{"file_id":"…","job_id":"…","name":"ОСВ_66 за 2024.xls"}],"rejected":[]}
curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN"     # статус, задание, документы

Нужны запущенное ядро с NATS_ENABLED=true, MinIO и tabular-parser (профиль services, см. «Сквозной прогон»). Без подключённого S3 загрузка отвечает 503 STORAGE_UNAVAILABLE.

Статусы файла и документа

Файл (stored_file.status) меняется только по текущему заданию (current_recognition_job_id): результат, ack, таймаут или dlq устаревшего задания файл не трогают (services/file_status.py).

Статус Когда
UPLOADED файл принят, задание в outbox
PROCESSING сервис подтвердил (ack) либо пришёл результат done/needs_check (если ack потерян — core NATS без JetStream)
PROCESSED маппинг завершён: документы и строки созданы
ERROR задание error, timeout (вахтёр, TIMEOUT), dlq или неповторяемая/исчерпавшая попытки ошибка маппинга; причина — в карточке файла (job.error_code/error_message, job.mapping_error)
SUPERSEDED файл заменён более новым с теми же документами (автозамена)

PROCESSED и SUPERSEDED события обмена не перезаписывают; запоздавший ack не воскрешает ERROR.

Документ (document.status) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на тип DISCLOSURE/OTHER результата (D-3; без данных, при маппинге RECOGNIZED, затем автоподтверждаются системой). Условия для листа ОСВ:

  • RECOGNIZED — задание done, проверки парсера без нарушений, шапка не противоречит владельцу (counterparty_check ≠ MISMATCH), предупреждений маппинга у листа нет;
  • NEEDS_CHECK — иначе: задание needs_check, нарушения checks, counterparty_check = MISMATCH, повтор счёта в файле, строки без родителя, нарастающая по данным Q1;
  • для формы ББ/ОФР условия RECOGNIZED/NEEDS_CHECK — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо проверок парсера;
  • CONFIRMED — аналитик нажал «Подтвердить» (POST /documents/{id}/confirm, confirmed_by/at, аудит) либо система при автоподтверждении чистого документа (confirmed_by = null, аудит DOCUMENT_CONFIRM без пользователя; OQ-157);
  • ERROR — зарезервирован: в Срезе 2c ядро документ в ERROR не создаёт (результат без документов — «в результате нет документов» — переводит в ERROR файл).

Чистый документ (RECOGNIZED при маппинге) подтверждается системой сразу в маппинге. RECOGNIZED остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (form_blocker), и у документов, созданных до автоподтверждения; у документов с замечаниями статус NEEDS_CHECK.

counterparty_check (MATCH / MISMATCH / UNKNOWN) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; period_check — UNKNOWN: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены ASSUMPTION(OQ-nn) в коде.

Цикл маппинга

Фоновая задача lifespan (services/result_mapping.py:run_mapping_loop, включается при NATS_ENABLED и подключённом S3 (storage); без S3 — предупреждение в логе, цикл не запускается) отделена от inbox: inbox только фиксирует результат и регистрирует файл результата в s3_object, не читает его. Цикл с периодом MAPPING_POLL_SECONDS берёт одно задание done/needs_check, чей файл PROCESSING и для которого оно текущее (FOR UPDATE SKIP LOCKED), затем:

  1. проверяет ключ результата: бакет хранилища и префикс results/{file_id}/{job_id}/ (OQ-70; ./../пустые сегменты отвергаются);
  2. читает объект из S3 вне транзакции и валидирует RecognitionResult v1.x (job_id/file_id должны совпасть с заданием);
  3. в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт document на каждый лист ОСВ и на каждую форму ББ/ОФР (второй BALANCE/INCOME файла пропускается с предупреждением; шапка, колонки, строки, значения; см. «Формы ББ/ОФР»), пакетами вставляет osv_entry (без строки TOTAL; parent_entry_id, account_root, account_id, counterparty_id), пишет recognition_job.group_schema и metrics.mapping_warnings, переводит файл в PROCESSED и выполняет автозамену (см. «Удаление и замена»).

Ошибки: неповторяемые (ключ вне папки задания, нет объекта, результат другого задания, невалидный результат, нет документов) сразу переводят файл в ERROR с metrics.mapping_error; повторяемые (БД, S3) откатывают транзакцию, увеличивают metrics.mapping_attempts, цикл спит MAPPING_POLL_SECONDS и пробует снова; после MAPPING_MAX_ATTEMPTS — ERROR. Пока попытки не исчерпаны, файл остаётся PROCESSING; повторный маппинг начинается с очистки строк файла, дублей нет.

Переменная По умолчанию Назначение
UPLOAD_MAX_BYTES 52428800 (50 МиБ) предельный размер файла при загрузке; больше — FILE_TOO_LARGE в rejected
MAPPING_POLL_SECONDS 5 период опроса цикла маппинга (и пауза после повторяемой неудачи)
MAPPING_MAX_ATTEMPTS 3 число попыток маппинга файла до ERROR

Что именно делает маппинг (нормализация кодов счетов, резолв контрагента, статус документа, нарастающая по Q1) — ../docs/context/05-domain-rules.md §1.3, §1.5, §3 п. 4, §9.2–9.5; код — src/domain/osv_mapping.py (чистые функции) и src/repositories/counterparties.py (индекс ЮЛ для резолва, без policy видимости).

API файлов и документов

Все маршруты — под /api/v1; объект вне видимости пользователя — 404 с тем же телом, что у несуществующего.

Метод и путь Право Что делает
POST /legal-entities/{legal_entity_id}/files DOCUMENT_EDIT загрузка файлов (multipart files), ответ {accepted[], rejected[]}
GET /legal-entities/{legal_entity_id}/files DOCUMENT_VIEW файлы ЮЛ, новые первыми (limit/offset; include_superseded=true — с заменёнными)
POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files DOCUMENT_EDIT загрузка в консолидацию (v5.18): файл получает consolidation_id; версии консолидации «в работе» с ЮЛ в периметре INITIATED → DOCUMENTS_PROCESSING
GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files DOCUMENT_VIEW рабочий стол консолидации для ЮЛ: файлы с её consolidation_id + файлы с документами, привязанными к её версиям (limit/offset/include_superseded)
GET /files/{file_id} DOCUMENT_VIEW карточка файла: статус, job (error_code, error_message, mapping_error, warnings — список объектов {code, severity, message, locator}, строки старых заданий отдаются как LEGACY_WARNING, severity — error или info; mapping_warnings — список строк), документы
DELETE /files/{file_id} DOCUMENT_EDIT удалить файл (см. «Удаление и замена»), 204
GET /documents/{document_id} DOCUMENT_VIEW документ: статус, проверки, locator, group_schema листа, warnings, file_id
GET /documents/{document_id}/entries DOCUMENT_VIEW строки ОСВ по row_number (limit ≤ 2000, по умолчанию 500); дерево — по parent_entry_id
POST /documents/{document_id}/confirm DOCUMENT_EDIT RECOGNIZED/NEEDS_CHECK → CONFIRMED, аудит DOCUMENT_CONFIRM; для автоподтверждённого документа — 409 ALREADY_CONFIRMED (D-6); для формы ББ/ОФР — ещё (в порядке проверки) PERIOD_REQUIRED, LINES_UNRESOLVED, DUPLICATE_FORM, CHECKS_NOT_PASSED
GET /documents/{document_id}/statement DOCUMENT_VIEW форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР»)
PATCH /documents/{document_id}/statement DOCUMENT_EDIT правка значений и шапки (inn, report_date, units) до подтверждения; аудит RECOGNIZED_DATA_EDIT
POST /documents/{document_id}/statement/checks/accept DOCUMENT_EDIT акцепт непройденных жёстких проверок с комментарием ({comment})
GET /documents/{document_id}/comments DOCUMENT_VIEW комментарии по возрастанию created_at: {id, text, author {id, full_name}, created_at}
POST /documents/{document_id}/comments DOCUMENT_EDIT добавить комментарий ({text}, 1–4000 символов после обрезки пробелов), 201; в любом статусе документа
GET /files/{file_id}/content DOCUMENT_VIEW исходный файл потоком из S3; ?disposition=attachment (по умолчанию) или inline; имя — Content-Disposition по RFC 5987, X-Content-Type-Options: nosniff

Карточка документа и документ в карточке файла несут comments_count. Просмотр и скачивание первички (/files/{id}/content) и CSV чек-листа, как и комментарии, в аудит не пишутся (в отличие от экспорта сводной ОСВ в xlsx: он пишет EXPORT; OQ-153). Тело потока из S3 освобождается детерминированно (common/streaming.py: ClosingStreamingResponse, ChainedStream), в том числе при обрыве клиента. Чужой и несуществующий файл — 404 NOT_FOUND; файл есть, а объекта в S3 нет (истёк или пропал) — 404 FILE_CONTENT_NOT_FOUND; S3 не подключено — 503 STORAGE_UNAVAILABLE.

Страницы — {items, total, limit, offset}. Суммы строк — десятичные значения без округления, как в результате парсера. Коды ошибок этих маршрутов — в таблице «Ошибки API» (FILE_TOO_LARGE, UNSUPPORTED_FILE, DUPLICATE_FILE, STORAGE_UNAVAILABLE, FILE_SUPERSEDED, ALREADY_CONFIRMED, DOCUMENT_NOT_CONFIRMABLE, FILE_HAS_CONFIRMED_DOCUMENTS, FILE_SUPERSEDES_OTHERS, FILE_CONTENT_NOT_FOUND) и форм ББ/ОФР (DOCUMENT_NOT_STATEMENT, DOCUMENT_READONLY, UNKNOWN_CELL, DUPLICATE_CELL, UNKNOWN_COLUMN, DUPLICATE_COLUMN, LINE_CODE_TAKEN, LINE_CODE_REQUIRED, INVALID_LINE_CODE, INVALID_LINE_PATCH, UNKNOWN_LINE, INVALID_INN, NOTHING_TO_ACCEPT, PERIOD_REQUIRED, CHECKS_NOT_PASSED). События аудита: FILE_UPLOAD, FILE_REPLACE, FILE_DELETE, DOCUMENT_CONFIRM (раздел PRIMARY_DOCUMENTS), RECOGNIZED_DATA_EDIT (раздел RECOGNITION; правка формы и акцепт).

Формы ББ/ОФР (Срез 3b)

Цикл маппинга разбирает не только листы ОСВ, но и формы ББ/ОФР результата парсера: на каждую форму создаются document (BALANCE/INCOME; второй BALANCE/INCOME того же файла пропускается с предупреждением маппинга, OQ-77, D-3, а первый получает NEEDS_CHECK) и шапка financial_statement_meta с колонками, строками и значениями (financial_statement_column, _line, _value). Первичка не мутируется: правка аналитика пишется рядом (is_modified, original_amount, modified_by/at; у полей шапки — *_modified, *_original_value). Период формы — по дате отчёта, а при её отсутствии по самой поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — RECOGNIZED только если задание done, нет нарушений проверок §7 (или они акцептованы), контрагент не MISMATCH, период определён, значения разобраны, есть строки и нет предупреждений; иначе NEEDS_CHECK (правило D-6 спеки). Проверки §7 считает пакет arm_statements (копия в api/arm_statements/, обновляется sync-statements) при приёме результата и после каждой правки; нарушения хранятся в document_issue (relation / soft). Копии словаря §7 в ядре нет; в domain/statement_checks.py остались межформенная проверка 1370↔2400 (на лету, допуск — из правил пакета) и subline_parents.

Формы, принятые кодом части А (M-5). У форм ББ/ОФР, загруженных до выкладки части Б, записей §7 в document_issue нет (нарушения считались на лету). После выкладки они не проверяются на CHECKS_NOT_PASSED при подтверждении, пока не пройдут первую правку (она запишет relation / soft) или не будут пересозданы повторной загрузкой; на стендах формы очищает scripts/purge_statements.py (раздел «Выкладка v5.19: очистка форм»).

  • GET /documents/{id}/statement — {document, edition, header, columns[], lines[], checks, issues[], edition_lines[]}: header — inn, report_date, units вида {value, original_value, confidence, verified, modified} (confidence есть у ИНН, у даты и единиц — null) и справочные поля формы (organization_name, okud, okpo, …); lines[].values[] — суммы строкой, is_modified, original_amount; lines[].parent_code — у пятизначной подстроки код её четырёхзначной строки-родителя (правило проверки «сумма подстрок = родитель», domain/statement_checks.subline_parents: ближайшая выше строка с теми же тремя цифрами, иначе строка с кодом из первых четырёх цифр; родитель считается по строкам, участвующим в проверках, — достроенные из справочника пустые строки родителями не бывают), у остальных строк null; поле вычисляется на лету, в БД не хранится; мягкая проверка «сумма подстрок = родитель» по колонке выполняется, только если хотя бы одна подстрока родителя в ней непустая (amount не null; ноль — значение), иначе не проверяется; checks — {failed, soft_failed, cross_form, accepted} (нарушение — {column_index, relation, expected, actual}, у межформенной проверки column_index = -1). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 DOCUMENT_NOT_STATEMENT. Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (api/rules/statement_lines.yaml): недостающие строки (id = null, origin = template) и ячейки (id = null); в БД не пишется.
  • Поля v5.19 в ответе GET …/statement (change unified-statement-normalization, P3-11, P3-17, P3-20…P3-23): document.form_variant; header.okei, header.needs_review_fields[], header.organization_name_modified, header.source{поле: {text, confidence}} (header_source или {}; confidence — строка с двумя знаками, text и confidence могут быть null); columns[].period_kind, label_confidence; lines[] — row_index, source_ref, line_status (ok / detail / unreadable / missing), line_code_text, line_code_confidence, line_code_modified, reference_name (первое имя кода в словаре редакции ответа, иначе null), parent_row_index, needs_review, is_confirmed; lines[].values[] — text (source_text), needs_review, is_confirmed; checks.*[] — codes и line_ids (строки ok кодов правила); issues[] — записи document_issue по created_at, id: {id, kind, code, severity, message, line_ids, column_id, column_index, field, source, relation, codes, expected, actual}; edition_lines[] {code, name} — справочник кодов для подсказки (состав формы редакции ответа по одному на код; у формы без даты — коды всех редакций). Порядок lines[]: строки ok (с достроенными шаблонными) по коду, затем row_index (NULL последними); за строкой ok источника — её вложения (detail с этим родителем, unreadable / missing / detail без родителя, у которых она — ближайшая предыдущая по row_index строка ok); вложения без такой строки — в начале (domain/statement_layout.py). Карточка документа (GET /documents/{id}) несёт issues[] (у документа любого типа) и issue_counts {error, info}; documents[] карточки файла — issue_counts (все виды записей по severity, в том числе акцептованные; один групповой запрос на список документов).
  • PATCH /documents/{id}/statement — {header?: {inn?, report_date?, units?, organization_name?}, values?: [{line_id | line_code, column_id, amount | confirmed}], columns?: [{column_id, period_date}]} (до 5000 ячеек, до 100 колонок; в элементе values[] ровно одно из amount и confirmed, confirmed: true | false — подтвердить ячейку или снять подтверждение без правки суммы, только с line_id; columns[].period_date обязательна, null — 422 массивом; ручная дата ставит колонке period_kind = absolute и пересчитывает is_current и период документа; дата, равная текущей, у колонки с COLUMN_WITHOUT_DATE / COLUMN_DATE_UNCERTAIN — подтверждение; header.organization_name — непустая строка до 1000 символов, пересчитывает counterparty_check; header.report_date, равный текущему, у поля из needs_review_fields — подтверждение даты; правка поля шапки или даты колонки снимает предупреждения PERIOD_UNKNOWN, REPORT_DATE_UNPARSED, REPORT_DATE_UNCERTAIN, NO_ORGANIZATION_NO_INN, COLUMN_WITHOUT_DATE, COLUMN_DATE_UNCERTAIN; подтверждения акцепт проверок не сбрасывают; amount: null очищает ячейку; не более двух знаков; units — непустая строка после обрезки пробелов до 50 символов, пробельная строка — 422, null — «не менять»; inn: null — 422 INVALID_INN, ИНН не очищается). Правится только неподтверждённая форма (RECOGNIZED/NEEDS_CHECK): CONFIRMED — 409 DOCUMENT_READONLY (исправление — новым файлом), заменённый файл — 409 FILE_SUPERSEDED, статус ERROR — 409 DOCUMENT_NOT_CONFIRMABLE. Ячейка не из этой формы — 422 UNKNOWN_CELL (в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 UNKNOWN_COLUMN, одна колонка дважды — 422 DUPLICATE_COLUMN, одна ячейка дважды — 422 DUPLICATE_CELL, ИНН не из 10 или 12 цифр — 422 INVALID_INN. Один запрос — одна транзакция и одно событие RECOGNIZED_DATA_EDIT с before/after; возврат ячейки к исходному значению снимает is_modified; правка ИНН пересчитывает counterparty_check, даты отчёта — период документа; акцепт сбрасывает только правка данных (подтверждения ячеек и дат — нет, Р-25), статус пересчитывается после каждой правки. lines[] {line_id, line_code | line_status: "detail" | confirmed} (ровно одно действие) — назначить или сменить код, отметить расшифровкой, подтвердить код (порядок частей: lines → values → columns → header). Все ошибки элемента проверяются до первой записи; первая неверная несёт в detail line_id и line_code; LINE_CODE_TAKEN — ничего не применено. values[] адресует ячейку line_id или line_code (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по line_code — только код справочника редакции, которого нет в форме. Запрос без изменений — 200 без аудита. Ответ — та же форма, что у GET.
  • POST /documents/{id}/statement/checks/accept — {comment} (1–2000 символов): акцепт нарушенных жёстких проверок (checks_accepted_by/at/comment). Нарушений нет — 409 NOTHING_TO_ACCEPT. Акцепт сбрасывается любой правкой формы (в before_value события аудита сброса остаётся комментарий сброшенного акцепта).
  • Подтверждение формы (POST /documents/{id}/confirm) дополнительно требует определённый период (иначе 409 PERIOD_REQUIRED) и пройденные или акцептованные жёсткие проверки §7 (иначе 409 CHECKS_NOT_PASSED; в detail.failures[] — {column_index, relation, expected, actual}). Мягкие проверки (в т. ч. межформенная 1370 − 1370(начало года) = 2400) подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ.
  • Порядок блокировок правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой. Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами (полиморфная связь без FK; решение №18 закрыто).

Расчёт — src/domain/statement_mapping.py, src/domain/statement_checks.py (межформенная проверка) и пакет arm_statements через src/services/statements_engine.py (чистые функции), доступ к данным — src/repositories/statements.py, правка и акцепт — src/services/statements.py.

Удаление и замена

Автозамена. После успешного маппинга ядро ищет другие файлы того же ЮЛ (не SUPERSEDED), у которых есть документ с теми же document_type, osv_account_code, period_year, period_quarter, и переводит их в SUPERSEDED (superseded_by_file_id — новый файл); новый файл получает version_no = максимум заменённых + 1. Документы и строки заменённых файлов остаются (история), но список файлов их скрывает. Заменяется и файл с CONFIRMED документом (решение владельца 05.10.2026); системное событие FILE_REPLACE (user_id пуст) фиксирует перечень документов и confirmed=true. Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний уходит в SUPERSEDED целиком (OQ-115), но последствия видны: в metrics.mapping_warnings задания нового файла (mapping_warnings карточки файла, JobBrief) добавляется предупреждение на каждый заменённый файл — его имя и версия, выпавшие документы (тип, счёт, период, статус, в т. ч. CONFIRMED) и версии консолидации «в работе», с которых сняты привязки этих документов (OQ-154; статусы документов нового файла от него не зависят). Автозамены одного ЮЛ идут под advisory-блокировкой.

Удаление. DELETE /files/{id} удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и stored_file, проставляет объектам S3 (uploads, results) из папок этого файла (uploads/{file_id}/, results/{file_id}/) expires_at = now() (физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет FILE_DELETE с карточкой файла в before_value. Нельзя удалить файл с документом, подтверждённым пользователем (confirmed_by задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION (409 FILE_HAS_CONFIRMED_DOCUMENTS); автоподтверждённые привязки к версиям «в работе» снимаются (FILE_DELETE.after_value.unbound_version_ids, OQ-157). Нельзя также удалить файл, который заменил другие (409 FILE_SUPERSEDES_OTHERS); SUPERSEDED файл удалить можно. Блокировки — в порядке «задания → файл → документы → advisory ГК → версии по id».

Выкладка v5.19: очистка форм

Перед alembic upgrade v5.19 на контуре (после резервной копии БД) база очищается от форм ББ/ОФР — они пересоздаются повторной загрузкой по новым правилам (Р-24). Запускает владелец или платформенная команда по его заявке; на стендах разработчиков — по желанию. Из api/, одна транзакция:

.venv/bin/python -m scripts.purge_statements                          # --dry-run (по умолчанию): только отчёт
.venv/bin/python -m scripts.purge_statements --apply                  # очистка
.venv/bin/python -m scripts.purge_statements --apply --include-mixed  # смешанные файлы удалить целиком

Отчёт: число файлов только с формами, смешанных файлов (есть и ОСВ, и формы; их повторная загрузка невозможна из-за дедупа по sha256, поэтому владелец решает до --apply, нужен ли --include-mixed), документов форм, подтверждённых документов, привязок к неверифицированным версиям, файлов с ОСВ, заменённых удаляемыми.

  • Файл только с формами удаляется целиком, как DELETE /files/{id}, но без запрета FILE_HAS_CONFIRMED_DOCUMENTS: привязки снимаются (статус версий не меняется), superseded_by_file_id ссылающихся файлов обнуляется, объектам S3 ставится expires_at. У смешанного файла удаляются только BALANCE / INCOME с формами, комментариями и привязками.
  • Отказ без изменений — только если документ упомянут в ex_snapshot_item или привязан к версии VERIFIED либо ARCHIVED со срезом ЕХ (Р-35); отчёт называет версии. Версии в работе, на согласовании, REJECTED, CANCELLED не мешают.
  • Аудит: на файл — системное FILE_DELETE (user_id пуст, описание «очистка форм перед v5.19», карточка файла в before_value; у смешанного — after_value.deleted_documents). ОСВ, консолидации, справочники и журнал не трогаются.
  • Коды выхода: 0 — отчёт или выполнено, 1 — отказ, 2 — ошибка аргументов.

Ограничения Среза 2c

  • Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — ASSUMPTION(OQ-55); audit_event пишут только загрузка, автозамена, удаление, подтверждение.
  • Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон.
  • Общий размер multipart-запроса POST /legal-entities/{id}/files (и маршрута консолидации) и число файлов в нём приложением не ограничены (Starlette спулит части на диск, UPLOAD_MAX_BYTES — лимит одного файла); лимит запроса задаётся ingress платформы.
  • Маппер Среза 2c разбирал только листы ОСВ (osvDocument); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»). Документы DISCLOSURE/OTHER результата не игнорируются: на каждый тип создаётся один документ без данных (RECOGNIZED при маппинге, затем автоподтверждение; locator — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (tabular/image) в маппере нет: разбирается любой результат, проходящий схему recognition-result.
  • Корневой сегмент счёта (account_root) — только цифровой (ASSUMPTION(OQ-103)): забалансовые «МЦ.04», «ГТД» получают account_root = NULL; account_id ищется точным совпадением с планом счетов seed, субсчетов в seed неполный набор — часть строк остаётся с account_id = NULL.
  • ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
  • ОСВ «нарастающая по данным Q1» (Q2–Q4 с opening корня, равным opening Q1) получает предупреждение маппинга, поэтому документ остаётся NEEDS_CHECK: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца).
  • period_check — UNKNOWN (OQ-120); к версии консолидации привязываются только CONFIRMED документы; файл с документом, подтверждённым пользователем, удалить нельзя, файл с автоподтверждённым документом — можно, пока все версии с его привязками в INITIATED/DOCUMENTS_PROCESSING/DATA_CORRECTION (привязки снимаются; иначе 409 FILE_HAS_CONFIRMED_DOCUMENTS; OQ-110, уточнено OQ-157); FK ex_snapshot_item — Срез 7; EntryOut не отдаёт is_modified (панель правки — подсрез 2d).
  • Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата); при уменьшении MAPPING_MAX_ATTEMPTS файлы с исчерпанным счётчиком остаются PROCESSING до ручного вмешательства.

Консолидация (Срез 4)

Срез 4: версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы (автоматические по событиям документов и ручные), привязка подтверждённых документов к версии, чек-лист комплекта, сводная ОСВ (чистая функция от версии, в БД не хранится), ручное сопоставление контрагента и экспорт в xlsx. Спецификация — openspec/changes/slice-4-consolidation/ (design.md: D-1…D-16 и таблица «Decisions made without discussion»); контракт для фронта — ../docs/dev/api-contract-slice-4.md; правила — ../docs/context/05-domain-rules.md §2, §3, §9, §10.

Эндпоинты

Все маршруты — под /api/v1; права — CONSOLIDATION_VIEW (чтение) и CONSOLIDATION_EDIT (правки). Порядок проверок: право → видимость ГК (чужое и несуществующее — один 404) → статус «в работе» и своя блокировка → операция.

Метод и путь Право Что делает
POST /consolidations EDIT создать консолидацию ГК за период и версию №1 (INITIATED); периметр — текущие члены ГСК (≥ 2) или выбранные ЮЛ ГК legal_entity_ids (≥ 2, #42)
GET /company-groups/{id}/consolidations VIEW периоды ГК и их версии (in_work_only)
GET /consolidations/versions/{id} VIEW карточка версии: периметр (тип ЮЛ, счета ОСВ к периоду версии), чек-лист по ЮЛ (самоописывающие ячейки), история статусов, блокировка
POST /consolidations/versions/{id}/lock EDIT «взять в работу»; {takeover_reason} — перехват чужой блокировки с причиной
DELETE /consolidations/versions/{id}/lock EDIT снять свою блокировку (204; нет активной — тоже 204)
POST /consolidations/versions/{id}/status EDIT ручной переход: CANCELLED (с комментарием) или возврат в INITIATED
PUT /consolidations/versions/{id}/members EDIT заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2)
GET /consolidations/versions/{id}/checklist VIEW комплект документов: ЮЛ × правила, статус ячейки; ?format=csv — тот же чек-лист файлом CSV
GET /consolidations/versions/{id}/osv VIEW страница сводной ОСВ и фильтры, в т. ч. counterparty_id — строки с этим контрагентом (limit ≤ 2000, по умолчанию 500)
GET /consolidations/versions/{id}/osv/summary VIEW rows_total, unmapped_total, auto_matched_total, documents_total, gaps[], counter_pairs[] (пары «участник не видит встречных записей»)
GET /consolidations/versions/{id}/osv/export VIEW xlsx: лист «Сводная ОСВ» и лист «Пробелы»
GET /consolidations/versions/{id}/counterparty-candidates VIEW кандидаты для сопоставления: scope=group|all, q, entry_id
POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty EDIT ручное сопоставление контрагента строки свода
POST /consolidations/{id}/legal-entities/{le}/files DOCUMENT_EDIT загрузка в контексте консолидации (v5.18); query consolidation_version_id у маршрута ЮЛ удалён (v5.18)

Деньги в ответах — строки JSON с двумя знаками ("100.00"); версия любого статуса (включая CANCELLED) читается.

Доработка #42 (openspec/changes/front-api-gaps/): создание с выбранным периметром (legal_entity_ids), ячейки чек-листа с document_type/account, members[].entity_type/osv_accounts; контракт — ../docs/dev/api-contract-slice-4.md, файлы и справочники — ../docs/dev/api-contract-files-directories.md.

Статусы версии

Автоматические переходы (services/consolidation_status.transition — единственное место смены consolidation_version.status; пишет consolidation_status_history и audit_event(CONSOLIDATION_STATUS_CHANGE)):

Событие Переход changed_by
загрузка в консолидацию, принят ≥ 1 файл INITIATED → DOCUMENTS_PROCESSING у каждой версии консолидации «в работе» с ЮЛ в периметре загрузивший
маппинг создал документ NEEDS_CHECK, подходящий версии DOCUMENTS_PROCESSING → DATA_CORRECTION uploaded_by файла (нет — переход пропускается, лог)
подтверждение документа, привязанного к версии INITIATED/DOCUMENTS_PROCESSING → DATA_CORRECTION подтвердивший
POST …/status {CANCELLED, comment} любой «в работе» → CANCELLED (снимает блокировку) пользователь
POST …/status {INITIATED} DOCUMENTS_PROCESSING/DATA_CORRECTION → INITIATED (привязки сохраняются) пользователь

«В работе» — INITIATED, DOCUMENTS_PROCESSING, DATA_CORRECTION. Остальные переходы (маршрут согласования) — Срез 7; вручную они дают 409 TRANSITION_NOT_ALLOWED. Загрузка и подтверждение документа блокировкой не ограничиваются.

Блокировка

consolidation_lock: одна активная блокировка на версию (частичный UNIQUE released_at IS NULL). POST …/lock без тела: свободна — 201, своя — 200 (идемпотентно), чужая — 409 LOCKED_BY_OTHER. С takeover_reason (непустая) чужая блокировка снимается, создаётся новая (taken_over_from), audit_event(CONSOLIDATION_LOCK_TAKEOVER). Любая правка версии (PUT …/members, POST …/status, ручное сопоставление) требует статус «в работе» и свою активную блокировку. Блокировка не истекает по времени (OQ-119); CANCELLED её снимает.

Привязка документов

Версия владеет явным комплектом документов (consolidation_document). Документ подходит версии, если ЮЛ — член периметра, period_year = год версии и: нарастающий (ББ/ОФР всегда) — period_quarter = квартал версии, квартальный — period_quarter ≤ квартал версии. Исключение для ОСВ (R-14): документ ОСВ за Q1 равнозначен нарастающему и квартальному и подходит версии любого квартала года (парсер помечает ОСВ за Q1 как нарастающую); ББ/ОФР за Q1 версиям Q2–Q4 не подходят (R-14a). Привязываются только CONFIRMED документы незаменённых файлов.

  • Привязка: подтверждение документа (ко всем версиям «в работе» ГК ЮЛ), создание версии и добавление ЮЛ в периметр (подхват: на ключ «тип, счёт, квартал, признак нарастающего» — документ с самым поздним confirmed_at).
  • Отвязка: автозамена файла (привязки всех документов заменённого файла; версии — в FILE_REPLACE.after_value.unbound_version_ids) и удаление ЮЛ из периметра. Версии CANCELLED не трогаются.
  • Удаление файла с документом в версии: файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 FILE_HAS_CONFIRMED_DOCUMENTS (OQ-110, уточнено OQ-157).
  • period_check документа остаётся UNKNOWN — подходящесть — функция (документ, версия), а не свойство документа (OQ-120).
  • Временный режим «период ОСВ из версии» (CONSOLIDATION_PERIOD_FROM_VERSION=true, в контуре — OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION; по умолчанию выключен; arm-core#103, OQ-161): ОСВ, загруженная в консолидацию (stored_file.consolidation_id), период шапки которой версии не подходит, считается нарастающей за год и квартал консолидации — для привязки, свода, чек-листа и колонки «ОСВ» периметра (src/domain/version_period.py). ОСВ, подходящие по шапке, не трогаются (квартальные склеиваются как раньше). Документ не меняется (карточка показывает период из шапки); у подменённых ОСВ склейки нет — на счёт ЮЛ один слот, побеждает подтверждённая последней; ББ/ОФР и файлы с рабочего стола режим не затрагивает. Только для тестов на произвольных ОСВ.

Чек-лист

GET …/checklist и members[].checklist карточки: строки — члены периметра, колонки — required_document_rule подхода версии (BASIC: ББ, ОФР, ОСВ 01, 58, 60, 62, 66, 67, 76, 90). Статус ячейки: CONFIRMED (пара покрыта привязанными документами), NEEDS_CHECK/RECOGNIZED (есть неподтверждённый подходящий), PROCESSING (у ЮЛ есть файл в обработке), ERROR (последний файл ЮЛ в ERROR), MISSING; missing_quarters — пропущенные кварталы при частичном покрытии ОСВ. PROCESSING ставится по всем недостающим столбцам ЮЛ, пока файл не смаплен (до маппинга счёт файла неизвестен).

GET …/checklist?format=csv (Срез 3b, право CONSOLIDATION_VIEW) отдаёт чек-лист файлом text/csv: UTF-8 с BOM, разделитель ;, строки через CRLF, заголовок — «Организация;ИНН;<правило>…» (подписи правил: «Бух. баланс», «ОФР», «ОСВ 66»), строки — ЮЛ, в ячейках — подписи статусов («Загружен», «Обработка», «Требуется проверка», «Ошибка загрузки», «Отсутствует»; при пропущенных кварталах — «… (нет: Q1, Q2)»); имя файла — checklist-<ГК>-<год>-<квартал>.csv в Content-Disposition по RFC 5987. Формат по умолчанию — json; другое значение format — 422 валидации. Скачивание в аудит не пишется (domain/checklist_csv.py, чистая функция).

Сводная ОСВ

Чистая функция от версии (domain/consolidated_osv.build + domain/consolidation_periods.select_sources), в БД не пишется (DECISION-5); одна и та же для страницы, сводки и экспорта (services/consolidated_osv.py:_build, весь свод версии в памяти — порог для кэша OQ-121). Источники — строки привязанных ОСВ-документов (group_type = COUNTERPARTY, data_type BU/SUM).

  • На пару (ЮЛ, счёт): нарастающий документ за квартал версии — как есть; иначе квартальные документы Q1..Qn склеиваются по ключу (счёт, имя контрагента, ИНН): сальдо начала — из Q1, обороты — сумма, сальдо конца — из Qn, отсутствующая в квартале строка — нули; нарастающий и квартальные вместе — нарастающий. Нет квартала — пробел (gaps[] по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов».
  • Контрагент строки: хранимый osv_entry.counterparty_id либо резолв на лету (ИНН → точное имя → алиас → нормализованное имя; алиас приоритетнее ЮЛ-тёзок, OQ-128; в БД не пишется, match_stored = false); match_kind — INN/NAME/ALIAS (шаг резолва), MANUAL, PROPAGATED. Тип контрагента: GSK (член периметра), GK (та же ГК), иначе EXTERNAL. Имя и ИНН сопоставленного ЮЛ справочника — counterparty_entity_name / counterparty_entity_inn (counterparty_name / counterparty_inn — как в файле).
  • Несопоставленная строка (unmapped=true, unmapped_total, «Осталось N») — нет ЮЛ после резолва и нет ИНН из файла; строка с ИНН, которого нет в справочнике, сопоставления не требует (решение владельца 08.10.2026, отмена П-1, #74).
  • Фильтры: legal_entity_id, account_root, counterparty_type, unmapped, match_kind (повторяемый), q (имя — подстрока без регистра, ИНН — префикс). Порядок: имя ЮЛ, счёт (натуральный), номер строки.
  • warnings строк — диагностика (расхождение counterparty_id между кварталами), не контракт.

Сопоставление контрагента

GET …/counterparty-candidates — до 100 ЮЛ: scope=group (периметр и ГК, q необязателен) или scope=all (весь справочник, q ≥ 3 символов, иначе 422 QUERY_TOO_SHORT); recommended — нормализованное имя/алиас совпало с именем строки entry_id. POST …/osv/entries/{entry_id}/counterparty {legal_entity_id, move_alias} — одна транзакция:

  1. строка должна быть строкой свода версии (COUNTERPARTY, BU/SUM, документ привязан; NU/ACCOUNT — 404);
  2. строка получает counterparty_id и match_kind = MANUAL; имя/ИНН из файла не меняются;
  3. создаётся алиас имени строки у выбранного ЮЛ (LEGAL_ENTITY_CHANGE); алиас, закреплённый за другим ЮЛ, — 409 ALIAS_TAKEN (owners[]), с move_alias = true переносится (право DIRECTORY_DELETE не нужно);
  4. автоприменение: строки всех ОСВ версии с тем же именем, пустым counterparty_id и ИНН пустым или равным — PROPAGATED;
  5. audit_event(OSV_COUNTERPARTY_MAP) с before/after и propagated_entry_ids.

Ответ — {row, propagated, alias_created, alias_moved_from}; row бывает null (строка привязанного документа, не попавшая в свод). Строки osv_entry общие у версий одного документа: сопоставление в одной версии меняет строку для всех (OQ-123).

Экспорт

GET …/osv/export — xlsx: лист «Сводная ОСВ» (фильтры как у GET …/osv, без пагинации; суммы — числовые ячейки #,##0.00) и лист «Пробелы» (по своду целиком, от фильтров не зависит). Имя — заголовок Content-Disposition (filename="svod-osv.xlsx"; filename*=UTF-8''svod-osv_<ГК>_<год>-<Qn>_v<n>.xlsx). Текстовые ячейки пишутся как текст (без формул из внешних строк). Пишет audit_event(EXPORT).

События аудита

CONSOLIDATION_CREATE, CONSOLIDATION_STATUS_CHANGE, CONSOLIDATION_LOCK, CONSOLIDATION_LOCK_TAKEOVER, MEMBERSHIP_CHANGE (замена периметра; состав не менялся — события нет), OSV_COUNTERPARTY_MAP, LEGAL_ENTITY_CHANGE (алиасы сопоставления), EXPORT; FILE_REPLACE дополнен unbound_version_ids.

Порядок блокировок и инварианты

  • Блокировки строк: файл → документ → advisory-lock ГК → версии (по возрастанию id); правка версии начинается с consolidation_version FOR UPDATE. lock_versions_for_entity блокирует надмножество версий «в работе» (член ИЛИ та же ГК), создание версии берёт pg_advisory_xact_lock по ГК (R-2) — иначе привязка тихо теряется.
  • Документы под FOR NO KEY UPDATE (R-3): вставка consolidation_document берёт FOR KEY SHARE на документ и конфликтует с FOR UPDATE. Сопоставление: версия → advisory по алиасу → алиасы → строки osv_entry с тем же именем.
  • Статус версии меняет только consolidation_status.transition, версия под FOR UPDATE у вызывающего.

Ограничения Среза 4

  • Маршрут согласования (CONSOLIDATION_RF и далее), «Добавить версию», CONFIRM, EXTENDED, гудвилл — Срезы 6–7; API шахматки, правил элиминации и результирующей таблицы ВГО реализован в Срезе 5; автоматическое сопоставление ВГО, корректировки и UI — отдельная работа. ББ/ОФР в своде не участвуют.
  • Открытые вопросы — ../docs/context/07-open-questions.md: OQ-118 (запрет загрузки/подтверждения после DATA_CORRECTION), OQ-119 (истечение блокировки), OQ-120 (period_check), OQ-121 (порог размера свода), OQ-122 (ячейка PROCESSING), OQ-123 (общие строки у версий), OQ-124 (Q1-эквивалентность ОСВ), OQ-125 (склейка Q1 версии Q1), OQ-126 (область ручного сопоставления), OQ-127 (рекомендация кандидата), OQ-128 (неоднозначность дорезолва), OQ-129 (формулировка «1 + propagated»).
  • Экспорт: числа xlsx — IEEE-754, суммы свыше ~10^13 могут потерять младшую копейку.
  • Тест на реальных данных — api/tests/test_consolidation_fixtures.py (маркер fixtures): ОСВ РБ и Счастливочной за Q1–Q3 2024 → версия Q3 → свод (1385 строк), 22 документа привязано, пробелы по остальным счетам.

Шахматка и результирующая таблица ВГО (Срез 5)

API: список счетов GET /directories/accounts, правила версии GET …/elimination-rules, матрица GET …/intercompany-matrix/all и POST …/manual, их XLSX-выгрузки, статичная таблица GET …/intercompany-matrix/result-table и её XLSX GET …/intercompany-matrix/result-table/export (аудит EXPORT, report = "intercompany_result_table"). Режим PRESET (счета и показатель из правила элиминации): GET …/intercompany-matrix/preset/{rule_id} и его выгрузка …/preset/{rule_id}/export. Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. one_sided — ровно одна сумма пары нулевая; пара подсвечивается (и в xlsx) при exceeds_threshold или one_sided (AURORA-1651). GET …/elimination-rules отдаёт объект {version_no, is_version_fixed, rules} (у правила есть id): правила версии consolidation_version.elimination_rules_version — она фиксируется при создании версии консолидации (действующая на дату создания, OQ-174) и с DBML v5.23 есть всегда, is_version_fixed = true. Ошибки PRESET: 422 RULE_REQUIRED, INVALID_RULE, ACCOUNTS_REQUIRED; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на _rule-balance-<rule_no>.xlsx; в шапке — номер, статьи и версия правила, строка «Показатель», audit EXPORT пишет mode (ALL/MANUAL/PRESET), для PRESET — rule_id, rule_no и фактический rule_version_no. Открытые вопросы по пресетам — openspec/changes/intercompany-matrix-presets/design.md. Контракт для фронта — ../docs/dev/frontend-api-guide.md §5.7.1 (docs #146); требования — в существующих спецификациях openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md. Расчёт читает сводную ОСВ версии, результат не хранится.

Пары ВГО §5 (AURORA-1536, PR #175). GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id= — итог тройки (ΣA, ΣB со знаком, расхождение, процент, eliminated_amount по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1 (matched, with_discrepancy, unpaired с причиной NO_MATCH / AMBIGUOUS / ZERO_AMOUNT / NEGATIVE_AMOUNT; группы сортируются по проценту расхождения, отрицательные строки в упаковку не идут); GET …/elimination-totals — итоги всех троек версии (правила ББ × упорядоченные пары периметра) со статусом AUTO / DISCREPANCY / ONE_SIDED / MANUAL, сводная ОСВ строится один раз. Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия, правило не из версии правил), 422 INVALID_COMPANY_PAIR, INVALID_RULE (в т. ч. правило по оборотам), ACCOUNTS_REQUIRED. Решения — openspec/changes/elimination-pairs-rule-totals/design.md; контракт — ../docs/dev/frontend-api-guide.md §5.7.2. Поставка DBML v5.23: миграция 0010_dbml_v5_23 проставляет пустой elimination_rules_version по дате создания версии и делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён раньше. Откат снимает только NOT NULL.

Ошибки API

Доменные ошибки (src/common/exceptions.py, обработчик — src/core/exception_handlers.py) отдаются объектом:

{"detail": {"code": "ALIAS_TAKEN", "message": "Такой алиас уже закреплён за другим ЮЛ", "legal_entity_id": "…"}}

code — машинный код (по нему работает клиент), message — текст по-русски, дополнительные поля зависят от ошибки (например, legal_entity_id, related_group_id, permissions, roles, missing). Остальные ответы — {"detail": …} другой формы: 401/403/413 — строка, 422 валидации тела — массив ошибок pydantic, 409 нарушения целостности БД — строка. Объект вне видимости — 404 NOT_FOUND с тем же телом, что у несуществующего.

Гонки уникальных ограничений. Если параллельный запрос успел вставить запись между проверкой сервиса и INSERT, БД отклоняет вставку (unique_violation), и ответ тот же 409 с тем же кодом, что у проверки (реестр — src/common/constraint_conflicts.py): CRM_ID_EXISTS (ГК, ЮЛ и ГСК/ГСЗ), INN_EXISTS, ALIAS_EXISTS, EMAIL_EXISTS, MEMBER_EXISTS/MEMBER_OF_OTHER_GROUP. В теле гонки — только код и постоянный текст, без дополнительных полей (legal_entity_id, related_group_id) и без значений ключей. Особенности: повторное добавление в ту же группу отдаёт MEMBER_OF_OTHER_GROUP с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс «ЮЛ + вид»); гонку за один алиас у разных ЮЛ (ALIAS_TAKEN) БД не ловит (уникален только (ЮЛ, алиас)) — её сериализует advisory-блокировка по нормализованному алиасу (lock_alias_key: добавление и удаление алиаса, ручное сопоставление контрагента; удаление перечитывает алиас под блокировкой и отвечает 404, если его уже убрал перенос); email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением. Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка нарушение целостности данных в БД.

Код HTTP Где
NOT_FOUND 404 объекта нет или он вне видимости; в консолидациях различие — по тексту: «версия консолидации не найдена», «консолидация не найдена», «ГК не найдена», «ГСК/ГСЗ не найдена», «строка ОСВ не найдена в версии», «ЮЛ не найдено»
OFFICE_NOT_FOUND 422 филиал из тела запроса не существует (ГК, ЮЛ, пользователь)
OFFICE_NOT_ALLOWED 422 ГК можно закрепить только за своим филиалом
INVALID_INN 422 ИНН не из 10 (ЮЛ) / 12 (ИП) цифр; в правке формы (PATCH …/statement) — не из 10 или 12 цифр
INVALID_KPP 422 КПП не из 9 символов формата NNNN[0-9A-Z]{2}NNN или КПП у ИП (DBML v5.17, OQ-31)
EFFECTIVE_DATE_IN_PAST 422 дата перевода/вывода раньше сегодняшней (по Москве)
EMPTY_ALIAS 422 алиас пуст после нормализации (одни ОПФ и знаки препинания)
DIFFERENT_COMPANY_GROUP 422 ЮЛ не входит в ГК этой ГСК/ГСЗ
WRONG_GROUP_KIND 422 в gsk_id формы ЮЛ передана ГСЗ или в gsz_id — ГСК (POST/PATCH /directories/legal-entities)
UNKNOWN_ROLE / UNKNOWN_PERMISSION 422 неизвестная или неактивная роль / неизвестный код права (поля roles / permissions)
MISSING_COLUMNS / UNSUPPORTED_FILE 422 файл импорта целиком (missing — перечень колонок)
CRM_ID_EXISTS 409 ГК, ЮЛ или ГСК/ГСЗ с таким crm_id уже есть
INN_EXISTS 409 ЮЛ с таким ИНН уже есть (без дополнительных полей)
LEGAL_ENTITY_CLOSED 409 перевод закрытого ЮЛ
SAME_COMPANY_GROUP 409 перевод в ту же ГК
ALREADY_CLOSED 409 ЮЛ уже закрыто
NOT_IN_COMPANY_GROUP 409 ЮЛ не входит в ГК (вывод невозможен)
GROUP_NOT_EMPTY 409 удаление ГСК/ГСЗ с текущими членами
MEMBER_EXISTS 409 ЮЛ уже входит в эту группу
MEMBER_OF_OTHER_GROUP 409 ЮЛ уже входит в другую группу этого вида (related_group_id)
ALIAS_EXISTS 409 у ЮЛ уже есть такой алиас
ALIAS_TAKEN 409 алиас закреплён за другим ЮЛ (legal_entity_id; в ручном сопоставлении контрагента — owners[] {legal_entity_id, name})
EMAIL_EXISTS 409 пользователь с таким email уже есть
LAST_ADMIN 409 операция оставила бы систему без активного USERS_ADMIN
UNSUPPORTED_FILE 422 загрузка: тип не из xls/xlsx/xlsb/pdf/png/jpg, расширение не совпало с сигнатурой, пустое имя; строка rejected[] ответа загрузки (при импорте — файл целиком)
FILE_TOO_LARGE 422 загрузка: файл больше UPLOAD_MAX_BYTES; строка rejected[]
DUPLICATE_FILE 422 загрузка: тот же sha256 у файла этого ЮЛ (не SUPERSEDED); строка rejected[] с file_id прежнего файла
STORAGE_UNAVAILABLE 503 / 422 S3 не подключено — 503 на весь запрос (и на GET /files/{id}/content); сбой записи одного файла — строка rejected[] (если отклонены все — 422)
ALREADY_CONFIRMED 409 подтверждение: документ уже CONFIRMED
FILE_SUPERSEDED 409 подтверждение: документ файла, заменённого новой версией
DOCUMENT_NOT_CONFIRMABLE 409 подтверждение: статус документа не RECOGNIZED/NEEDS_CHECK (например, ERROR)
FILE_HAS_CONFIRMED_DOCUMENTS 409 удаление файла с документом, подтверждённым пользователем, или с автоподтверждённым документом в версии вне «в работе» (OQ-157)
FILE_CONTENT_NOT_FOUND 404 GET /files/{id}/content: файл есть, объекта нет в S3 (истёк или пропал)
DOCUMENT_NOT_STATEMENT 409 …/statement: документ — не форма ББ/ОФР (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b)
DOCUMENT_READONLY 409 правка/акцепт формы: форма уже CONFIRMED (исправление — новым файлом)
UNKNOWN_CELL 422 PATCH …/statement: line_id/column_id не этой формы; line_code уже есть в форме (передайте line_id), нет в справочнике редакции или редакция не определена
DUPLICATE_CELL 422 PATCH …/statement: одна ячейка указана в запросе дважды
UNKNOWN_COLUMN 422 PATCH …/statement: columns[].column_id не этой формы; ничего не применено
DUPLICATE_COLUMN 422 PATCH …/statement: одна колонка указана в columns[] дважды
LINE_CODE_TAKEN 409 PATCH …/statement, lines[] {line_id, line_code}: код занят строкой, созданной правкой (row_index IS NULL), с суммами; ничего из запроса не применено. detail несёт line_id строки, которой назначается код, и line_code
LINE_CODE_REQUIRED 422 PATCH …/statement: lines[] {line_status: "ok"} — в ok строку переводит только назначение кода. detail.line_id, detail.line_code (null)
INVALID_LINE_CODE 422 PATCH …/statement: lines[].line_code не из 4–5 цифр. detail.line_id, detail.line_code
INVALID_LINE_PATCH 422 PATCH …/statement: в элементе lines[] не ровно одно действие (line_code / line_status / confirmed), line_id дважды в запросе, подтверждение кода у строки не ok, отметка расшифровкой строки, созданной правкой. detail.line_id, detail.line_code (код элемента или null)
UNKNOWN_LINE 422 PATCH …/statement: lines[].line_id не этой формы. detail.line_id — чужой id, detail.line_code
NOTHING_TO_ACCEPT 409 акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы)
PERIOD_REQUIRED 409 подтверждение формы: период не определён — поправьте дату отчёта
LINES_UNRESOLVED 409 подтверждение формы: есть строки unreadable/missing с непустой ячейкой; detail.lines[] {line_id, row_index, line_code_text} по row_index — назначьте код или отметьте строку расшифровкой
DUPLICATE_FORM 409 подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; detail.document_id — подтверждённая форма
CHECKS_NOT_PASSED 409 подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; detail.failures[] {column_index, relation, expected, actual}
TOO_MANY_IDS 422 GET /users/brief: больше 200 id
INVALID_IDS 422 GET /users/brief: элемент ids — не UUID
FILE_SUPERSEDES_OTHERS 409 удаление файла, на который ссылаются superseded_by_file_id других файлов
CONSOLIDATION_EXISTS 409 консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух POST /consolidations)
ELIMINATION_RULES_MISSING 409 POST /consolidations: нет правил элиминации, действующих на дату создания (DBML v5.23: версия правил обязательна)
PERIMETER_TOO_SMALL 422 в ГСК (при создании без списка), в выбранных ЮЛ (legal_entity_ids при создании) или в новом периметре (PUT …/members) меньше двух ЮЛ; ничего не создаётся и не меняется
RELATED_GROUP_MISMATCH 422 ГСК принадлежит другой ГК или имеет вид gsz (невидимая/неизвестная ГСК — 404 NOT_FOUND)
ENTITY_NOT_IN_GROUP 422 POST /consolidations с legal_entity_ids и PUT …/members: ЮЛ не из ГК консолидации, без ГК или несуществующее (legal_entity_ids — перечень); проверка раньше PERIMETER_TOO_SMALL
ENTITY_NOT_IN_PERIMETER 422 загрузка в консолидацию: ЮЛ не входит в периметр ни одной версии «в работе»
CONSOLIDATION_NOT_EDITABLE 409 загрузка в консолидацию без версии «в работе»
QUERY_TOO_SHORT 422 кандидаты scope=all: q короче 3 символов после обрезки
ACCOUNT_NOT_FOUND 422 MANUAL шахматки или её экспорта: выбранного счёта нет в плане счетов
ACCOUNT_KIND_MISMATCH 422 MANUAL шахматки или её экспорта: вид счёта не подходит дебетовой/кредитовой стороне
ACCOUNTS_REQUIRED 422 сервис шахматки: не выбран дебетовый или кредитовый набор; через HTTP пустые массивы отклоняются раньше валидацией тела
RESULT_ACCOUNTS_MISSING 422 результирующая таблица ВГО: в плане счетов нет обязательных кодов статей
INVALID_COMPANY_PAIR 422 пары ВГО тройки: компании совпадают или не входят в периметр версии
ACCOUNTS_NOT_ALLOWED / INVALID_MODE 422 защита внутреннего вызова сервиса шахматки; маршруты ALL/MANUAL не дают вызвать эти ветки
COMMENT_REQUIRED 422 POST …/status в CANCELLED без комментария (из одних пробелов — тоже)
VERSION_NOT_EDITABLE 409 версия не «в работе» (правки) либо CANCELLED при «взять в работу»
LOCK_REQUIRED 409 правка версии без своей активной блокировки
LOCKED_BY_OTHER 409 версию держит другой пользователь: из POST …/lock — locked_by, locked_by_name, locked_at; из правок (ensure_version_editable) — только locked_by, locked_at; гонка двух взятий (частичный UNIQUE) — то же 409
NOT_LOCK_OWNER 409 DELETE …/lock: блокировка чужая (locked_by, locked_at)
TRANSITION_NOT_ALLOWED 409 ручной переход вне таблицы Среза 4, в т. ч. повтор того же статуса (from_status, to_status)

Файл импорта больше IMPORT_MAX_BYTES — 413 со строкой в detail; при загрузке первичных файлов лимит UPLOAD_MAX_BYTES отдаётся не 413, а строкой rejected[] с кодом FILE_TOO_LARGE. Недостаточно прав — 403 строкой (недостаточно прав: требуется <ПРАВО>), нет токена или он неверен — 401. Под /api/v1 SPA не отвечает: неверный метод на существующий маршрут — 405 {"detail": "метод не поддерживается этим маршрутом"} с заголовком Allow (например, POST /api/v1/me), несуществующий путь (и dev-маршрут при выключенных флагах) — 404 {"detail": "Not Found"}. Все остальные пути — SPA: GET / и клиентские маршруты (/admin) отдают index.html без редиректов.

Конфигурация

Настройки — pydantic-settings; читаются из переменных окружения и файла api/.env. Вложенные настройки разделяются __. Файл окружения выбирается переменной ENV_FILE (по умолчанию .env, путь относительно текущего каталога — api/; значение читается при импорте). Тесты используют api/.env.test (pytest.ini). Полный пример — api/.env.example.

Переменная По умолчанию Назначение
OPENBAO__APP__<ИМЯ> — контур платформы: туда передаются только секреты OpenBao (OPENBAO__*), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (NATS_ENABLED, DEBUG, AUTH_DEV_ISSUER, FORWARDED_ALLOW_IPS, CORS_ORIGINS, …) может прийти как OPENBAO__APP__<ИМЯ>, например OPENBAO__APP__NATS_ENABLED=true. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → OPENBAO__APP__* → .env → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); OPENBAO__APP__OPENBAO__… не допускается. Читается только из окружения процесса (в .env не действует). Реализация — api/src/core/settings_sources.py
ENV_FILE .env файл окружения (относительно api/)
DEBUG false режим разработки. true необходим dev-эмитенту токенов и make seed-dev, включает документацию API (/docs, /redoc, /openapi.json) и CORS *; при false документации нет (404), CORS — из CORS_ORIGINS, пакеты РАИСА подгружаются условно
DB_SCHEMA dkb схема PostgreSQL модели данных: MetaData ORM, search_path движка приложения, CREATE SCHEMA и таблица версий Alembic, схема в миграциях. Имя — строчный идентификатор [a-z_][a-z0-9_]{0,62}, не pg_* (подставляется в DDL без кавычек; иное — ошибка при старте). Одно значение для приложения и для alembic upgrade: сменить схему у уже мигрированной БД нельзя (таблицы остаются в старой); в контуре — OPENBAO__APP__DB_SCHEMA, в миграционном задании тоже. Читается при импорте моделей, поэтому скрипты и тесты видят его через те же .env/окружение
FORWARDED_ALLOW_IPS 127.0.0.1 доверенные прокси для X-Forwarded-For/X-Forwarded-Proto (ProxyHeadersMiddleware): адреса через запятую или *. Адрес клиента (audit_event.ip_address) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65)
CORS_ORIGINS пусто при DEBUG=false — источники, которым разрешены кросс-доменные запросы: строка через запятую (https://a.example.ru,https://b.example.ru) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); allow_credentials — только для явного списка без *. При DEBUG=true не используется (всегда * без credentials: токен передаётся заголовком, cookie не нужны). В обоих режимах фронту открыт заголовок Content-Disposition (Access-Control-Expose-Headers) — имя файла экспорта (#173)
SERVER__HOST, SERVER__PORT 0.0.0.0, 8000 адрес и порт сервера (run_server.py); в .env.example — 127.0.0.1 (dev-запуск только на loopback)
ROOT_PATH / корневой путь за прокси (должен совпадать с REACT_APP_ROOT_PATH фронта)
VERSION 1.0.0 версия в /healthcheck и OpenAPI
OPENBAO__DATABASE__DATABASE_URL — (обязательно) PostgreSQL, драйвер asyncpg: postgresql+asyncpg://armka:armka@localhost:5432/armka
OPENBAO__MINIO__MINIO_URL — (обязательно) адрес MinIO без схемы (host:port, так ждёт miniopy-async)
OPENBAO__MINIO__MINIO_ADMIN_LOGIN — (обязательно) логин MinIO
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD — (обязательно) пароль MinIO
OPENBAO__MINIO__MINIO_BUCKET — (обязательно) бакет MinIO (arm-ka)
OPENBAO__MINIO__MINIO_SECURE true true — MinIO по https (платформа), false — по http (MinIO из compose; так в .env.example)
OPENBAO__NATS__NATS_URL — (обязательно) адрес NATS (nats://localhost:4222)
OPENBAO__NATS__NATS_SEED пусто nkey seed; пусто — подключение без учётных данных (compose, тесты). Задаётся вместе с NATS_JWT или не задаётся вовсе
OPENBAO__NATS__NATS_JWT пусто JWT NATS; см. NATS_SEED
OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT files.tabular_uploaded subject запроса распознавания таблиц (публикация)
OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT files.img_uploaded subject запроса распознавания изображений (публикация)
OPENBAO__NATS__NATS_RESULT_SUBJECT files.tabular_parsed subject ответов (подписка, queue group arm-core). Три имени различны, без пробелов, *, > и пустых токенов; нарушение — отказ старта. Действующие значения — в логе старта и в /healthcheck.subjects
OPENBAO__SMB__SMB_HOST пусто SMB из шаблона, необязателен (ядру в Срезе 0 не нужен). Пусто — сессия SMB не регистрируется; задан — регистрация при старте (в отдельном потоке) и сброс кэша соединений при остановке
OPENBAO__SMB__SMB_PATH пусто путь SMB (в Срезе 0 не используется)
OPENBAO__SMB__SMB_LOGIN пусто логин SMB
OPENBAO__SMB__SMB_PASSWORD пусто пароль SMB
NATS_ENABLED false подключение к NATS и обмен через recognition_job: фоновые outbox-публикатор, вахтёр сроков ответа (период 30 с, processing/published с прошедшим reply_deadline → timeout) и цикл маппинга результатов (при подключённом S3, MAPPING_POLL_SECONDS); без него фоновых задач нет, /healthcheck показывает nats=off
OUTBOX_POLL_SECONDS 5 период опроса outbox (queued → публикация → published)
ACK_TIMEOUT_SECONDS 30 ожидание ack сервиса распознавания до повторной публикации
OUTBOX_MAX_REPUBLISH 3 число повторных публикаций до перевода в dlq
IMPORT_MAX_BYTES 20971520 (20 МиБ) предельный размер файла импорта справочников; больше — 413
UPLOAD_MAX_BYTES 52428800 (50 МиБ) предельный размер файла первичной отчётности при загрузке; больше — FILE_TOO_LARGE в rejected
MAPPING_POLL_SECONDS 5 период опроса цикла маппинга результатов распознавания
MAPPING_MAX_ATTEMPTS 3 число попыток маппинга файла до ERROR
CONSOLIDATION_PERIOD_FROM_VERSION false временно, для тестов на произвольных ОСВ: ОСВ, загруженная в консолидацию, берёт год и квартал консолидации (раздел «Привязка документов», arm-core#103); в контуре — OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION
AUTH_DEV_ISSUER false dev-эмитент токенов: работает только при AUTH_DEV_ISSUER=true и DEBUG=true
AUTH_DEV_SECRET пусто секрет HS256 dev-эмитента (не короче 32 байт); только для разработки
AUTH_JWKS_URL или AUTH_PUBLIC_KEY пусто проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM)
AUTH_ISSUER, AUTH_AUDIENCE пусто проверка iss и aud токена РАИСА
AUTH_EMAIL_CLAIM email claim токена с email пользователя (сопоставляется с app_user.email)

Параметры JWT РАИСА (алгоритм, claim, эмитент) пока неизвестны — это открытый вопрос (../docs/context/07-open-questions.md); AUTH_JWKS_URL/AUTH_PUBLIC_KEY и связанные поля — каркас под него. Если не настроено ни dev-эмитента, ни ключей РАИСА, токены не принимаются вовсе (/me отвечает 401).

Порты по умолчанию: приложение 8000; compose — PostgreSQL 5432, NATS 4222 (мониторинг 8222), MinIO 9000 (консоль 9001). Порты compose меняются в ../docs/dev/.env, тогда же — соответствующие OPENBAO__* в api/.env.

Команды make

Все цели выполняются из корня репозитория (сами переходят в api/).

Цель Что делает
make venv api/.venv на Python 3.11 через uv, зависимости без raisa_*
make up поднимает compose из ../docs/dev (PostgreSQL, NATS, MinIO, бакет)
make migrate alembic upgrade head на БД из api/.env
make seed загрузка справочников продукта (идемпотентна)
make seed-dev то же и dev-пользователи/филиалы (--dev) — только для разработки, требует DEBUG=true
make lint ruff check, ruff format --check, mypy src scripts
make test юнит-тесты: pytest -m "not integration", Docker не нужен
make test-integration интеграционные тесты: pytest -m "integration and not e2e", нужны PostgreSQL (и NATS)
make e2e сквозной прогон загрузки ОСВ: pytest -m e2e, нужны compose и tabular-parser из профиля services (без него — skip)
make dbml-check сверка ORM с ../docs/model/data-model-v5.23.dbml
make migration-cycle миграции на временной БД: upgrade head → downgrade base → upgrade head, пустой autogenerate
make check быстрая проверка без Docker: lint + test
make docker-build сборка образа: docker build -t arm-core .
make web-check фронт: npm install, biome check src, tsc --noEmit, npm run build
make check-infra быстрая проверка, что PostgreSQL и NATS из compose доступны (иначе: make up)
make check-full полная проверка перед PR: check + dbml-check + check-infra + test-integration (в нём цикл миграций) + e2e (без профиля services пропускается) + docker-build + web-check; нужна поднятая инфраструктура (make up) и Docker

Тесты

  • Юнит-тесты (make test) не обращаются ни к БД, ни к NATS, Docker не нужен.
  • Интеграционные (make test-integration) требуют PostgreSQL, тесты обмена через NATS — ещё и NATS. Рабочую БД armka они не трогают: на каждую сессию pytest создаётся свежая БД armcore_pytest_<hex>, мигрируется alembic и удаляется в конце. Параметры подключения берутся из api/.env.test (pytest.ini задаёт ENV_FILE=.env.test). Файл указывает на стандартные порты и учётные данные compose из ../docs/dev (PostgreSQL 5432, NATS 4222, MinIO 9000), поэтому после make up тесты работают «из коробки». То же относится к make migration-cycle и make check-full: они тоже читают .env.test через pytest.ini.
  • Реальные данные — репозиторий ../fixtures/ (путь можно задать переменной ARM_KA_FIXTURES). Тесты с маркером fixtures без каталога пропускаются с понятной причиной. Срез 4: tests/test_consolidation_fixtures.py — сквозной сценарий на ОСВ РБ и Счастливочной (golden-снимки, без tabular-parser).

Сквозной прогон (e2e)

Тест api/tests/e2e/test_osv_ingest_e2e.py (маркеры integration, e2e, fixtures) проверяет весь путь Среза 2c с РЕАЛЬНЫМ tabular-parser: POST /legal-entities/{id}/files (ОСВ 66 за 2024 из ../fixtures) → outbox → NATS → tabular-parser (MinIO) → inbox → маппинг → GET /files/{id} = PROCESSED, документ 66/2024/Q4 (нарастающий, CONFIRMED автоподтверждением, counterparty_check = MATCH) и 9 строк ОСВ. Приложение — настоящий src.app.app с lifespan на отдельной временной БД с seed, NATS и MinIO — из api/.env.test; в конце файл удаляется через DELETE /files/{id} (объекту S3 ставится expires_at), БД снимается.

Предусловие — сервис tabular-parser из профиля services на localhost:8001. Без него тест пропускается (профиль services не поднят), make check-full при этом зелёный. Инфраструктура уже поднята (make up); поднимайте только парсер, без зависимостей (--no-deps), чтобы не пересоздавать PostgreSQL/NATS/MinIO:

docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services up -d --build --no-deps tabular-parser
curl -s localhost:8001/healthcheck      # {"uptime": "...", "version": "...", "nats": "ok", "subjects": {"tabular_request": "...", "result": "..."}}
make e2e                                # PASSED
docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services stop tabular-parser

Образ собирается из ../tabular-parser (main соседнего репозитория workspace).

Перед e2e остановите другие экземпляры ядра, подписанные на files.tabular_parsed (контейнер arm-core из профиля services, локальный make run/run_server.py с NATS_ENABLED=true): тест подписывается на реальный subject в боевой queue group arm-core, подписчики группы получают сообщения по очереди (round-robin), и ack/результат парсера уйдут чужому экземпляру (он отбросит их как unknown_job, у него другая БД) — тест упадёт по таймауту 60 с. Поднимайте только tabular-parser (--no-deps), как в команде выше.

Проверка ORM и DBML

Модель данных — ../docs/model/data-model-v5.23.dbml. ORM обязана совпадать с ней один в один:

make dbml-check     # пустой вывод и код 0 — совпадение; расхождения печатаются построчно

Другой путь к DBML: make dbml-check DBML=<путь относительно api/>.

Миграции

make migrate                                  # применить: alembic upgrade head
cd api && .venv/bin/alembic downgrade base    # откатить до пустой БД
make migration-cycle                          # проверить цикл upgrade -> downgrade -> upgrade на временной БД

Ревизии лежат в api/alembic/versions/; автогенерация — только черновик, финал сверяется с DBML (make dbml-check).

Раскладка репозитория

api/
  run_server.py        точка входа (uvicorn; хост и порт — из настроек)
  alembic/             миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21, 0010_dbml_v5_23.py — v5.23; v5.22 структуру не меняла)
  rules/               statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements)
  seed/core/           справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
  seed/dev/            dev-данные: филиалы и пользователи по ролям
  scripts/             check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников)
  src/
    app.py             FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA
    api/endpoints/     эндпоинты (me.py, dev_auth.py, directories/, admin/, files/, consolidations/); router.py собирает /api/v1
    api/dependencies/  зависимости FastAPI (current_user, проверка прав)
    core/              config, auth (JWT), clients (S3), nats (подключение, publish, подписка),
                       logger, exception_handlers
    db/                движок и сессия (схема из `DB_SCHEMA`, по умолчанию dkb)
    models/            ORM-модели, 1:1 с DBML
    repositories/      доступ к данным
    schemas/           pydantic-схемы запросов и ответов
    services/          бизнес-логика; primary_files (загрузка/удаление), result_mapping (цикл маппинга и автозамена),
                       file_status (статус файла по заданию), documents (карточки, подтверждение, комментарии), statements (форма ББ/ОФР: чтение, правка, акцепт; Срез 3b),
                       recognition_exchange (обмен);
                       Срез 4 — consolidations (версии, периметр), consolidation_guard (общий пролог правок), consolidation_locks,
                       consolidation_status (`transition`), consolidation_documents (привязка), consolidated_osv (свод, кандидаты),
                       counterparty_mapping (ручное сопоставление), osv_export (xlsx)
    common/            доменные ошибки (`{detail: {code, message}}`), часы
    domain/            чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn,
                       file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3);
                       Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (межформенная проверка §7, родители подстрок),
                       checklist_csv (CSV чек-листа);
                       Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy
                       (`ensure_version_editable`, §10)
  tests/               pytest (юнит и integration/fixtures по маркерам)
web/                   фронтенд (React 18 + TypeScript, rsbuild, antd; ведётся здесь с 21.10.2026, см. web/README.md, web/CLAUDE.md): api/ (клиент), auth/, services/api/, store/, layouts/ (страницы), widgets/ (экраны); сборка web/dist -> api/web/
setup/                 бутстрап шаблона через Nexus банка (pip.conf, setup.sh)
openspec/              спецификации и изменения (OpenSpec)
docs/superpowers/      планы реализации
raisa_config.yml       ресурсы для платформы
Dockerfile             только для локального compose

Слои: src/api/endpoints → services → repositories → models.

Работа без Nexus

Пакеты raisa_* (Python) и raisa-* (npm) лежат в Nexus банка и вне банка недоступны. Что с этим сделано:

  • make venv ставит requirements.txt без строк raisa_* (фильтр grep -v '^raisa_') и поэтому требует uv, а не setup/setup.sh (тот рассчитан на pip.conf с Nexus).
  • Код импортирует raisa_* условно: пакет middleware РАИСА импортируется только при DEBUG=false, и ImportError допустим — приложение работает без него. Аутентификация Среза 0 — собственная проверка JWT (api/src/core/auth.py).
  • В web/package.json пакеты raisa-* вынесены в optionalDependencies: npm install не падает, если Nexus недоступен. Файл .npmrc с адресом реестра (см. setup/setup.sh) вне банка не нужен.

Безопасность dev-режима

  • POST /api/v1/dev/token выдаёт токен любому, кто знает email существующего активного пользователя: пароля нет. Только для локальной разработки и тестовых стендов.
  • Маршрут существует лишь при AUTH_DEV_ISSUER=true и DEBUG=true; при любом другом сочетании его нет вовсе (404), а dev-проверяющий токенов не создаётся даже при заданном AUTH_DEV_SECRET. В проде DEBUG=false, поэтому dev-эмитент там невозможен.
  • AUTH_DEV_SECRET из api/.env.example — публичный пример, в проде не использовать. make seed-dev (python -m scripts.seed --dev) при DEBUG=false отказывает (код 2), dev-пользователи в прод не попадут.
  • Порты compose по умолчанию публикуются только на 127.0.0.1 (BIND_ADDR); пароли armka/minioadmin — только dev.
  • Аудит входа (OQ-64; src/services/session_audit.py), раздел SESSION, object_type = app_user, IP и user-agent запроса:
    • LOGIN — выдача dev-токена (description «выдан dev-токен», в транзакции запроса) и первый успешный запрос пользователя с токеном после старта процесса («первый запрос с токеном»; учёт — в памяти процесса, после рестарта и на каждом экземпляре приложения первый запрос пишется снова);
    • LOGIN_FAILED — 401 (токен не принят: user_id пуст, в description причина, сам токен не пишется) и 403 аутентификации (пользователь не зарегистрирован, отключён или неоднозначен: email в description, user_id — если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась.
    • Не события входа: запрос без заголовка Authorization или со схемой не Bearer (401 «требуется заголовок…») и 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (LOGOUT) не пишется: сессий на сервере нет.
    • Поля, которые присылает клиент, обрезаются: user_agent — до 512 символов, description — до 1000.
  • audit_event.ip_address достоверен только за доверенным прокси. X-Forwarded-For принимается лишь от адресов FORWARDED_ALLOW_IPS (по умолчанию 127.0.0.1); без прокси в журнал попадает адрес TCP-соединения, а FORWARDED_ALLOW_IPS=* позволяет любому клиенту подставить свой адрес — в проде так не настраивать.

Соседние документы

Description
No description provided
Readme 14 MiB
Languages
Python 83.9%
TypeScript 15.7%
CSS 0.2%