arm-core/README.md
Andrey Epifancev 8940f32ae3 fix: пары ВГО — суммы со знаком, процент в упаковке, deviation_amount (ревью #175, AURORA-1536)
- суммы сторон со знаком, как в шахматке; строки с суммой < 0 в упаковку не идут, причина NEGATIVE_AMOUNT;
  при min(ΣA, ΣB) ≤ 0 процент null и элиминация 0, статус списка AUTO — только при элиминации > 0
- группы одного размера выбираются по проценту расхождения к меньшей сумме (точной дробью), ничья — по равному проценту
- elimination_amount группы и тройки переименован в deviation_amount; version_no тройки обязателен
- суммы и проценты пар в JSON — с двумя знаками
- elimination-pairs по правилу с measure ≠ closing — 422 INVALID_RULE
- дельта спеки, design.md (R-7, R-8), tasks, README

make check-full: exit 0 (1896 passed; integration 990 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:47:47 +04:00

1310 lines
156 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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":"<JWT>","token_type":"bearer"}
curl -s localhost:8000/api/v1/me -H "Authorization: Bearer <JWT>"
```
```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=<uuid>,<uuid>` | любой аутентифицированный (права не нужны) | `[{id, full_name, is_active}]` для найденных пользователей (в т. ч. неактивных; неизвестные id пропускаются) — чтобы фронт показывал имена вместо UUID (`confirmed_by`, `locked_by`, авторы комментариев); до 200 id, больше — `TOO_MANY_IDS`, некорректный UUID — `INVALID_IDS` |
`documents_count` у ЮЛ — число документов его файлов, кроме файлов `SUPERSEDED` (подсчёт одним запросом на страницу).
Списки верхнего уровня (ГК, ЮЛ, пользователи) отдают страницу `{items, total, limit, offset}` (`limit` 1–200, по
умолчанию 50); список ГСК/ГСЗ одной ГК — простой массив. Каждая мутация пишет `audit_event` в той же транзакции, что и
сама правка; повтор того же состояния ничего не пишет. Примеры:
```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/<file_id>" -H "Authorization: Bearer $TOKEN" # статус, задание, документы
```
Нужны запущенное ядро с `NATS_ENABLED=true`, MinIO и tabular-parser (профиль `services`, см. «Сквозной прогон»).
Без подключённого S3 загрузка отвечает `503 STORAGE_UNAVAILABLE`.
## Статусы файла и документа
**Файл** (`stored_file.status`) меняется только по **текущему** заданию (`current_recognition_job_id`): результат, ack,
таймаут или dlq устаревшего задания файл не трогают (`services/file_status.py`).
| Статус | Когда |
|---|---|
| `UPLOADED` | файл принят, задание в outbox |
| `PROCESSING` | сервис подтвердил (ack) либо пришёл результат `done`/`needs_check` (если ack потерян — core NATS без JetStream) |
| `PROCESSED` | маппинг завершён: документы и строки созданы |
| `ERROR` | задание `error`, `timeout` (вахтёр, `TIMEOUT`), `dlq` или неповторяемая/исчерпавшая попытки ошибка маппинга; причина — в карточке файла (`job.error_code`/`error_message`, `job.mapping_error`) |
| `SUPERSEDED` | файл заменён более новым с теми же документами (автозамена) |
`PROCESSED` и `SUPERSEDED` события обмена не перезаписывают; запоздавший ack не воскрешает `ERROR`.
**Документ** (`document.status`) создаётся маппером на каждый лист ОСВ, на каждую форму ББ/ОФР (Срез 3b, D-2) и по одному на
тип `DISCLOSURE`/`OTHER` результата (D-3; без данных, при маппинге `RECOGNIZED`, затем автоподтверждаются системой). Условия для листа ОСВ:
- `RECOGNIZED` — задание `done`, проверки парсера без нарушений, шапка не противоречит владельцу
(`counterparty_check ≠ MISMATCH`), предупреждений маппинга у листа нет;
- `NEEDS_CHECK` — иначе: задание `needs_check`, нарушения `checks`, `counterparty_check = MISMATCH`, повтор счёта
в файле, строки без родителя, нарастающая по данным Q1;
- для формы ББ/ОФР условия `RECOGNIZED`/`NEEDS_CHECK` — правило D-6 (раздел «Формы ББ/ОФР»): проверки §7 по суммам формы вместо
проверок парсера;
- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157);
- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл).
Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_blocker`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`.
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
## Цикл маппинга
Фоновая задача lifespan (`services/result_mapping.py:run_mapping_loop`, включается при `NATS_ENABLED` и подключённом
S3 (storage); без S3 — предупреждение в логе, цикл не запускается) отделена от inbox: inbox только фиксирует результат
и регистрирует файл результата в `s3_object`, **не читает его**. Цикл с периодом
`MAPPING_POLL_SECONDS` берёт одно задание `done`/`needs_check`, чей файл `PROCESSING` и для которого оно текущее
(`FOR UPDATE SKIP LOCKED`), затем:
1. проверяет ключ результата: бакет хранилища и префикс `results/{file_id}/{job_id}/` (OQ-70; `.`/`..`/пустые сегменты
отвергаются);
2. читает объект из S3 **вне транзакции** и валидирует `RecognitionResult` v1.x (`job_id`/`file_id` должны совпасть с заданием);
3. в одной транзакции (порядок блокировок «задание → файл») удаляет прежние строки файла (идемпотентность), создаёт
`document` на каждый лист ОСВ и на каждую форму ББ/ОФР (второй `BALANCE`/`INCOME` файла пропускается с предупреждением; шапка, колонки, строки, значения; см. «Формы ББ/ОФР»), пакетами вставляет `osv_entry` (без строки `TOTAL`; `parent_entry_id`, `account_root`,
`account_id`, `counterparty_id`), пишет `recognition_job.group_schema` и `metrics.mapping_warnings`, переводит файл
в `PROCESSED` и выполняет автозамену (см. «Удаление и замена»).
Ошибки: **неповторяемые** (ключ вне папки задания, нет объекта, результат другого задания, невалидный результат, нет
документов) сразу переводят файл в `ERROR` с `metrics.mapping_error`; **повторяемые** (БД, S3) откатывают транзакцию,
увеличивают `metrics.mapping_attempts`, цикл спит `MAPPING_POLL_SECONDS` и пробует снова; после `MAPPING_MAX_ATTEMPTS`
— `ERROR`. Пока попытки не исчерпаны, файл остаётся `PROCESSING`; повторный маппинг начинается с очистки строк файла, дублей нет.
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `UPLOAD_MAX_BYTES` | `52428800` (50 МиБ) | предельный размер файла при загрузке; больше — `FILE_TOO_LARGE` в `rejected` |
| `MAPPING_POLL_SECONDS` | `5` | период опроса цикла маппинга (и пауза после повторяемой неудачи) |
| `MAPPING_MAX_ATTEMPTS` | `3` | число попыток маппинга файла до `ERROR` |
Что именно делает маппинг (нормализация кодов счетов, резолв контрагента, статус документа, нарастающая по Q1) —
`../docs/context/05-domain-rules.md` §1.3, §1.5, §3 п. 4, §9.2–9.5; код — `src/domain/osv_mapping.py`
(чистые функции) и `src/repositories/counterparties.py` (индекс ЮЛ для резолва, без policy видимости).
## API файлов и документов
Все маршруты — под `/api/v1`; объект вне видимости пользователя — 404 с тем же телом, что у несуществующего.
| Метод и путь | Право | Что делает |
|---|---|---|
| `POST /legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка файлов (multipart `files`), ответ `{accepted[], rejected[]}` |
| `GET /legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | файлы ЮЛ, новые первыми (`limit`/`offset`; `include_superseded=true` — с заменёнными) |
| `POST /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_EDIT` | загрузка в консолидацию (v5.18): файл получает `consolidation_id`; версии консолидации «в работе» с ЮЛ в периметре `INITIATED` → `DOCUMENTS_PROCESSING` |
| `GET /consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files` | `DOCUMENT_VIEW` | рабочий стол консолидации для ЮЛ: файлы с её `consolidation_id` + файлы с документами, привязанными к её версиям (`limit`/`offset`/`include_superseded`) |
| `GET /files/{file_id}` | `DOCUMENT_VIEW` | карточка файла: статус, `job` (`error_code`, `error_message`, `mapping_error`, `warnings` — список объектов `{code, severity, message, locator}`, строки старых заданий отдаются как `LEGACY_WARNING`, `severity` — `error` или `info`; `mapping_warnings` — список строк), документы |
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
| `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` |
| `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` |
| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё (в порядке проверки) `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED` |
| `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») |
| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` |
| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) |
| `GET /documents/{document_id}/comments` | `DOCUMENT_VIEW` | комментарии по возрастанию `created_at`: `{id, text, author {id, full_name}, created_at}` |
| `POST /documents/{document_id}/comments` | `DOCUMENT_EDIT` | добавить комментарий (`{text}`, 1–4000 символов после обрезки пробелов), 201; в любом статусе документа |
| `GET /files/{file_id}/content` | `DOCUMENT_VIEW` | исходный файл потоком из S3; `?disposition=attachment` (по умолчанию) или `inline`; имя — `Content-Disposition` по RFC 5987, `X-Content-Type-Options: nosniff` |
Карточка документа и документ в карточке файла несут `comments_count`. Просмотр и скачивание первички (`/files/{id}/content`)
и CSV чек-листа, как и комментарии, в аудит не пишутся (в отличие от экспорта сводной ОСВ в xlsx: он пишет `EXPORT`; OQ-153). Тело потока из S3 освобождается детерминированно (`common/streaming.py`: `ClosingStreamingResponse`, `ChainedStream`), в том числе при обрыве клиента. Чужой и несуществующий файл — 404 `NOT_FOUND`; файл есть, а объекта
в S3 нет (истёк или пропал) — 404 `FILE_CONTENT_NOT_FOUND`; S3 не подключено — 503 `STORAGE_UNAVAILABLE`.
Страницы — `{items, total, limit, offset}`. Суммы строк — десятичные значения без округления, как в результате парсера.
Коды ошибок этих маршрутов — в таблице «Ошибки API» (`FILE_TOO_LARGE`, `UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
`STORAGE_UNAVAILABLE`, `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`,
`FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS`, `FILE_CONTENT_NOT_FOUND`) и форм ББ/ОФР (`DOCUMENT_NOT_STATEMENT`,
`DOCUMENT_READONLY`, `UNKNOWN_CELL`, `DUPLICATE_CELL`, `UNKNOWN_COLUMN`, `DUPLICATE_COLUMN`, `LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`,
`INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `INVALID_INN`, `NOTHING_TO_ACCEPT`, `PERIOD_REQUIRED`,
`CHECKS_NOT_PASSED`). События аудита: `FILE_UPLOAD`, `FILE_REPLACE`, `FILE_DELETE`, `DOCUMENT_CONFIRM` (раздел
`PRIMARY_DOCUMENTS`), `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`; правка формы и акцепт).
## Формы ББ/ОФР (Срез 3b)
Цикл маппинга разбирает не только листы ОСВ, но и формы ББ/ОФР результата парсера: на каждую форму создаются `document`
(`BALANCE`/`INCOME`; второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением маппинга, OQ-77, D-3, а первый получает
`NEEDS_CHECK`) и шапка `financial_statement_meta` с колонками, строками и значениями (`financial_statement_column`,
`_line`, `_value`). Первичка не мутируется: правка аналитика пишется рядом (`is_modified`, `original_amount`,
`modified_by/at`; у полей шапки — `*_modified`, `*_original_value`). Период формы — по дате отчёта, а при её отсутствии по самой
поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений
проверок §7 (или они акцептованы), контрагент не `MISMATCH`, период определён, значения разобраны, есть строки и нет
предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 считает пакет `arm_statements` (копия в `api/arm_statements/`,
обновляется `sync-statements`) при приёме результата и после каждой правки; нарушения хранятся в `document_issue`
(`relation` / `soft`). Копии словаря §7 в ядре нет; в `domain/statement_checks.py` остались межформенная проверка 1370↔2400
(на лету, допуск — из правил пакета) и `subline_parents`.
**Формы, принятые кодом части А (M-5).** У форм ББ/ОФР, загруженных до выкладки части Б, записей §7 в `document_issue` нет
(нарушения считались на лету). После выкладки они не проверяются на `CHECKS_NOT_PASSED` при подтверждении, пока не пройдут
первую правку (она запишет `relation` / `soft`) или не будут пересозданы повторной загрузкой; на стендах формы очищает
`scripts/purge_statements.py` (раздел «Выкладка v5.19: очистка форм»).
- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks, issues[], edition_lines[]}`: `header` — `inn`,
`report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы
(`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `lines[].parent_code` — у пятизначной подстроки код её четырёхзначной строки-родителя (правило проверки «сумма подстрок = родитель», `domain/statement_checks.subline_parents`: ближайшая выше строка с теми же тремя цифрами, иначе строка с кодом из первых четырёх цифр; родитель считается по строкам, участвующим в проверках, — достроенные из справочника пустые строки родителями не бывают), у остальных строк `null`; поле вычисляется на лету, в БД не хранится; мягкая проверка «сумма подстрок = родитель» по колонке выполняется, только если хотя бы одна подстрока родителя в ней непустая (`amount` не `null`; ноль — значение), иначе не проверяется; `checks` —
`{failed, soft_failed, cross_form, accepted}` (нарушение — `{column_index, relation, expected, actual}`, у межформенной
проверки `column_index = -1`). Не форма (ОСВ, расшифровка, ББ/ОФР без данных формы — созданные до Среза 3b) — 409 `DOCUMENT_NOT_STATEMENT`.
Подтверждённая форма читается. Форма достраивается по справочнику строк редакции (`api/rules/statement_lines.yaml`): недостающие строки (`id = null`, `origin = template`) и ячейки (`id = null`); в БД не пишется.
- **Поля v5.19 в ответе `GET …/statement`** (change `unified-statement-normalization`, P3-11, P3-17, P3-20…P3-23):
`document.form_variant`; `header.okei`, `header.needs_review_fields[]`, `header.organization_name_modified`,
`header.source{поле: {text, confidence}}` (`header_source` или `{}`; `confidence` — строка с двумя знаками, `text` и
`confidence` могут быть `null`); `columns[].period_kind`, `label_confidence`; `lines[]` — `row_index`, `source_ref`,
`line_status` (`ok` / `detail` / `unreadable` / `missing`), `line_code_text`, `line_code_confidence`,
`line_code_modified`, `reference_name` (первое имя кода в словаре редакции ответа, иначе `null`), `parent_row_index`,
`needs_review`, `is_confirmed`; `lines[].values[]` — `text` (`source_text`), `needs_review`, `is_confirmed`;
`checks.*[]` — `codes` и `line_ids` (строки `ok` кодов правила); `issues[]` — записи `document_issue` по
`created_at, id`: `{id, kind, code, severity, message, line_ids, column_id, column_index, field, source, relation,
codes, expected, actual}`; `edition_lines[] {code, name}` — справочник кодов для подсказки (состав формы редакции
ответа по одному на код; у формы без даты — коды всех редакций). Порядок `lines[]`: строки `ok` (с достроенными
шаблонными) по коду, затем `row_index` (`NULL` последними); за строкой `ok` источника — её вложения (`detail` с этим
родителем, `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index`
строка `ok`); вложения без такой строки — в начале (`domain/statement_layout.py`).
Карточка документа (`GET /documents/{id}`) несёт `issues[]` (у документа любого типа) и `issue_counts {error, info}`;
`documents[]` карточки файла — `issue_counts` (все виды записей по `severity`, в том числе акцептованные; один групповой
запрос на список документов).
- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?, organization_name?}, values?: [{line_id | line_code, column_id, amount | confirmed}], columns?: [{column_id, period_date}]}`
(до 5000 ячеек, до 100 колонок; в элементе `values[]` ровно одно из `amount` и `confirmed`, `confirmed: true | false` — подтвердить ячейку
или снять подтверждение без правки суммы, только с `line_id`; `columns[].period_date` обязательна, `null` — 422 массивом; ручная дата
ставит колонке `period_kind = absolute` и пересчитывает `is_current` и период документа; дата, равная текущей, у колонки с
`COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN` — подтверждение; `header.organization_name` — непустая строка до 1000 символов,
пересчитывает `counterparty_check`; `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты;
правка поля шапки или даты колонки снимает предупреждения `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`,
`NO_ORGANIZATION_NO_INN`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; подтверждения акцепт проверок не сбрасывают;
`amount: null` очищает ячейку; не более двух знаков; `units` — непустая строка после обрезки пробелов до 50 символов,
пробельная строка — 422, `null` — «не менять»; `inn: null` — 422 `INVALID_INN`, ИНН не очищается). Правится только неподтверждённая форма
(`RECOGNIZED`/`NEEDS_CHECK`): `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление — новым файлом), заменённый файл —
409 `FILE_SUPERSEDED`, статус `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Ячейка не из этой формы — 422 `UNKNOWN_CELL`
(в том числе подтверждение ячейки, которой нет в БД), колонка не этой формы — 422 `UNKNOWN_COLUMN`, одна колонка дважды — 422
`DUPLICATE_COLUMN`, одна ячейка дважды — 422 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно
событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН
пересчитывает `counterparty_check`, даты отчёта — период документа; акцепт сбрасывает только правка данных (подтверждения ячеек и дат — нет, Р-25), статус пересчитывается после каждой правки.
`lines[] {line_id, line_code | line_status: "detail" | confirmed}` (ровно одно действие) — назначить или сменить код, отметить расшифровкой, подтвердить код (порядок частей: `lines` → `values` → `columns` → `header`). Все ошибки элемента проверяются до первой записи; первая неверная несёт в `detail` `line_id` и `line_code`; `LINE_CODE_TAKEN` — ничего не применено.
`values[]` адресует ячейку `line_id` или `line_code` (ровно одно): ячейка, которой нет, создаётся при первой непустой правке; строка по `line_code` — только код справочника редакции, которого нет в форме.
Запрос без изменений — 200 без аудита. Ответ — та же форма, что у `GET`.
- **`POST /documents/{id}/statement/checks/accept`** — `{comment}` (1–2000 символов): акцепт нарушенных жёстких проверок
(`checks_accepted_by/at/comment`). Нарушений нет — 409 `NOTHING_TO_ACCEPT`. Акцепт сбрасывается любой правкой формы (в `before_value` события аудита сброса остаётся комментарий сброшенного акцепта).
- **Подтверждение формы** (`POST /documents/{id}/confirm`) дополнительно требует определённый период (иначе 409
`PERIOD_REQUIRED`) и пройденные или акцептованные жёсткие проверки §7 (иначе 409 `CHECKS_NOT_PASSED`; в `detail.failures[]` —
`{column_index, relation, expected, actual}`). Мягкие проверки (в т. ч. межформенная `1370 − 1370(начало года) = 2400`)
подтверждение не блокируют. Подтверждённая форма привязывается к версиям консолидации и закрывает ячейки ББ/ОФР чек-листа, как ОСВ.
- **Порядок блокировок** правки и акцепта — «файл → документ» (как у подтверждения); шапка перечитывается под блокировкой.
Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами
(полиморфная связь без FK; решение №18 закрыто).
Расчёт — `src/domain/statement_mapping.py`, `src/domain/statement_checks.py` (межформенная проверка) и пакет
`arm_statements` через `src/services/statements_engine.py` (чистые функции), доступ к данным —
`src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`.
## Удаление и замена
**Автозамена.** После успешного маппинга ядро ищет другие файлы того же ЮЛ (не `SUPERSEDED`), у которых есть документ
с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, и переводит их в `SUPERSEDED`
(`superseded_by_file_id` — новый файл); новый файл получает `version_no` = максимум заменённых + 1. Документы и строки
заменённых файлов остаются (история), но список файлов их скрывает. Заменяется и файл с `CONFIRMED` документом (решение
владельца 05.10.2026); системное событие `FILE_REPLACE` (`user_id` пуст) фиксирует перечень документов и `confirmed=true`.
Документ без года или квартала в замене не участвует. Если новый файл содержит лишь часть документов прежнего, прежний
уходит в `SUPERSEDED` целиком (OQ-115), но последствия видны: в `metrics.mapping_warnings` задания нового файла
(`mapping_warnings` карточки файла, `JobBrief`) добавляется предупреждение на каждый заменённый файл — его имя и версия,
выпавшие документы (тип, счёт, период, статус, в т. ч. `CONFIRMED`) и версии консолидации «в работе», с которых сняты
привязки этих документов (OQ-154; статусы документов нового файла от него не зависят). Автозамены одного ЮЛ идут под
advisory-блокировкой.
**Удаление.** `DELETE /files/{id}` удаляет формы ББ/ОФР (шапки, колонки, строки, значения), комментарии документов, строки ОСВ, документы, задания и `stored_file`, проставляет объектам S3
(`uploads`, `results`) из папок этого файла (`uploads/{file_id}/`, `results/{file_id}/`) `expires_at = now()`
(физически объект не удаляется — ретеншн OQ-22, OQ-111) и пишет `FILE_DELETE` с карточкой файла в `before_value`. Нельзя удалить файл с документом, подтверждённым пользователем (`confirmed_by` задан), и с автоподтверждённым документом, привязанным к версии вне статусов «в работе» `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (`409 FILE_HAS_CONFIRMED_DOCUMENTS`); автоподтверждённые привязки к версиям «в работе» снимаются (`FILE_DELETE.after_value.unbound_version_ids`, OQ-157). Нельзя также удалить файл, который заменил другие (`409 FILE_SUPERSEDES_OTHERS`); `SUPERSEDED` файл удалить можно. Блокировки — в порядке
«задания → файл → документы → advisory ГК → версии по id».
## Выкладка v5.19: очистка форм
Перед `alembic upgrade` v5.19 на контуре (после резервной копии БД) база очищается от форм ББ/ОФР — они пересоздаются
повторной загрузкой по новым правилам (Р-24). Запускает владелец или платформенная команда по его заявке; на стендах
разработчиков — по желанию. Из `api/`, одна транзакция:
```
.venv/bin/python -m scripts.purge_statements # --dry-run (по умолчанию): только отчёт
.venv/bin/python -m scripts.purge_statements --apply # очистка
.venv/bin/python -m scripts.purge_statements --apply --include-mixed # смешанные файлы удалить целиком
```
Отчёт: число файлов только с формами, смешанных файлов (есть и ОСВ, и формы; их повторная загрузка невозможна из-за
дедупа по `sha256`, поэтому владелец решает до `--apply`, нужен ли `--include-mixed`), документов форм, подтверждённых
документов, привязок к неверифицированным версиям, файлов с ОСВ, заменённых удаляемыми.
- Файл только с формами удаляется целиком, как `DELETE /files/{id}`, но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`:
привязки снимаются (статус версий не меняется), `superseded_by_file_id` ссылающихся файлов обнуляется, объектам S3
ставится `expires_at`. У смешанного файла удаляются только `BALANCE` / `INCOME` с формами, комментариями и привязками.
- Отказ без изменений — только если документ упомянут в `ex_snapshot_item` или привязан к версии `VERIFIED` либо
`ARCHIVED` со срезом ЕХ (Р-35); отчёт называет версии. Версии в работе, на согласовании, `REJECTED`, `CANCELLED` не
мешают.
- Аудит: на файл — системное `FILE_DELETE` (`user_id` пуст, описание «очистка форм перед v5.19», карточка файла в
`before_value`; у смешанного — `after_value.deleted_documents`). ОСВ, консолидации, справочники и журнал не трогаются.
- Коды выхода: `0` — отчёт или выполнено, `1` — отказ, `2` — ошибка аргументов.
## Ограничения Среза 2c
- Аудит обмена (смена статусов задания/файла в inbox, вахтёре, цикле маппинга) не пишется — `ASSUMPTION(OQ-55)`;
`audit_event` пишут только загрузка, автозамена, удаление, подтверждение.
- Объекты S3 при удалении не стираются (OQ-22, OQ-111): растёт бакет; e2e оставляет ~2 объекта за прогон.
- Общий размер multipart-запроса `POST /legal-entities/{id}/files` (и маршрута консолидации) и число файлов в нём приложением не ограничены
(Starlette спулит части на диск, `UPLOAD_MAX_BYTES` — лимит одного файла); лимит запроса задаётся ingress платформы.
- Маппер Среза 2c разбирал только листы ОСВ (`osvDocument`); с Среза 3b он мапит и формы ББ/ОФР (раздел «Формы ББ/ОФР»).
Документы `DISCLOSURE`/`OTHER` результата не игнорируются: на каждый тип создаётся один документ без данных (`RECOGNIZED` при маппинге, затем автоподтверждение;
`locator` — перечень листов через запятую; OQ-23). Отдельной обработки по конвейеру (`tabular`/`image`) в маппере нет: разбирается
любой результат, проходящий схему `recognition-result`.
- Корневой сегмент счёта (`account_root`) — только цифровой (`ASSUMPTION(OQ-103)`): забалансовые «МЦ.04», «ГТД»
получают `account_root = NULL`; `account_id` ищется точным совпадением с планом счетов seed, субсчетов в seed
неполный набор — часть строк остаётся с `account_id = NULL`.
- ИНН контрагента в тексте строки парсер не извлекает (OQ-100): резолв идёт по ИНН из колонки, иначе по имени и алиасу.
- ОСВ «нарастающая по данным Q1» (Q2–Q4 с `opening` корня, равным `opening` Q1) получает предупреждение маппинга,
поэтому документ остаётся `NEEDS_CHECK`: аналитик подтверждает его вручную (решение R-17, ждёт подтверждения владельца).
- `period_check` — `UNKNOWN` (OQ-120); к версии консолидации привязываются только `CONFIRMED` документы; файл с документом, подтверждённым
пользователем, удалить нельзя, файл с автоподтверждённым документом — можно, пока все версии с его привязками в
`INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION` (привязки снимаются; иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS`; OQ-110,
уточнено OQ-157); FK `ex_snapshot_item` — Срез 7; `EntryOut` не отдаёт `is_modified` (панель правки — подсрез 2d).
- Два экземпляра ядра могут одновременно мапить один файл (второй проиграет блокировку и пропустит кандидата);
при уменьшении `MAPPING_MAX_ATTEMPTS` файлы с исчерпанным счётчиком остаются `PROCESSING` до ручного вмешательства.
## Консолидация (Срез 4)
Срез 4: версии консолидации ГК за период, периметр, блокировка «взять в работу», статусы (автоматические по событиям
документов и ручные), привязка подтверждённых документов к версии, чек-лист комплекта, сводная ОСВ (чистая функция от
версии, в БД не хранится), ручное сопоставление контрагента и экспорт в xlsx. Спецификация —
`openspec/changes/slice-4-consolidation/` (`design.md`: D-1…D-16 и таблица «Decisions made without discussion»);
контракт для фронта — `../docs/dev/api-contract-slice-4.md`; правила — `../docs/context/05-domain-rules.md` §2, §3, §9, §10.
### Эндпоинты
Все маршруты — под `/api/v1`; права — `CONSOLIDATION_VIEW` (чтение) и `CONSOLIDATION_EDIT` (правки). Порядок проверок:
право → видимость ГК (чужое и несуществующее — один 404) → статус «в работе» и своя блокировка → операция.
| Метод и путь | Право | Что делает |
|---|---|---|
| `POST /consolidations` | EDIT | создать консолидацию ГК за период и версию №1 (`INITIATED`); периметр — текущие члены ГСК (≥ 2) или выбранные ЮЛ ГК `legal_entity_ids` (≥ 2, #42) |
| `GET /company-groups/{id}/consolidations` | VIEW | периоды ГК и их версии (`in_work_only`) |
| `GET /consolidations/versions/{id}` | VIEW | карточка версии: периметр (тип ЮЛ, счета ОСВ к периоду версии), чек-лист по ЮЛ (самоописывающие ячейки), история статусов, блокировка |
| `POST /consolidations/versions/{id}/lock` | EDIT | «взять в работу»; `{takeover_reason}` — перехват чужой блокировки с причиной |
| `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) |
| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` |
| `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) |
| `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки; `?format=csv` — тот же чек-лист файлом CSV |
| `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры (`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_<ГК>_<год>-<Qn>_v<n>.xlsx`). Текстовые ячейки пишутся как текст
(без формул из внешних строк). Пишет `audit_event(EXPORT)`.
### События аудита
`CONSOLIDATION_CREATE`, `CONSOLIDATION_STATUS_CHANGE`, `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`, `MEMBERSHIP_CHANGE`
(замена периметра; состав не менялся — события нет), `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE` (алиасы сопоставления),
`EXPORT`; `FILE_REPLACE` дополнен `unbound_version_ids`.
### Порядок блокировок и инварианты
- Блокировки строк: **файл → документ → advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с
`consolidation_version FOR UPDATE`. `lock_versions_for_entity` блокирует надмножество версий «в работе» (член ИЛИ та же
ГК), создание версии берёт `pg_advisory_xact_lock` по ГК (R-2) — иначе привязка тихо теряется.
- Документы под `FOR NO KEY UPDATE` (R-3): вставка `consolidation_document` берёт `FOR KEY SHARE` на документ и конфликтует
с `FOR UPDATE`. Сопоставление: версия → advisory по алиасу → алиасы → строки `osv_entry` с тем же именем.
- Статус версии меняет только `consolidation_status.transition`, версия под `FOR UPDATE` у вызывающего.
### Ограничения Среза 4
- Маршрут согласования (`CONSOLIDATION_RF` и далее), «Добавить версию», `CONFIRM`, `EXTENDED`, гудвилл — Срезы 6–7;
API шахматки, правил элиминации и результирующей таблицы ВГО реализован в Срезе 5; автоматическое сопоставление ВГО,
корректировки и UI — отдельная работа. ББ/ОФР в своде не участвуют.
- Открытые вопросы — `../docs/context/07-open-questions.md`: OQ-118 (запрет загрузки/подтверждения после `DATA_CORRECTION`),
OQ-119 (истечение блокировки), OQ-120 (`period_check`), OQ-121 (порог размера свода), OQ-122 (ячейка `PROCESSING`),
OQ-123 (общие строки у версий), OQ-124 (Q1-эквивалентность ОСВ), OQ-125 (склейка Q1 версии Q1), OQ-126 (область ручного
сопоставления), OQ-127 (рекомендация кандидата), OQ-128 (неоднозначность дорезолва), OQ-129 (формулировка «1 + propagated»).
- Экспорт: числа xlsx — IEEE-754, суммы свыше ~10^13 могут потерять младшую копейку.
- Тест на реальных данных — `api/tests/test_consolidation_fixtures.py` (маркер `fixtures`): ОСВ РБ и Счастливочной за Q1–Q3 2024
→ версия Q3 → свод (1385 строк), 22 документа привязано, пробелы по остальным счетам.
## Шахматка и результирующая таблица ВГО (Срез 5)
API: список счетов `GET /directories/accounts`, правила версии `GET …/elimination-rules`, матрица
`GET …/intercompany-matrix/all` и `POST …/manual`, их XLSX-выгрузки, статичная таблица
`GET …/intercompany-matrix/result-table` и её XLSX `GET …/intercompany-matrix/result-table/export`
(аудит `EXPORT`, `report = "intercompany_result_table"`). Режим PRESET (счета и показатель из правила элиминации):
`GET …/intercompany-matrix/preset/{rule_id}` и его выгрузка `…/preset/{rule_id}/export`.
Режим ALL — только балансовые счета (без 001–012), MANUAL принимает и забалансовые. `one_sided` — ровно одна сумма
пары нулевая; пара подсвечивается (и в xlsx) при `exceeds_threshold` или `one_sided` (AURORA-1651).
`GET …/elimination-rules` отдаёт объект `{version_no, is_version_fixed, rules}` (у правила есть `id`): правила версии
`consolidation_version.elimination_rules_version` — она фиксируется при создании версии консолидации (действующая на дату
создания, OQ-174) и с DBML v5.23 есть всегда, `is_version_fixed = true`. Ошибки PRESET: 422 `RULE_REQUIRED`, `INVALID_RULE`,
`ACCOUNTS_REQUIRED`; правило недействительное для версии или несуществующее — 404. Имя XLSX пресета оканчивается на
`_rule-balance-<rule_no>.xlsx`; в шапке — номер, статьи и версия правила, строка «Показатель»,
audit `EXPORT` пишет `mode` (`ALL`/`MANUAL`/`PRESET`), для PRESET — `rule_id`, `rule_no` и фактический
`rule_version_no`. Открытые вопросы по пресетам —
`openspec/changes/intercompany-matrix-presets/design.md`. Контракт для фронта —
`../docs/dev/frontend-api-guide.md` §5.7.1 (docs #146); требования — в существующих спецификациях
`openspec/specs/{directories-api,consolidations-api,consolidated-osv,audit-trail}/spec.md`.
Расчёт читает сводную ОСВ версии, результат не хранится.
**Пары ВГО §5 (AURORA-1536, PR #175).** `GET …/elimination-pairs?rule_id=&company_a_id=&company_b_id=` — итог тройки
(ΣA, ΣB со знаком, расхождение, процент, `eliminated_amount` по правилу 5 %) и упаковка строк 1:1 / 1:N / N:1
(`matched`, `with_discrepancy`, `unpaired` с причиной `NO_MATCH` / `AMBIGUOUS` / `ZERO_AMOUNT` / `NEGATIVE_AMOUNT`;
группы сортируются по проценту расхождения, отрицательные строки в упаковку не идут); `GET …/elimination-totals` — итоги всех троек версии (правила ББ ×
упорядоченные пары периметра) со статусом `AUTO` / `DISCREPANCY` / `ONE_SIDED` / `MANUAL`, сводная ОСВ строится один раз.
Каскада по остатку нет: строка общего счёта входит в несколько правил до разнесения (AURORA-1751). Ошибки: 404 (версия,
правило не из версии правил), 422 `INVALID_COMPANY_PAIR`, `INVALID_RULE` (в т. ч. правило по оборотам), `ACCOUNTS_REQUIRED`. Решения —
`openspec/changes/elimination-pairs-rule-totals/design.md`; контракт — `../docs/dev/frontend-api-guide.md` §5.7.2.
Поставка DBML v5.23: миграция `0010_dbml_v5_23` проставляет пустой `elimination_rules_version` по дате создания версии и
делает колонку NOT NULL; нет правил вовсе — миграция падает до изменения схемы, поэтому seed правил должен быть применён
раньше. Откат снимает только NOT NULL.
## Ошибки API
Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом:
```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_<hex>`, мигрируется `alembic`
и удаляется в конце. Параметры подключения берутся из `api/.env.test` (`pytest.ini` задаёт `ENV_FILE=.env.test`).
Файл указывает на стандартные порты и учётные данные compose из `../docs/dev` (PostgreSQL `5432`, NATS `4222`,
MinIO `9000`), поэтому после `make up` тесты работают «из коробки». То же относится к `make migration-cycle` и
`make check-full`: они тоже читают `.env.test` через `pytest.ini`.
- **Реальные данные** — репозиторий `../fixtures/` (путь можно задать переменной `ARM_KA_FIXTURES`). Тесты с маркером
`fixtures` без каталога пропускаются с понятной причиной. Срез 4: `tests/test_consolidation_fixtures.py` — сквозной сценарий
на ОСВ РБ и Счастливочной (golden-снимки, без tabular-parser).
### Сквозной прогон (e2e)
Тест `api/tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`, `fixtures`) проверяет весь путь Среза 2c с
РЕАЛЬНЫМ tabular-parser: `POST /legal-entities/{id}/files` (ОСВ 66 за 2024 из `../fixtures`) → outbox → NATS →
tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PROCESSED`, документ 66/2024/Q4 (нарастающий,
`CONFIRMED` автоподтверждением, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на
отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}`
(объекту S3 ставится `expires_at`), БД снимается.
Предусловие — сервис tabular-parser из профиля `services` на `localhost:8001`. Без него тест пропускается
(`профиль services не поднят`), `make check-full` при этом зелёный. Инфраструктура уже поднята (`make up`); поднимайте
только парсер, без зависимостей (`--no-deps`), чтобы не пересоздавать PostgreSQL/NATS/MinIO:
```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.