1124 lines
128 KiB
Markdown
1124 lines
128 KiB
Markdown
# 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)».
|
||
|
||
## Предварительные требования
|
||
|
||
- **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"}
|
||
```
|
||
|
||
`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` («Пользователи и роли» в боковом меню; видна только с `USERS_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 && npm run start # фронтенд (rsbuild dev), откроется браузер
|
||
```
|
||
|
||
Вход — 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_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `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`, `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` и `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`, `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 (`domain/statement_checks.py`) считаются на лету по
|
||
текущим суммам формы и не хранятся; словарь — `api/rules/statement_checks.yaml` (копия словаря tabular-parser — правится
|
||
в обоих репозиториях).
|
||
|
||
- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks}`: `header` — `inn`,
|
||
`report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы
|
||
(`organization_name`, `okud`, `okpo`, …); `lines[].values[]` — суммы строкой, `is_modified`, `original_amount`; `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`); в БД не пишется.
|
||
- **`PATCH /documents/{id}/statement`** — `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}`
|
||
(до 5000 ячеек; `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 `DUPLICATE_CELL`, ИНН не из 10 или 12 цифр — 422 `INVALID_INN`. Один запрос — одна транзакция и одно
|
||
событие `RECOGNIZED_DATA_EDIT` с `before/after`; возврат ячейки к исходному значению снимает `is_modified`; правка ИНН
|
||
пересчитывает `counterparty_check`, даты отчёта — период документа; любая правка сбрасывает акцепт и пересчитывает статус.
|
||
`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` (чистые функции), доступ к данным —
|
||
`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».
|
||
|
||
## Ограничения Среза 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).
|
||
|
||
### Чек-лист
|
||
|
||
`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;
|
||
шахматка и ВГО — Срез 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 документа привязано, пробелы по остальным счетам.
|
||
|
||
## Ошибки 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`: одна ячейка указана в запросе дважды |
|
||
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
|
||
| `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта |
|
||
| `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`) |
|
||
| `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 символов после обрезки |
|
||
| `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 не нужны) |
|
||
| `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__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` |
|
||
| `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.18.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"}
|
||
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.18.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)
|
||
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
|
||
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): api/ (клиент), auth/ (вход, профиль), layouts/Admin (/admin); сборка 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.18.dbml`](../docs/model/data-model-v5.18.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.
|