# 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`](../CLAUDE.md) — инварианты и процесс работы всего workspace; - [`../docs/`](../docs/) — контекст (`context/`), модель данных (`model/`), контракты (`contracts/`), окружение (`dev/`); - [`CLAUDE.md`](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` принимает любые из четырёх полей; отсутствующие не меняются. Пример: ```json {"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`](https://docs.astral.sh/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. Виртуальное окружение ```bash 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 не попадает: ```bash 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`: ```bash 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`](../docs/dev/README.md). ### 3. Настройки приложения ```bash cp api/.env.example api/.env ``` Значения из `api/.env.example` уже совпадают с `../docs/dev/.env.example` (стандартные порты и учётные данные compose), менять ничего не нужно. Менять `api/.env` придётся, только если вы изменили порты или пароли в `../docs/dev/.env` (правьте обе части). `api/.env` в git не попадает. ### 4. Миграции и справочники ```bash 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. Запуск ```bash 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. Проверка ```bash curl -s localhost:8000/healthcheck ``` ```json {"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-пользователя и профиль текущего пользователя: ```bash curl -s -X POST localhost:8000/api/v1/dev/token \ -H 'Content-Type: application/json' \ -d '{"email":"ka_rf@armka.local"}' # {"access_token":"","token_type":"bearer"} curl -s localhost:8000/api/v1/me -H "Authorization: Bearer " ``` ```json { "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-режима»). ### Остановка и сброс ```bash 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=,` | любой аутентифицированный (права не нужны) | `[{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` в той же транзакции, что и сама правка; повтор того же состояния ничего не пишет. Примеры: ```bash 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`; ранее пропущенные (ошибочные) строки остаются ошибками. ```bash # шаг 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` у роли администратора. **Как запустить.** ```bash 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}/{безопасное имя}`; имя файла очищается от путей и управляющих символов. ```bash # ЮЛ — любое видимое пользователю (создайте через 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/" -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 | страница сводной ОСВ и фильтры (`limit` ≤ 2000, по умолчанию 500) | | `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]` | | `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_<ГК>_<год>-_v.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-.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`) отдаются объектом: ```json {"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`](../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_`, мигрируется `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: ```bash 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 обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно ``` Другой путь к DBML: `make dbml-check DBML=<путь относительно api/>`. ### Миграции ```bash 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=*` позволяет любому клиенту подставить свой адрес — в проде так не настраивать. ## Соседние документы - [`CLAUDE.md`](CLAUDE.md) — соглашения этого репозитория; - [`../CLAUDE.md`](../CLAUDE.md) — workspace: инварианты, источники истины, процесс; - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и [`../docs/model/data-model-v5.23.dbml`](../docs/model/data-model-v5.23.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; - [`../docs/contracts/`](../docs/contracts/) — JSON-схемы обмена с сервисами распознавания; - `openspec/changes/slice-0-skeleton/` — спецификация и решения Среза 0.