arm-ka-docs/dev/qa-guide.md
Andrey Epifancev 15d069c730 docs(context): Срез 3b — правки по ревью PR #49 (#48)
Порядок проверок в контракте, ссылки на решения №1–19, разделители в OQ,
уточнения по units и /users/brief, номера PR закрытых срезов 2a/2c/3a, убрана
ссылка на исполнителя экранов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 13:39:21 +04:00

744 lines
92 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Гайд тестировщика: локальный стенд АРМ КА (Срезы 0–2c, 3b, 4)
Документ для ручного тестирования текущего `main`. Путь проверен 05.10.2026 на macOS (Docker 28, compose 2.34,
Node 24, uv 0.10): от пустого каталога до экрана администратора — около 15 минут, из них 10 — скачивание образов.
## 1. Что сейчас есть и чего нет
**Есть (Срез 0 + Срез 1 + Срез 2c):**
- Бэкенд `arm-core` (FastAPI, `/api/v1`): вход по email в dev-режиме, профиль пользователя, права как данные.
- API справочников: группы компаний (ГК), юрлица и ИП (ЮЛ) с историей, переводом между ГК и закрытием, алиасы
контрагентов, группы связанных компаний и заёмщиков (ГСК/ГСЗ) с составом, филиалы. Видимость по филиалу: головной
офис видит всё, филиал — только свои ГК.
- Импорт справочников из выгрузки ЕЦП CRM (xlsx/csv) с пробным прогоном и отчётом.
- Администрирование: пользователи, роли, матрица «роль × право», защита последнего администратора.
- Фронтенд: страница `/admin` («Пользователи и роли»). Другого UI нет.
- Журнал аудита в БД (`dkb.audit_event`).
- Загрузка файлов ОСВ через API (`POST /legal-entities/{id}/files`), распознавание реальным `tabular-parser`, разбор
результата в документы и строки ОСВ, статусы файла и документа, автозамена версий, подтверждение документа, удаление
файла (раздел 6.6); проверка парсера на произвольных форматах ОСВ и формах ББ/ОФР — раздел 6.7; формы ББ/ОФР в ядре (просмотр, правка, акцепт проверок, подтверждение, комментарии, исходный файл, CSV чек-листа; Срез 3b) — раздел 6.9. Всё — через Swagger/curl, UI «Первичные документы» — подсрез 2d.
- Распознавание PDF в `scan-gateway` с эмулятором ИС Сканирование (Срез 3a, раздел 6.8): отдельно от ядра, без UI; загружать
`aep_3.pdf` / `aep_6.pdf` из `fixtures/ocr`.
- Консолидация через API (Срез 4): версии за период, периметр, блокировка «взять в работу», статусы, привязка подтверждённых
документов, чек-лист, сводная ОСВ, ручное сопоставление контрагента, экспорт xlsx (раздел 7; полный прогон на данных РБ и
контрольные цифры — 7.1–7.3). UI консолидации — другой исполнитель.
- План счетов — полная выгрузка 1С заказчика, 488 строк (arm-core #25): субсчета реальных ОСВ (66.01, 76.07.x, забалансовые,
буквенные `01.К`) резолвятся в `account_id`. Границы входов консолидации: периметр ≤ 500 ЮЛ, комментарий статуса ≤ 1000, поиск
`q` ≤ 500 символов (arm-core #26, раздел 6.5).
- Парсер ОСВ после пакета ДКБ (arm-tabular-parser #13, #17): скользящие периоды, несколько корней без строки счёта, «Итого
развернутое», шапка без меток, ИНН в тексте названия и строкой выше (#15); тесты на 25 файлах `osv-dkb/**` по манифесту
ожиданий, golden — 113 файлов (раздел 6.7).
**Нет (это не баги):**
- Экранов справочников ГК/ЮЛ/ГСК — проверяются только через Swagger (`/docs`) или curl.
- Экрана загрузки и таблицы ОСВ (подсрез 2d), экранов консолидации (API есть, раздел 7), экрана «Проверка распознавания» ББ/ОФР (API есть, раздел 6.9), экрана распознавания PDF, шахматки, КФО — следующие срезы.
- Настоящего входа через SSO банка. Вход — dev-форма по email без пароля.
## 2. Что установить и проверить из ПО
| Что | Версия | Проверка | Если нет |
|---|---|---|---|
| ОС | macOS или Linux. Windows — только через WSL2 (Ubuntu); нативно не проверялось | — | — |
| Git + SSH-ключ в Gitea | любая свежая | `ssh -T -p 2222 git@git.aepif.ru` отвечает приветствием | добавить публичный ключ в профиль Gitea `git.aepif.ru` |
| Docker + compose v2 | Docker ≥ 24, compose ≥ 2.20 (проверено 28.0 / 2.34) | `docker compose version` | Docker Desktop (macOS) или docker-ce + docker-compose-plugin (Linux) |
| `uv` | ≥ 0.4 (проверено 0.10) | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh`; Python 3.11 `uv` скачает сам |
| Node.js + npm | Node ≥ 20 LTS (проверено 24.7) | `node --version && npm --version` | nodejs.org или `nvm install --lts` |
| `make`, `curl` | любые | `make --version`, `curl --version` | macOS: Xcode Command Line Tools (`xcode-select --install`); Linux: `build-essential` |
| Свободные порты | 5432, 4222, 8222, 9000, 9001 (compose), 8000 (бэкенд), 3000 (фронт) | `lsof -i :5432` и т. д. пусто | закрыть локальный PostgreSQL/MinIO или сменить порты в `docs/dev/.env` и `arm-core/api/.env` |
| Ресурсы | Docker: ≥ 4 ГБ RAM, ≥ 3 ГБ диска под образы и тома | — | — |
| Доступ в интернет | нужен один раз: образы Docker Hub, PyPI, npm | — | Nexus банка не требуется |
Необязательно, но удобно: клиент PostgreSQL (DBeaver или `psql`) для просмотра аудита; Postman или аналог — Swagger
на `/docs` достаточно.
Браузер — любой современный (Chrome, Firefox, Safari, Edge).
## 3. Клонирование
Три репозитория должны лежать **рядом**, в одном каталоге, под именами `docs`, `arm-core`, `fixtures` (в коде пути
вида `../docs/...`):
```bash
mkdir arm-ka && cd arm-ka
git clone ssh://git@git.aepif.ru:2222/RSHB/arm-ka-docs.git docs
git clone ssh://git@git.aepif.ru:2222/RSHB/arm-core.git arm-core
git clone ssh://git@git.aepif.ru:2222/RSHB/arm-ka-fixtures.git fixtures
```
Без SSH-ключа можно клонировать по HTTPS с логином и паролем Gitea: `https://git.aepif.ru/RSHB/arm-core.git` и т. д.
`fixtures` содержит реальные данные (внутренний доступ) — нужен для файла импорта. Если доступа нет, пункт 6.3
чек-листа можно выполнить на полной выгрузке из `docs/source/biq/`.
## 4. Запуск стенда
Все команды — из каталога `arm-core`, если не сказано иное.
### 4.1 Инфраструктура (PostgreSQL, NATS, MinIO)
```bash
cp ../docs/dev/.env.example ../docs/dev/.env
make up
docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio
docker compose -f ../docs/dev/docker-compose.yml ps
```
Ожидание: `postgres`, `nats`, `minio` — `Up (healthy)`; `minio-init` — `Exited (0)` (это нормально: он создаёт бакет и
завершается). Первый запуск скачивает образы, это самая долгая часть.
### 4.2 Бэкенд
```bash
make venv # api/.venv на Python 3.11, зависимости из PyPI
cp api/.env.example api/.env # dev-настройки: DEBUG=true, AUTH_DEV_ISSUER=true
make migrate # схема БД
make seed-dev # справочники + 7 dev-пользователей + dev-ГК под документы fixtures (см. ниже)
cd api && .venv/bin/python run_server.py
```
Ожидание: в логе нет трейсбэков; `curl -s localhost:8000/healthcheck` отвечает `{"uptime": ..., "nats": "ok"}`.
Swagger: <http://localhost:8000/docs>. Сервер слушает только `127.0.0.1`, так и оставьте.
Для сценариев загрузки ОСВ (6.6) нужны ещё `NATS_ENABLED=true` (так в `api/.env.example`) и **tabular-parser**; поднимайте
только его, без второго экземпляра ядра (он забирает часть сообщений `recognition.result`):
```bash
docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build --no-deps tabular-parser
curl -s localhost:8001/healthcheck # nats: ok
```
### 4.3 Фронтенд (второй терминал)
```bash
cd arm-core/web
npm install
REACT_APP_API_URL=http://localhost:8000 REACT_APP_ROOT_PATH=/ npm run start
```
Откроется <http://localhost:3000/> с формой «Вход для разработки». Две переменные окружения обязательны: без них
фронт ищет API на своём порту и путь приложения как на платформе банка.
Не используйте `npm run build` для тестирования: в production-сборке dev-формы входа нет, страница показывает
«Вход выполняется через РАИСА».
Настройки из `api/.env.example` (`DEBUG=true`, `AUTH_DEV_ISSUER=true`, `CORS_ORIGINS=`, `FORWARDED_ALLOW_IPS=127.0.0.1`)
для локального стенда менять не нужно: `DEBUG=true` включает документацию API на `/docs`, dev-вход и разрешает `make seed-dev`.
`make seed-dev` (arm-core #31) создаёт, кроме пользователей, **dev-ГК под документы из `fixtures`**: ГК «Русский Бисквит» ровно как в
выгрузке ЕЦП CRM (20 ЮЛ/ИП, филиал «Санкт-Петербургский РФ», ГСК из АО «Русский бисквит» `3528052718` и ООО «Пекарня
Счастливочная» `3528104476`, алиасы под имена контрагентов из ОСВ) и ГК владельцев форм пакета ДКБ («Унагранде», «Новороссийский
прокатный завод», «Агрофирма Межениновская»). Поэтому ОСВ и формы из `../fixtures` загружаются сразу на «свои» ЮЛ (6.6, 7.1), а
импорт выгрузки ЕЦП (6.3) на таком стенде отвечает `GK_EXISTS`/`LE_EXISTS`. Стенд без dev-ГК (пустые справочники, как в тестах
ядра): `cd api && .venv/bin/python -m scripts.seed --dev --no-dev-groups`.
### 4.4 Вход
В форме введите **email** dev-пользователя (не имя и не логин) и нажмите «Войти (dev)». Пароля нет. Это единственная форма
входа АРМ: страница с полями «Имя пользователя / пароль» — это Gitea, а не стенд. Токен живёт в текущей вкладке
(`sessionStorage`), новая вкладка — новый вход. Токен действует 8 часов.
| Email | Роль | Филиал | Что видит |
|---|---|---|---|
| `admin@armka.local` | ADMIN | Головной офис | всё; единственный с правом администрирования и импорта |
| `ka_go@armka.local` | KA_GO | Головной офис | все ГК и ЮЛ, может создавать и менять |
| `curator_go@armka.local` | CURATOR_GO | Головной офис | все ГК и ЮЛ, может создавать и менять |
| `expert@armka.local` | EXPERT | Головной офис | всё, только чтение |
| `ka_rf@armka.local` | KA_RF | Ростовский РФ | только ГК своего филиала, может создавать и менять |
| `rka@armka.local` | RKA | Ростовский РФ | только ГК своего филиала |
| `client_manager@armka.local` | CLIENT_MANAGER | Краснодарский РФ | только ГК своего филиала, чтение |
### 4.5 Токен для Swagger и curl
В Swagger (`/docs`) токен подставляется кнопкой **Authorize** (замок справа вверху): вставьте значение `access_token` без слова
`Bearer`, дальше все защищённые ручки отправляют заголовок сами. Кнопка появилась с arm-core #29; если её нет — бэкенд старше, используйте curl:
```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 sys,json;print(json.load(sys.stdin)["access_token"])')
curl -s localhost:8000/api/v1/me -H "Authorization: Bearer $TOKEN"
```
В Swagger: кнопка **Authorize** → вставить токен (без слова Bearer) → все запросы пойдут от этого пользователя.
Чтобы проверить видимость разными ролями, получите второй токен другим email и переавторизуйтесь.
## 5. Как читать ответы
- Успех: 200/201, тело JSON. Удаление — 204 без тела.
- Нет токена или он плохой — 401. Нет права — 403 с текстом `недостаточно прав: требуется <КОД>`.
- Доменные ошибки — объект `{"detail": {"code": "...", "message": "..."}}`: 404 `NOT_FOUND` (в том числе для **чужой**
записи — так задумано, чужое неотличимо от несуществующего), 409 конфликты (`INN_EXISTS`, `LAST_ADMIN`, …),
422 валидация (`INVALID_INN`, `OFFICE_NOT_ALLOWED`, …).
- Ошибки валидации тела (не тот тип поля) — 422 со списком от pydantic.
- Списки — `{"items": [...], "total": N, "limit": 50, "offset": 0}`, `limit` ≤ 200.
Полный каталог кодов — `arm-core/README.md`, раздел «Ошибки API».
## 6. Чек-лист сценариев
Отмечайте: ✅ ожидание совпало / ❌ баг (с номером issue) / ⚠️ вопрос. Пустые поля ожиданий — проверьте сами и
зафиксируйте результат.
### 6.1 Администрирование (UI `/admin`, вход `admin@armka.local`)
| № | Шаги | Ожидание |
|---|---|---|
| A1 | Открыть «Система → Пользователи и роли» | таблица из 7 пользователей, вкладки «Пользователи» и «Матрица прав» |
| A2 | «Создать пользователя»: email `qa1@example.com`, ФИО, филиал, роль KA_RF | пользователь появился; войти им в новой вкладке — получится, роли KA_RF |
| A3 | Создать пользователя с email `qa1@EXAMPLE.com` | ошибка 409 `EMAIL_EXISTS` уведомлением |
| A4 | Создать пользователя с email `x@corp.local` | ошибка 422 (домен `.local` отклоняется — известное ограничение) |
| A5 | У `qa1@example.com` сменить роли на KA_GO + EXPERT | сохранилось; `/me` этого пользователя (новая вкладка) показывает обе роли |
| A6 | Снять роль ADMIN у `admin@armka.local` (у себя), пока он единственный админ | 409 `LAST_ADMIN`, роли не изменились |
| A7 | Выдать роль ADMIN пользователю `qa1@example.com`, затем снять ADMIN у себя | теперь разрешено |
| A8 | Деактивировать единственного активного админа | 409 `LAST_ADMIN` |
| A9 | Матрица прав: роли EXPERT добавить `EXPORT`, сохранить | `/me` эксперта содержит `EXPORT` |
| A10 | Матрица прав: снять `USERS_ADMIN` у ADMIN, когда других админов нет | 409 `LAST_ADMIN` |
| A11 | Войти `expert@armka.local`, открыть `/admin` напрямую | «Нет доступа», пункт меню скрыт, запросов к `/admin/*` нет (вкладка Network) |
| A12 | После правки своих ролей обновить страницу | профиль в шапке обновился только после перезагрузки (известное ограничение) |
### 6.2 Справочники (Swagger `/docs`)
| № | Пользователь | Шаги | Ожидание |
|---|---|---|---|
| D1 | ka_go | `POST /directories/company-groups` `{"name":"ГК Тест ГО"}` | 201, `code` вида `GK-000001`, `base_regional_office_id` пустой |
| D2 | ka_rf | `POST /directories/company-groups` `{"name":"ГК Тест РФ"}` без филиала | 201, филиал подставлен Ростовский |
| D3 | ka_rf | то же с `base_regional_office_id` Краснодарского РФ (id из `GET /directories/regional-offices`) | 422 `OFFICE_NOT_ALLOWED` |
| D4 | ka_rf | `GET /directories/company-groups` | только «ГК Тест РФ», `total` = 1 (ГК ГО не видна) |
| D5 | ka_rf | `GET /directories/company-groups/{id ГК Тест ГО}` | 404, тело такое же, как для случайного uuid |
| D6 | ka_go | `GET /directories/company-groups` | обе ГК |
| D7 | ka_go | `PATCH` ГК: сменить имя | 200; `GET .../history` — одна запись с прежним именем и `valid_to` |
| D8 | ka_go | `POST /directories/legal-entities` ИП с ИНН из 10 цифр | 422 `INVALID_INN` |
| D9 | ka_go | ЮЛ `{"inn":"7700000001","entity_type":"ЮЛ","name":"ООО Альфа","company_group_id":<ГК ГО>}` | 201 |
| D10 | ka_go | повторно тот же ИНН | 409 `INN_EXISTS` |
| D11 | ka_go | ЮЛ без `company_group_id` | 201, в списке `outside_group = true` |
| D12 | ka_go | `POST .../legal-entities/{id}/aliases` `{"alias":"ООО «Альфа»"}` | 201, `normalized_alias` = `альфа` |
| D13 | ka_go | тот же алиас другому ЮЛ | 409 `ALIAS_TAKEN` с `legal_entity_id` владельца |
| D14 | ka_go | `POST /directories/company-groups/{ГК}/related-groups` kind `gsk`, затем `gsz` | 201 оба, коды `GSK-…`, `GSZ-…` |
| D15 | ka_go | добавить ЮЛ «Альфа» в ГСК и в ГСЗ той же ГК | оба 201 |
| D16 | ka_go | создать вторую ГСК в той же ГК и добавить туда «Альфа» | 409 `MEMBER_OF_OTHER_GROUP` |
| D17 | ka_go | добавить в ГСК ЮЛ из другой ГК | 422 `DIFFERENT_COMPANY_GROUP` |
| D18 | ka_go | `POST .../legal-entities/{Альфа}/move` в другую ГК, `effective_date` сегодня | 200; членства закрыты (`GET .../legal-entities/{id}` — `memberships` пуст); история `MOVE_TO_GROUP` |
| D19 | ka_go | `move` с `effective_date` вчера | 422 |
| D20 | ka_go | `POST .../withdraw` `{"event":"CLOSE","effective_date":сегодня}` | `close_date` заполнен, `company_group_id` пуст |
| D21 | admin | `DELETE /directories/related-groups/{id непустой}` | 409 `GROUP_NOT_EMPTY`; пустой — 204 |
| D22 | ka_go | `DELETE /directories/related-groups/{id пустой}` | 403 (право `DIRECTORY_DELETE` только у ADMIN — так задумано) |
| D23 | expert | любой `POST` в справочники | 403 |
| D24 | любой | `GET /directories/company-groups?limit=201` | 422 |
### 6.3 Импорт из ЕЦП CRM (пользователь `admin`, curl или Swagger)
Файл: `fixtures/directories/ecp-export-russkiy-biskvit.xlsx` (21 строка). Выполняйте на стенде **без dev-ГК**: после сброса
(раздел 9) засейте `python -m scripts.seed --dev --no-dev-groups` вместо `make seed-dev`, иначе ГК «Русский Бисквит» и её ЮЛ уже
есть и счётчики будут другими (I2 даст `GK_EXISTS`/`LE_EXISTS`, `offices_created` 0).
| № | Шаги | Ожидание |
|---|---|---|
| I1 | `POST /directories/import` с `dry_run=true` | 200; `rows` 21, `groups_created` 1, `offices_created` 1, `entities_created` 20, `memberships_created` 2, `errors` 1 (строка типа ФЛ → `UNSUPPORTED_TYPE`); в БД и справочниках ничего не появилось |
| I2 | тот же файл `dry_run=false` | 200, тот же отчёт; появились ГК «Русский Бисквит», филиал «Санкт-Петербургский РФ», 20 ЮЛ/ИП, одна ГСК с двумя членами |
| I3 | ещё раз `dry_run=false`, `on_existing=skip` | все строки `LE_EXISTS`/`GK_EXISTS`, число записей не изменилось |
| I4 | csv без колонки «Тип» | 422 `MISSING_COLUMNS`, в ответе список недостающих колонок |
| I5 | файл в кодировке cp1251 (сохранить csv из Excel «CSV (разделители — запятые)») | 422 `UNSUPPORTED_FILE` |
| I6 | пользователем `ka_go` | 403 |
| I7 | полная выгрузка `docs/source/biq/ecp-export-lagal-entity.xlsx`, `dry_run=true` | 200 за несколько секунд; `rows` 8400, `offices_created` 53; есть коды `UNSUPPORTED_TYPE`, `INVALID_INN`, `DUPLICATE_IN_FILE` |
```bash
ADMIN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \
-d '{"email":"admin@armka.local"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN" \
-F file=@../fixtures/directories/ecp-export-russkiy-biskvit.xlsx -F dry_run=true | python3 -m json.tool | head -40
```
### 6.4 Аудит (БД)
```bash
docker compose -f ../docs/dev/docker-compose.yml exec postgres \
psql -U armka -d armka -c "select occurred_at, action, section, object_type, changed_field from dkb.audit_event order by occurred_at desc limit 20"
```
| № | После чего | Ожидание |
|---|---|---|
| L1 | A2, A5, A9 | записи `USER_CHANGE`, `USER_ROLE_CHANGE`, `ROLE_PERMISSION_CHANGE` с `before_value`/`after_value` |
| L2 | D1, D7 | `GROUP_CHANGE`; смена филиала ГК — `ASSIGNMENT_CHANGE` |
| L3 | D9, D12, D18 | `LEGAL_ENTITY_CHANGE`, членства — `MEMBERSHIP_CHANGE` |
| L4 | I2 | одна запись `DIRECTORY_IMPORT` с отчётом в `after_value`; после I1 (`dry_run`) записи нет |
| L5 | A6, D10 (отказы) | записей аудита нет |
### 6.5 Негатив и границы
| № | Шаги | Ожидание |
|---|---|---|
| N1 | запрос без заголовка `Authorization` | 401 |
| N2 | токен для несуществующего email (`nobody@armka.local`) через `POST /dev/token` | 404 `пользователь не найден или отключён`, токен не выдан |
| N3 | тело `PATCH` ГК с неизвестным полем | 422 (поля вне схемы запрещены) |
| N4 | импорт файла больше 20 МБ | 413 |
| N5 | `PUT /consolidations/versions/{id}/members` с 501 `legal_entity_ids` (можно повторить один id) | 422, тело — массив pydantic `detail[] {loc: ["body", "legal_entity_ids"], …}`, без `code` |
| N6 | `POST …/status` с `comment` длиннее 1000 символов | 422, массив pydantic с `loc: ["body", "comment"]` |
| N7 | `GET …/osv?q=<501 символ>` и `GET …/osv/export?q=<501 символ>` | 422, массив pydantic с `loc: ["query", "q"]`; 500 символов — 200 |
| N8 | `DELETE /directories/legal-entities/{id}/aliases/{alias_id}` для уже перенесённого алиаса | 404 `NOT_FOUND`, записи аудита об удалении нет |
### 6.6 Загрузка ОСВ и просмотр строк через API (Срез 2c)
Предусловия: раздел 4.2 (ядро с `NATS_ENABLED=true`, tabular-parser на `:8001`), файлы из `../fixtures/osv/`. Выполняйте на
свежем стенде (раздел 9) или с другим ЮЛ: дедуп и автозамена зависят от уже загруженного. Ожидаемое время от загрузки до
`PROCESSED` — до 15 секунд (опрос публикатора и цикла маппинга по 5 с, парсер — доли секунды).
Подготовка: пользователь `ka_go` (права `DOCUMENT_EDIT`, `DOCUMENT_VIEW`, видит все ЮЛ). ЮЛ должно называться как в шапке ОСВ —
`АО "Русский бисквит"`, ИНН `3528052718`. После `make seed-dev` оно уже есть («РУССКИЙ БИСКВИТ, АО» с алиасом под шапку):
`GET /directories/legal-entities?q=бисквит` — берите его `id`; создавать как в D9 нужно только на стенде без dev-ГК.
Для проверки «чужого филиала» понадобится ГК с филиалом (dev-ГК «Унагранде» — Головной офис, «Агрофирма Межениновская» —
Ростовский РФ).
```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 sys,json;print(json.load(sys.stdin)["access_token"])')
H="Authorization: Bearer $TOKEN"
# ЮЛ — только на стенде без dev-ГК (после make seed-dev оно есть: возьмите id из GET …/legal-entities?q=бисквит)
LE=$(curl -s -X POST localhost:8000/api/v1/directories/legal-entities -H "$H" -H 'Content-Type: application/json' \
-d '{"inn":"3528052718","entity_type":"ЮЛ","name":"АО \"Русский бисквит\""}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# загрузка (U1)
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "$H" -F 'files=@../fixtures/osv/rb/2024-FY/ОСВ_66 за 2024.xls'
FILE=<file_id из ответа>
# опрос карточки файла (U2): повторяйте до status = PROCESSED
curl -s "localhost:8000/api/v1/files/$FILE" -H "$H" | python3 -m json.tool
DOC=<documents[0].id из карточки>
curl -s "localhost:8000/api/v1/documents/$DOC" -H "$H" | python3 -m json.tool
curl -s "localhost:8000/api/v1/documents/$DOC/entries" -H "$H" | python3 -m json.tool
curl -s -X POST "localhost:8000/api/v1/documents/$DOC/confirm" -H "$H"
curl -s -X DELETE "localhost:8000/api/v1/files/$FILE" -H "$H" -w '%{http_code}\n'
```
| № | Пользователь | Шаги | Ожидание |
|---|---|---|---|
| U1 | ka_go | загрузить `ОСВ_66 за 2024.xls` для ЮЛ «Русский бисквит» | 201, `accepted` из одного элемента (`file_id`, `job_id`, `name`), `rejected` пуст; сразу после загрузки `GET /files/{id}` — `status` `UPLOADED` или `PROCESSING` |
| U2 | ka_go | опрашивать `GET /files/{file_id}` (раз в 2–3 с) | не позднее чем через ~15 с `status = PROCESSED`, `job.status = done`, `job.error_code` пуст; `documents` — один: `document_type = OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `status = RECOGNIZED`, `counterparty_check = MATCH`, `period_check = UNKNOWN` |
| U3 | ka_go | `GET /documents/{id}` | `locator = sheet:Лист_1`, `file_id` = файл из U1, `group_schema` с группировками листа, `warnings` пуст, `confirmed_by` пуст |
| U4 | ka_go | `GET /documents/{id}/entries` | `total = 9`; первая строка — `account_code = "66"`, `level = 0`, `parent_entry_id` пуст; строка «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» под `66.03` имеет `parent_entry_id` = id строки `66.03`; строки «Итого» нет; суммы — десятичные строки как в Excel (сальдо на конец счёта 66: кредит `54.64`) |
| U5 | ka_go | загрузить тот же файл ещё раз | 422, `rejected[0].code = DUPLICATE_FILE`, `rejected[0].file_id` — файл из U1; новых файлов нет |
| U6 | ka_go | загрузить `.docx` (или `.xlsx`, переименованный из `.xls`) | 422, `rejected[0].code = UNSUPPORTED_FILE` |
| U7 | ka_go | загрузить `ОСВ_67 за 2024.xls` и `.docx` одним запросом (`-F 'files=@…' -F 'files=@…'`) | 201, один в `accepted`, один в `rejected` с `UNSUPPORTED_FILE` |
| U8 | ka_go | `POST /documents/{id}/confirm` | 200, `status = CONFIRMED`, `confirmed_by`/`confirmed_at` заполнены; повторный вызов — 409 `ALREADY_CONFIRMED` |
| U9 | ka_go | `DELETE /files/{file_id}` файла с подтверждённым документом | 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, файл и строки на месте |
| U10 | ka_go | для того же ЮЛ загрузить `ОСВ_60 за 2024.xls` (rb) и дождаться `PROCESSED`; затем `../fixtures/osv/schastl/2024-FY/Оборотносальдовая ведомость по счету 60 за 2024 г..xlsx` | второй файл — `PROCESSED`, `version_no = 2` (если оба документа 60 / 2024 / Q4); первый — `SUPERSEDED`, `superseded_by_file_id` = второй; у документа второго файла `counterparty_check = MISMATCH`, `status = NEEDS_CHECK` (шапка — другая организация) |
| U11 | ka_go | `GET /legal-entities/{id}/files`, затем с `include_superseded=true` | в первом — только актуальные файлы; во втором — и заменённый `SUPERSEDED` |
| U12 | ka_go | `DELETE` второго файла из U10 (заменил первый) | 409 `FILE_SUPERSEDES_OTHERS`; `DELETE` первого (`SUPERSEDED`) — 204, далее `GET /files/{id}` — 404 |
| U13 | ka_go | `POST /documents/{id}/confirm` для документа первого файла из U10 (`SUPERSEDED`), до удаления в U12 | 409 `FILE_SUPERSEDED` |
| U14 | expert | `POST .../files` | 403 (нет `DOCUMENT_EDIT`); `GET /files/{id}` — 200 (есть `DOCUMENT_VIEW`) |
| U15 | client_manager | `GET /files/{id}` файла ЮЛ другого филиала | 404, тело как у случайного uuid |
| U16 | ka_go | остановить tabular-parser, загрузить новый файл | `status` остаётся `UPLOADED` (ack нет — в `PROCESSING` файл не переходит) ~2 мин (3 повтора по 30 с), затем `ERROR`, `job.error_code` заполнен; `DELETE` такого файла — 204 |
Аудит (6.4 — запрос из psql): после U1 — `FILE_UPLOAD`; U8 — `DOCUMENT_CONFIRM`; U10 — `FILE_REPLACE` с пустым `user_id`
(системное событие) и `description`, где перечислены документы заменённого файла; U12 — `FILE_DELETE` с карточкой файла
в `before_value`. Смены статусов обмена (`PROCESSING`, `PROCESSED`, `ERROR`) аудитом не фиксируются — так задумано.
### 6.7 Проверка парсинга ОСВ (xlsx/xls/xlsb) на произвольных файлах
Парсер (`tabular-parser`) не привязан к координатам: лист ОСВ, заголовок таблицы, колонки сумм, колонка ИНН и иерархия
строк ищутся по содержимому (`05-domain-rules.md` §1.0). Поэтому фикстуры РБ и Счастливочной — только примеры, и
основная задача тестирования — **файлы других форматов**: иные конфигурации 1С, другие учётные системы, файлы после
ручной правки бухгалтером. Два способа прогнать файл.
**Способ А — через стенд** (как в 6.6): загрузить файл на ЮЛ, дождаться `PROCESSED`, смотреть `GET /documents/{id}` и
`…/entries`. Так проверяется весь путь, включая маппинг в ядре.
**Способ Б — парсер без стенда** (быстрее, виден «сырой» результат парсера до ядра; нужен клон `tabular-parser` и
`make venv` в нём):
```bash
cd ../tabular-parser/api
ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "/путь/к/файлу.xlsx" > result.json; echo "exit=$?"
```
Код возврата: `0` — `done`, `1` — `needs_check` (контрольные суммы не сошлись, причины в `metrics.warnings`), `2` — ошибка
(`error.code`: `INVALID_DOCUMENT` — не найден лист ОСВ, `INVALID_FORMAT` — нет заголовка таблицы или обязательных групп
сумм, `UNREADABLE`, `UNSUPPORTED_FORMAT`, `TIMEOUT`), `3` — файл не найден. Для расширения не из `.xls/.xlsx/.xlsb`
добавьте `--file-type XLSX`. В `result.json` — то же, что ядро кладёт в `document`/`osv_entry`: `documents[].header`
(организация, ИНН, счёт, период, нарастающий итог, БУ/НУ, валюта), `group_schema`, `rows[]` (уровень, тип строки,
счёт, контрагент, ИНН, шесть сумм, `extra`), `checks`, `metrics.warnings`.
**Что сверять с Excel (чек-лист на каждый файл).** Откройте файл рядом с результатом и проверьте:
| № | Что | Ожидание |
|---|---|---|
| P1 | Лист | найден каждый лист с текстом «Оборотно-сальдовая ведомость» (один `documents[]` на лист, `locator = sheet:<имя>`); листы без ОСВ (справки) пропущены с `warnings` `skipped: …`, а не ошибка; листы форм ББ/ОФР — отдельные документы, см. «Формы ББ/ОФР» ниже |
| P2 | Шапка | `organization_name`, `organization_inn` (если есть в шапке), `account_code_raw`, `period_text` → `period_year`, `period_quarter`, `is_cumulative` соответствуют тексту над таблицей. Поле не найдено → `null` + предупреждение, не ошибка |
| P3 | Период | «1 квартал» → `Q1`, нарастающий; «2 квартал» → `Q2`, квартальный; «полугодие», «6 месяцев», «январь – июнь» → `Q2`, нарастающий; «9 месяцев», «3 квартал» → `Q3`; «2024 г.», «12 месяцев» → `Q4`, нарастающий; римские кварталы («I кв.») тоже. Диапазон месяцев: «Апрель – Июнь 2025 г.» → `Q2`, квартальный; **скользящий** «Октябрь 2024 г. – Сентябрь 2025 г.» → `period_year = 2025`, `period_quarter` и `is_cumulative` — `null`, в `warnings` «не с начала года» (ядро такой документ к версии не привяжет, OQ-131). Период отдельной ячейкой без «за» («9 месяцев 2025 г.» в строке «Бухгалтерский \| …») тоже распознаётся |
| P4 | Колонки сумм | шесть сумм разнесены верно: сальдо на начало Дт/Кт, обороты Дт/Кт, сальдо на конец Дт/Кт. Проверьте 2–3 строки с ненулевыми значениями во всех шести колонках и строку «Итого» |
| P5 | Иерархия | уровни строк (`level`, `parent_row_number`) повторяют дерево в Excel: счёт → субсчёт → контрагент → договор. Стратегия — в `warnings` `hierarchy=outline|indent|account_pattern|sums`; если `sums` — дерево восстановлено по равенству сумм, посмотрите внимательнее |
| P6 | Типы строк | `group_type`: `ACCOUNT` (код счёта), `COUNTERPARTY`, `CONTRACT`, `DEPARTMENT`, `OTHER`, `TOTAL` (строка «Итого»; в 1С бывает вторая — «Итого развернутое», обе `TOTAL` уровня 0, первая — основная). Строки «Вал.», «Кол.», «USD» → `data_type` `CURRENCY`/`QUANTITY`; «НУ» → `NU` |
| P7 | ИНН | если в таблице есть колонка ИНН (по заголовку или по содержимому, когда ≥ 80 % значений — 10/12 цифр), `counterparty_inn` заполнен у контрагентов и **не** заполнен у счетов и договоров; КПП и номера договоров за ИНН не принимаются. Без колонки ИНН: ИНН берётся из **хвоста названия** («Полиграф, 3528315710», «(ИНН 7700000000)», «ИНН: …») — `counterparty_name` без ИНН и разделителя, `row_value` как в файле; **строка только с ИНН над названием** склеивается со следующей строкой того же уровня и из `rows` исчезает (нумерация `row_number` с пропуском — это нормально); ячейка «ИНН/КПП» даёт ИНН. Приоритет: колонка → строка выше → хвост названия |
| P8 | Числа | суммы — строки с двумя знаками (`"1234567.89"`); текстовые числа «1 234 567,89», «(1 234,00)», «—» разобраны как число, отрицательное, `null`; пустая ячейка — `null`, не `0` |
| P9 | Контрольные суммы | `status = done`: сальдо строки сходится (начало + обороты = конец), «сумма детей = родитель», «Итого» = корню **или** сумме строк уровня 0 (несколько корней 58.01/58.03 без строки «58» — вариант 2, Новоросметалл/Агрокомплекс). `needs_check` при расхождении, в `checks` — какие строки; «подгонки» под сходимость быть не должно |
| P10 | Лишние колонки | «КПП», «Код», «Количество» и прочие попали в `extra` строки, суммы не сдвинулись |
| P11 | Шапка без меток | ОСВ без строки «по счету …» и с колонками «Счет: Код / Наименование» (ОСВ 63 Новоросметалл): счёт берётся из первой строки-счёта таблицы (в `warnings` об этом сказано), период и тип данных («Бухгалтерский») — из отдельных ячеек; строка сумм без подписи принимается за «Итого» с предупреждением |
**Реальные нестандартные файлы** — `../fixtures/osv-dkb/` (пакет ДКБ 06.10.2026: шесть организаций, многолистовые книги,
вариант 2 «сумма», скользящий период, 50 тыс. строк). Ожидаемый статус каждого листа — манифест
`tabular-parser/api/tests/osv_dkb_expectations.yaml` (данные с причиной: чего там нет, должно быть `done`); golden-снимки есть у 22 файлов
до 1 МиБ (`golden/expected_osv_parse/osv-dkb/`), три книги Агрокомплекса по 9–14 МБ проверяются только инвариантами.
`../fixtures/analysis-reports/` («Анализ счета») — ожидается `INVALID_DOCUMENT`. **Готовые кейсы синтетики** — `../fixtures/osv/synthetic/s01…s19` с описанием каждого файла и
ожиданием в `../fixtures/osv/synthetic/README.md`: шапка варианта 2 («Период: …», «Субконто»), уровни без outline (только
отступами, только по кодам счетов), строки валюты и количества, числа текстом, два листа ОСВ и посторонний лист, нет
сальдо на начало и свёрнутое сальдо на конец, колонка КПП, названия субсчетов, сломанная сумма (`needs_check`), повтор
заголовка внутри таблицы, лист ББ рядом с ОСВ, xlsb, синонимы заголовков, ИНН в названии / строкой выше / «ИНН/КПП»
(s17–s19). Прогоните их способом Б и сверьте с README.
**Формы ББ/ОФР (Срез 2b).** Та же команда CLI (способ Б) разбирает и формы: в `documents[]` лист формы — отдельный документ
(`document_type` = `BALANCE` / `INCOME` / `DISCLOSURE`, ОСВ идут первыми), с `header`, `columns[]`, `lines[]`, `checks`.
Что смотреть: `document_type` соответствует форме на листе; `columns[].period_date` — даты колонок слева направо (для ОФР —
конец периода); `checks.relations_failed` (жёсткие соотношения §7 с `expected`/`actual` по колонкам — причина `needs_check`),
`checks.soft_relations_failed` и `duplicate_line_codes`; в `metrics.warnings` — предупреждение об единицах («значений с
дробной частью при единице тыс. руб.: N из M»: значения как в файле, не нормализованы, OQ-138), «колонка кодов не найдена»,
«страница-продолжение без шапки». Ожидания по реальным файлам — `../tabular-parser/api/tests/statements_manifest.yaml` и
таблица «файл → статус» в README `tabular-parser`; файлы — `../fixtures/statements-xlsx/` (синтетика `st01…st07`).
Постраничные xls из 1С (страница 2 отдельным файлом, ББ только актив) и формы в рублях при шапке «тыс. руб.» дают
`needs_check` — это штатно (OQ-138, OQ-140), не баг; `done` ожидается только у форм, где §7 сходится (Межениновская).
**Как делать свои файлы.** Возьмите любую реальную ОСВ и меняйте по одному признаку, после каждого — прогон и чек-лист:
- сдвинуть таблицу: вставить 5–10 пустых строк и 2–3 колонки слева/сверху; перенести шапку организации ниже или выше;
- переименовать лист; добавить лист «Справка» перед листом ОСВ; положить две ОСВ по разным счетам на разные листы;
- убрать колонку ИНН совсем; вставить колонку «КПП» между ИНН и суммами; переставить блоки «Обороты» и «Сальдо на конец»;
- разбить заголовок таблицы на 1–4 строки с объединёнными ячейками; заменить «Сальдо на начало периода» на «Нач. сальдо»,
«Дебет/Кредит» на «Дт/Кт»;
- убрать группировку outline (скопировать значения в новую книгу) — уровни должны восстановиться по отступам, затем по кодам
счетов; убрать и отступы — по шаблону кодов и по суммам;
- суммы текстом с пробелами и запятой, отрицательные в скобках, прочерки вместо нулей;
- сохранить как `.xls`, `.xlsx`, `.xlsb` (xlsb — обязательно из Excel, см. ниже);
- сломать одну сумму на 1 рубль — ожидается `needs_check` с указанием строки, не `done`.
**Что не считать багом (известные границы):**
- Заголовок таблицы без групп «Обороты» или «Сальдо на конец» → `INVALID_FORMAT`: без них ОСВ не ОСВ. Отсутствовать может
только «Сальдо на начало».
- Заголовок с незнакомым синонимом («Нач. остаток») даёт `needs_check`, суммы уходят в `extra`. Это не ошибка кода, а
словарь: присылайте файл, синоним добавляется строкой в `rules/osv_headers.yaml`.
- ИНН внутри текста контрагента («Полиграф, 3528315710»), в ячейке над названием и склеенный «ИНН/КПП» парсер извлекает
(OQ-100, OQ-130, arm-tabular-parser#9): `counterparty_inn` заполнен, `counterparty_name` без ИНН, исходный текст — в `row_value`.
- ИНН в колонке и ИНН в хвосте названия различаются — берётся колонка, предупреждения нет; строка-ИНН над счётом или договором
(не контрагентом) склеивается, но её ИНН теряется молча (arm-tabular-parser#18, A9-2/A9-3).
- Счёт 91 (и, вероятно, 90/99): строки аналитики несут только обороты, сальдо закрыто на 91.09 → `needs_check` на каждой ОСВ 91
до решения OQ-132. Аналитика без сальдо на конец (магазины Агрокомплекса в 62/76) — тоже `needs_check` по данным файла.
- Скользящий период даёт `period_quarter = null` (см. P3) — это не ошибка парсера; что с ним делать, решает ядро (OQ-131).
- xlsb читается без outline level и форматирования: уровни только по отступам пробелами и кодам счетов. Настоящего xlsb из
Excel в фикстурах нет (OQ-102) — если у вас есть Excel, сохраните `s02_no_outline_indent.xlsx` как xlsb и прогоните.
- Строка «Итого» нужна: без неё `needs_check`.
- Файл больше 50 МиБ, распакованный xlsx больше 500 МиБ или разбор дольше 120 с → `UNSUPPORTED_FORMAT` / `TIMEOUT`.
- Парсер не знает ядра: признак «нарастающая» для ОСВ за Q2–Q4 ядро перепроверяет по данным Q1 (6.6, `is_cumulative`), ОСВ за
Q1 ядро считает и нарастающей, и квартальной (OQ-124).
Баг парсера — это: суммы в не тех колонках, потерянные или задвоенные строки, неверный уровень при наличии outline или
отступов, `done` при реально не сходящихся суммах, падение (`RECOGNITION_FAILED`) на любом файле. К багу прикладывайте
файл (или его обезличенную копию) и `result.json`.
### 6.8 Распознавание PDF: scan-gateway и эмулятор ИС Сканирование (Срез 3a)
PDF и изображения обрабатывает `scan-gateway`; ядро с Среза 3b маппит формы из PDF (`statementDocument`) в документы так же, как из Excel
(раздел 6.9), но на стенде это требует профиля `services` с эмулятором, поэтому здесь сервис проверяется отдельно. Сервис и нормализатор проверяются отдельно: поднимите профиль `services`
(`scan-gateway` и `scan-emulator` в `docs/dev/docker-compose.yml`) и пройдите сценарий из README `scan-gateway` («Полный круг против compose и эмулятора»:
файл в MinIO → `recognition.image.request` в NATS → ack и итоговый конверт).
Что загружать на стенде: **PDF из `fixtures/ocr` — `aep_3.pdf` и `aep_6.pdf`**. Эмулятор узнаёт их **по содержимому**
(sha256), а не по имени: шлюз кладёт файл в горячую папку как `<uuid>.<ext>`, и имя фикстуры в нём не сохраняется.
Файл без пары по содержимому получает ответ-ошибку `INVALID_DOCUMENT` («нет пары по содержимому»), если не задан
`SCAN_EMULATOR_DEFAULT` (в compose по умолчанию пуст); при заданном `SCAN_EMULATOR_DEFAULT` такой файл получает набор по
умолчанию. Исключение — PDF из `fixtures/ocr`, для которых нет JSON-набора (`aep_4`, `aep_7`, `aep_9`, `aep_30`–`aep_34`):
они совпадают по хешу, но «нет пары» и **остаются `INVALID_DOCUMENT` даже при заданном default** (OQ-151).
| Файл | Ожидание |
|---|---|
| `aep_3.pdf` | `needs_check`, два документа (`BALANCE`, `INCOME`). В `metrics.warnings` (по эталону `fixtures/ocr/expected_normalize/aep_3.json`): файл — `ИНН распознан неуверенно`; таблица 0 — `шапка синтезирована: колонки без дат`, `строк с нечисловым кодом пропущено: 8`, `строк без кода с суммами пропущено: 2`, повторы кодов 1320/1350/1360/1370; таблица 1 — шапка синтезирована, пропущено 2 и 2; строка `<job_id>_ofr.json: INVALID_DOCUMENT: …` (второй файл набора — ответ-ошибка, штатно; понижает статус до `needs_check`) |
| `aep_6.pdf` | `needs_check`, два документа. Предупреждения: файл — `ИНН не распознан`, `дата отчёта не разобрана`; таблица 0 — пропущено 2 (нечисловой код) и 19 (без кода с суммами), `редакция fsbu4 не заполнена` (форма 2025 года, `fsbu4` пока пуста — OQ-150), повторы кодов 1220/1520; таблица 1 — пропущено 1 и 2, `fsbu4` не заполнена, повтор кода 2410 (OQ-152) |
| `aep_5.PDF`, `aep_8.pdf` | `error`, `INVALID_DOCUMENT` с сообщением ИС (в фикстурах лежат ответы-ошибки) |
| любой другой файл | `error`, `INVALID_DOCUMENT` эмулятора («нет пары по содержимому»), если не задан `SCAN_EMULATOR_DEFAULT`; PDF без JSON-набора — всегда (OQ-151) |
Что смотреть: `raw/<job_id>.json` (склейка всех файлов набора) и `results/<file_id>/<job_id>/result.json` в MinIO; подписи
колонок разобраны в даты, коды строк, суммы со скобками (минус), `is_recognized`, `string_value` только у неразобранных сумм.
`done` на реальных сканах почти не бывает — это ожидаемо. В dev-compose у шлюза `SCAN_CLEANUP=true`: после ответа
`Output/<job_id>/` и входящий файл удаляются. Что не считать багом: строки с мусорным или пустым кодом не попадают в документ, но есть предупреждения
`строк с нечисловым кодом пропущено` / `строк без кода с суммами пропущено` (OQ-149); при повторе кода остаётся первая
строка (OQ-152); минус не поставлен при `}` вместо `)` (OQ-147); `is_recognized = false` у прочерка «—» (OQ-148); сумма
с тремя знаками после запятой (`1,234`) — `amount = null`, текст в `string_value`.
### 6.9 Формы ББ/ОФР: просмотр, правка, акцепт, подтверждение (Срез 3b)
Предусловия: раздел 4.2 (ядро с `NATS_ENABLED=true`, tabular-parser на `:8001`), `make seed-dev` (dev-ГК «Агрофирма Межениновская»,
ЮЛ с ИНН `7016006635`, Ростовский РФ — ИНН совпадает с ИНН шапок форм), пользователь `ka_go` (`DOCUMENT_EDIT`, `DOCUMENT_VIEW`,
видит всё) и для негатива `expert` (только `DOCUMENT_VIEW`), `client_manager` (чужой филиал). Файлы — книги Excel
`../fixtures/statements-xlsx/mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` (ББ, 3 колонки: 30.09.2025, 31.12.2024, 31.12.2023, 37 строк
с кодом) и `1_Форма2_бух_09.xlsx` (ОФР, 2 колонки). Подробные схемы и коды ошибок — `dev/api-contract-slice-3b.md`; правила —
`context/05-domain-rules.md` §7, §9.3, §9.5. Новый ЮЛ с чужим ИНН для сценария «не совпал контрагент» — создайте через API
справочников (раздел 6.2). Ожидаемое время от загрузки до `PROCESSED` — до 15 секунд.
```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 sys,json;print(json.load(sys.stdin)["access_token"])')
H="Authorization: Bearer $TOKEN"
LE=$(curl -s "localhost:8000/api/v1/directories/legal-entities?q=7016006635" -H "$H" | python3 -c 'import sys,json;print(json.load(sys.stdin)["items"][0]["id"])')
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files" -H "$H" \
-F 'files=@../fixtures/statements-xlsx/mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx'
FILE=<file_id из ответа>
curl -s "localhost:8000/api/v1/files/$FILE" -H "$H" | python3 -m json.tool # опрос до status = PROCESSED
DOC=<documents[0].id из карточки>
curl -s "localhost:8000/api/v1/documents/$DOC/statement" -H "$H" | python3 -m json.tool
```
| № | Пользователь | Шаги | Ожидание |
|---|---|---|---|
| F1 | ka_go | загрузить `1_Форма1_бух_09.xlsx` для ЮЛ с ИНН 7016006635, дождаться `PROCESSED` | один документ: `document_type = BALANCE`, `osv_account_code` пуст, `period_year = 2025`, `period_quarter = Q3`, `counterparty_check = MATCH`, `comments_count = 0` |
| F2 | ka_go | `GET /documents/{id}/statement` | 200; `edition = fsbu4`; `document.reporting_date = 2025-09-30`; `columns` — 3 (у колонки 0 `is_current = true`); `lines` — 37, отсортированы по `line_code`, у каждой `values` на все колонки; суммы — строки; `header.inn.value = "7016006635"`, `confidence = "100.00"`, `modified = false`; `checks.failed`, `soft_failed` пусты, `accepted = null` |
| F3 | ka_go | `PATCH …/statement` `{"values":[{"line_id":…,"column_id":…,"amount":"<сумма строки 1600, колонки 0, увеличенная на 100>"}]}` (id — из F2) | 200; у ячейки `amount` новая, `original_amount` = прежняя, `is_modified = true`, `modified_by` = пользователь; `checks.failed` непуст (нарушено `1600 = 1100 + 1200` и/или `1600 = 1700`), `document.status = NEEDS_CHECK`; в `audit_event` одно `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type = document`) с `before`/`after` ячейки (6.4) |
| F4 | ka_go | `POST …/confirm` | 409 `CHECKS_NOT_PASSED`, `detail.failures[]` — нарушения (`column_index`, `relation`, `expected`, `actual`) |
| F5 | ka_go | `POST …/statement/checks/accept` `{"comment":"округление в форме клиента"}` | 200; `checks.accepted = {by, at, comment}`, `checks.failed` по-прежнему непуст; статус документа пересчитан (`RECOGNIZED`, если других причин `NEEDS_CHECK` нет); `POST …/confirm` теперь проходит (но F11 делайте после возврата значений) |
| F6 | ka_go | снова `PATCH` той же ячейки другой суммой | 200; `checks.accepted = null` (акцепт сброшен правкой) |
| F7 | ka_go | `PATCH` той же ячейки обратно на исходную сумму (`original_amount` из F3) | 200; `is_modified = false`, `original_amount` остаётся, `checks.failed` пуст; `PATCH` с пустым телом `{}` — 200, новых записей `RECOGNIZED_DATA_EDIT` нет |
| F8 | ka_go | `PATCH` `{"header":{"inn":"123"}}`; `{"values":[{"line_id":"<случайный uuid>","column_id":"<случайный uuid>","amount":"1"}]}` | 422 `INVALID_INN`; 422 `UNKNOWN_CELL`; данные не изменились |
| F9 | ka_go | `PATCH` `{"header":{"report_date":"2025-06-30"}}`, затем вернуть `"2025-09-30"` | период пересчитывается: `period_quarter = Q2` (`reporting_date = 2025-06-30`), `header.report_date.modified = true`, `original_value` не меняется (после возврата на `2025-09-30` — `Q3`, `modified` остаётся `true`); дата не на конец квартала (`2025-09-15`) — 200, `period_quarter = null`, статус `NEEDS_CHECK`, `POST …/confirm` — 409 `PERIOD_REQUIRED` (поправьте дату — подтверждение проходит) |
| F10 | ka_go | `POST …/accept` без нарушений | 409 `NOTHING_TO_ACCEPT` |
| F11 | ka_go | `POST …/confirm` для чистой формы (после F7/F9 вернуть исходные значения) | 200 `DocumentOut`, `status = CONFIRMED`, `confirmed_by`/`confirmed_at`; повтор — 409 `ALREADY_CONFIRMED`; `PATCH`/`accept` — 409 `DOCUMENT_READONLY`; в БД у шапки `reviewed_by/at` заполнены |
| F12 | ka_go | загрузить `1_Форма2_бух_09.xlsx` тому же ЮЛ, дождаться `PROCESSED`; `GET …/statement` | `document_type = INCOME`, `period_quarter = Q3`, 2 колонки, `edition = fsbu4`; у ББ и ОФР поле `checks.cross_form` — либо `null`, либо нарушение `1370 − 1370(начало года) = 2400` (мягкое, не блокирует подтверждение); статус ОФР — `RECOGNIZED` |
| F13 | ka_go | `GET /files/{file_id}/content?disposition=inline` и без параметра | 200, тело байт в байт равно загруженному `.xlsx`, `Content-Type` xlsx; `inline` — `Content-Disposition: inline; filename="file.xlsx"; filename*=UTF-8''<имя>` (кириллица в percent-кодировке); `disposition=foo` — 422 |
| F14 | ka_go | `POST /documents/{id}/comments` `{"text":"нет подписи руководителя"}`; `{"text":" "}`; затем `GET …/comments` и `GET /files/{file_id}` | 201 с `author.full_name`; пустой после обрезки текст — 422; в списке один комментарий; в карточке файла и `GET /documents/{id}` у документа `comments_count = 1`; комментарий к `CONFIRMED` документу разрешён |
| F15 | ka_go | `GET /users/brief?ids=<uploaded_by файла>,<случайный uuid>`; с 201 id; с `ids=abc` | 200, один элемент `{id, full_name, is_active}`; 422 `TOO_MANY_IDS`; 422 `INVALID_IDS`; то же под `expert` (без `USERS_ADMIN`) — 200 |
| F16 | ka_go | `GET /directories/legal-entities?q=7016006635` после F1 и F12 | `documents_count = 2` (ББ и ОФР) |
| F17 | ka_go | в ГК «Агрофирма Межениновская» завести второе ЮЛ и ГСК из двух ЮЛ (раздел 7, «Подготовка»), создать версию за 2025 Q3; подтвердить ББ и ОФР (F11, F12); `GET …/checklist` и `GET …/checklist?format=csv` | ячейки «Бух. баланс» и «ОФР» ЮЛ — `CONFIRMED`; CSV: файл начинается с BOM (`EF BB BF`), первая строка «Организация;ИНН;Бух. баланс;ОФР;ОСВ …», разделитель `;`, у ЮЛ «Загружен»; `Content-Disposition` с `filename*=UTF-8''checklist-<ГК>-2025-Q3.csv` |
| F18 | expert | `PATCH …/statement`, `POST …/comments`, `POST …/accept` | 403 (нет `DOCUMENT_EDIT`); `GET …/statement` и `GET …/comments` — 200 |
| F19 | client_manager | `GET …/statement`, `GET /files/{id}/content` документа ЮЛ другого филиала | 404 `NOT_FOUND`, тело как у случайного uuid |
| F20 | ka_go | `GET …/statement` документа `OSV` (раздел 6.6) | 409 `DOCUMENT_NOT_STATEMENT` |
| F21 | ka_go | загрузить второй файл с формой ББ за тот же период (книга Form1, пересохранённая в Excel — другой sha256); `PATCH` по документу первого файла | старый файл `SUPERSEDED`; `PATCH`/`accept` по его документу — 409 `FILE_SUPERSEDED`; чтение `GET …/statement` и комментарии работают |
Что не считать багом: формы из OCR (PDF) почти всегда `NEEDS_CHECK` — уверенность значений меньше 100, ИНН распознан неуверенно
(`aep_3.pdf`, `aep_6.pdf`, раздел 6.8), аналитик правит значения и подтверждает; у форм без колонки кодов документ создаётся без
строк (OQ-139); повтор одной формы в одном файле — вторая не создаётся, первая `NEEDS_CHECK`; страницы одной формы из разных файлов
не склеиваются (OQ-140); строк без кода в ответе нет (OQ-36); скачивание файла и CSV в журнал не пишутся (OQ-153); формулы §7 одни
для редакций `66n` и `fsbu4` (OQ-146); уже смапленные до Среза 3b файлы форм не получают — загрузите файл заново (раздел 9 — сброс
стенда).
## 7. Консолидация через API (Срез 4)
Предусловия: ядро запущено (раздел 4.2); для сценариев с файлами — ещё `NATS_ENABLED=true` и tabular-parser на `:8001`
(раздел 6.6); данные — в `../fixtures/osv/`. Токены: `ka_go` (`CONSOLIDATION_VIEW/EDIT`, видит всё), `ka_rf`
(`CONSOLIDATION_VIEW/EDIT`, только ГК Ростовского РФ), `expert` (только `CONSOLIDATION_VIEW`), `client_manager`
(`CONSOLIDATION_VIEW`, Краснодарский РФ), `admin` (прав на консолидации нет). Подробные схемы запросов и ответов, коды ошибок и
нюансы для фронта — `dev/api-contract-slice-4.md`; Swagger — `/docs`.
**Подготовка.** Нужна ГК с ГСК не менее чем из двух ЮЛ (ГК — в Ростовском РФ, чтобы её видел `ka_rf`): создайте через API
справочников (раздел 6.2; ГСК — `POST /directories/company-groups/{id}/related-groups`, участники — `POST
/directories/related-groups/{id}/members`) либо импортом ЕЦП (I2). Для данных РБ: ЮЛ «АО "Русский бисквит"» (ИНН 3528052718) и
«ООО "Пекарня Счастливочная"» (в ГСК обе). Ниже `V` — id версии, `G` — id ГК, `S` — id ГСК.
```bash
curl -s -X POST localhost:8000/api/v1/consolidations -H "$H" -H 'Content-Type: application/json' \
-d '{"company_group_id":"'$G'","related_group_id":"'$S'","year":2024,"quarter":"Q3"}'
# 201: VersionCard; V = ответ.id, status = INITIATED, reporting_date = 2024-10-01
curl -s -X POST localhost:8000/api/v1/consolidations/versions/$V/lock -H "$H" # 201 LockOut
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE/files?consolidation_version_id=$V" -H "$H" -F 'files=@…'
curl -s localhost:8000/api/v1/consolidations/versions/$V/checklist -H "$H"
curl -s "localhost:8000/api/v1/consolidations/versions/$V/osv?limit=50" -H "$H"
curl -s localhost:8000/api/v1/consolidations/versions/$V/osv/summary -H "$H"
curl -s -o svod.xlsx -D - "localhost:8000/api/v1/consolidations/versions/$V/osv/export" -H "$H" # Content-Disposition
```
| № | Пользователь | Шаги | Ожидание |
|---|---|---|---|
| K1 | ka_go | создать консолидацию ГК/ГСК за 2024 Q3 (команда выше) | 201, `status = INITIATED`, `version_no = 1`, `scenario = CREATE`, `approach = BASIC`, `goodwill_enabled = false`, `members` = текущие члены ГСК, `lock = null`; повтор — 409 `CONSOLIDATION_EXISTS` |
| K2 | ka_go | то же с `year = 1999`; с ГСК другой ГК; с ГСК из одного ЮЛ; с чужим/несуществующим id ГК | 422 (валидация года); 422 `RELATED_GROUP_MISMATCH`; 422 `PERIMETER_TOO_SMALL`; 404 `NOT_FOUND` |
| K3 | ka_go | `GET /company-groups/{G}/consolidations`, затем с `?in_work_only=true`; `GET …/versions/{V}` | период с версией; карточка с `members[]` (у каждого `checklist[]`), `history[0].from_status = null`; `ka_rf` для ГК вне своего филиала — 404 |
| K4 | ka_go | `POST …/lock`; повтор | 201 `LockOut` (`locked_by`, `locked_by_name`); повтор — 200, блокировка та же |
| K5 | ka_rf (ГК его филиала) | `POST …/lock` на версию, занятую `ka_go` | 409 `LOCKED_BY_OTHER` (`locked_by`, `locked_at`, `locked_by_name`); с телом `{"takeover_reason":"…"}` — 201, `taken_over_from` = id `ka_go`, `ka_go` теряет блокировку; в аудите `CONSOLIDATION_LOCK_TAKEOVER` |
| K6 | ka_go | после K5 `PUT …/members` | 409 `LOCKED_BY_OTHER` (чужая блокировка), без блокировки вообще — 409 `LOCK_REQUIRED` |
| K7 | владелец блокировки | `PUT …/members` с составом ≤ 1 ЮЛ, с ЮЛ другой ГК, с тем же составом | 422 `PERIMETER_TOO_SMALL`; 422 `ENTITY_NOT_IN_GROUP` (`legal_entity_ids`); 200 без изменений и без события `MEMBERSHIP_CHANGE` |
| K8 | владелец блокировки | загрузить ОСВ ЮЛ периметра с `?consolidation_version_id=V` (раздел 6.6) | 201; версия `INITIATED` → `DOCUMENTS_PROCESSING`; ЮЛ вне периметра — 422 `ENTITY_NOT_IN_PERIMETER`; несуществующая версия — 404 |
| K9 | ka_go | после `PROCESSED` и `NEEDS_CHECK` документа — `GET …/versions/{V}`; затем `POST /documents/{id}/confirm` | `NEEDS_CHECK` → версия `DATA_CORRECTION` (если файл загружал пользователь); подтверждение переводит `INITIATED`/`DOCUMENTS_PROCESSING` → `DATA_CORRECTION`; документ привязан к версии (виден в чек-листе: ячейка `CONFIRMED`) |
| K10 | ka_go | `GET …/checklist` до и после подтверждения; для квартальных ОСВ — только Q3 | ячейка ОСВ: `MISSING` → `NEEDS_CHECK`/`RECOGNIZED` → `CONFIRMED`; при неполных кварталах — `missing_quarters`; ОСВ по субсчёту (58.03) не закрывает «ОСВ 58» |
| K11 | ka_go | `GET …/osv`, `…/osv/summary` по данным РБ (раздел «Данные для K11») | строки свода по парам, где покрыты все кварталы `Q1..Qn`; пары без квартала — в `gaps[]` с `missing_quarters`; деньги — строки с двумя знаками; `unmapped_total` > 0 |
| K12 | ka_go | фильтры: `legal_entity_id`, `account_root=60`, `counterparty_type=EXTERNAL`, `unmapped=true`, `match_kind=MANUAL&match_kind=PROPAGATED`, `q=бисквит`, `limit=2001` | отфильтрованные страницы с `total`; `limit=2001` — 422 |
| K13 | ka_go | `GET …/counterparty-candidates?q=счастл&entry_id=<entry_ids[0]>`; `scope=all&q=ab` | список ЮЛ с `counterparty_type`; `recommended` — только при точном совпадении нормализованного имени; `scope=all` с `q` короче 3 — 422 `QUERY_TOO_SHORT` |
| K14 | владелец блокировки | `POST …/osv/entries/{entry_id}/counterparty {legal_entity_id}` для строки «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» | 200: `row.match_kind = MANUAL` (слать **последнюю** `entry_id` строки свода), `propagated` ≥ 0, `alias_created = true`; в сводке `unmapped_total` уменьшился, у других строк с тем же именем — `PROPAGATED`; в аудите `OSV_COUNTERPARTY_MAP` и `LEGAL_ENTITY_CHANGE` |
| K15 | владелец блокировки | сопоставить другое имя с алиасом, уже закреплённым за другим ЮЛ; затем с `"move_alias": true` | 409 `ALIAS_TAKEN` (`owners[]`); с `move_alias` — 200, `alias_moved_from` = прежний владелец |
| K16 | ka_go | `entry_id` строки `NU`/`ACCOUNT` или чужой версии | 404 `NOT_FOUND`; без блокировки — 409 `LOCK_REQUIRED` |
| K17 | ka_go | `GET …/osv/export` (в т. ч. с фильтрами) | 200, `Content-Disposition: attachment; filename="svod-osv.xlsx"; filename*=UTF-8''…`; файл открывается в Excel: лист «Сводная ОСВ» (суммы — числа, формат `#,##0.00`, число строк как в API) и лист «Пробелы» (от фильтров не зависит); в аудите `EXPORT` |
| K18 | владелец блокировки | `POST …/status {"to_status":"CANCELLED"}` без комментария; с комментарием | 422 `COMMENT_REQUIRED`; 200, `status = CANCELLED`, блокировка снята (`lock = null`), `history` пополнена |
| K19 | ka_go | после K18: `PUT …/members`, `POST …/lock`, `POST …/status`, сопоставление | 409 `VERSION_NOT_EDITABLE`; чтение (`GET …/osv`, экспорт, чек-лист) — 200 |
| K20 | владелец блокировки | `POST …/status {"to_status":"INITIATED"}` из `DATA_CORRECTION`; затем `{"to_status":"DATA_CORRECTION"}` и `{"to_status":"VERIFIED"}` | 200, привязки документов сохраняются; ручные `DATA_CORRECTION`/`VERIFIED` — 409 `TRANSITION_NOT_ALLOWED` (`from_status`, `to_status`) |
| K21 | expert, client_manager | `GET …/osv`, `…/checklist`; `POST …/lock`, `PUT …/members` | чтение — 200 (client_manager — только ГК своего филиала, иначе 404); правки — 403 `недостаточно прав: требуется CONSOLIDATION_EDIT` |
| K22 | ka_go | заменить файл (загрузить исправленную ОСВ того же счёта и периода) после подтверждения документа | старый файл `SUPERSEDED`, привязка его документов к версии снята (`FILE_REPLACE.after_value.unbound_version_ids` содержит `V`); новый документ привязывается только после подтверждения |
Данные для K11 (РБ 2024, как в сквозном тесте `test_consolidation_fixtures`): ОСВ 60 и 62 РБ и Счастливочной, ОСВ 76
Счастливочной за Q1, Q2 и Q3 2024 (`../fixtures/osv/…`, подтвердить документы), плюс по одному документу за Q3 (РБ: 01, 58.03,
66, 67, 76; Счастливочная: 01, 02). Для версии Q3 пары с одним Q3 попадают в `gaps` (пропущены Q1–Q2); ОСВ за Q1 (нарастающая
по признаку парсера) подходит версии любого квартала (R-14).
Аудит (6.4): K1 — `CONSOLIDATION_CREATE`; K4 — `CONSOLIDATION_LOCK`; K5 — `CONSOLIDATION_LOCK_TAKEOVER`; K7 — `MEMBERSHIP_CHANGE`
(только при изменении состава); переходы статуса — `CONSOLIDATION_STATUS_CHANGE`; K14 — `OSV_COUNTERPARTY_MAP` и `LEGAL_ENTITY_CHANGE`;
K17 — `EXPORT`; во всех — `consolidation_version_id`. Блокировка не истекает по времени — освободить её можно только `DELETE …/lock`,
перехватом или аннулированием версии (OQ-119).
### 7.1 Полный прогон на данных РБ за 2024 (скрипт)
Для сводной ОСВ нужны все квартальные файлы двух ЮЛ. Их 22: РБ (`../fixtures/osv/rb/2024-Q{1,2,3}/`, 2 + 2 + 7) и
Счастливочная (`../fixtures/osv/schastl/2024-Q{1,2,3}/`, 3 + 3 + 5). Загружать по одному через Swagger долго, поэтому цикл
(`ka_go`, `$H` из 6.6; `LE_RB` и `LE_SCH` — id ЮЛ «АО "Русский бисквит"» и «ООО "Пекарня Счастливочная"», обе в одной ГСК
ГК из раздела 7; `V` — версия за 2024 Q3, созданная и взятая в работу по K1/K4 **до** загрузки — тогда переходы статусов
видны на K8/K9):
```bash
F=../fixtures/osv
for f in "$F"/rb/2024-Q1/* "$F"/rb/2024-Q2/* "$F"/rb/2024-Q3/*; do
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE_RB/files?consolidation_version_id=$V" -H "$H" -F "files=@$f" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["accepted"][0]["name"] if d["accepted"] else d["rejected"])'
done
for f in "$F"/schastl/2024-Q1/* "$F"/schastl/2024-Q2/* "$F"/schastl/2024-Q3/*; do
curl -s -X POST "localhost:8000/api/v1/legal-entities/$LE_SCH/files?consolidation_version_id=$V" -H "$H" -F "files=@$f" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["accepted"][0]["name"] if d["accepted"] else d["rejected"])'
done
# подождать ~30 с, затем убедиться, что все файлы PROCESSED, и подтвердить все документы
for LE in $LE_RB $LE_SCH; do
curl -s "localhost:8000/api/v1/legal-entities/$LE/files?limit=50" -H "$H" | python3 -c '
import sys,json
for f in json.load(sys.stdin)["items"]:
print(f["status"], f["original_name"], [ (d["id"], d["osv_account_code"], d["period_quarter"], d["status"]) for d in f["documents"]])'
done
# подтверждение: для каждого document id из вывода выше
for DOC in <id1> <id2> …; do curl -s -o /dev/null -w "%{http_code} $DOC\n" -X POST "localhost:8000/api/v1/documents/$DOC/confirm" -H "$H"; done
```
Ожидание после подтверждения: `GET …/versions/{V}` — `status = DATA_CORRECTION`; в чек-листе у РБ ОСВ 60 и 62 `CONFIRMED` с
тремя `document_ids`, у Счастливочной — 60, 62, 76; ОСВ 58 у РБ не `CONFIRMED` (есть только 58.03); остальные ячейки ОСВ
Q3-only — `missing_quarters = ["Q1","Q2"]`.
Если файл загружен **до** создания версии — не страшно: подтверждённые документы подхватываются при создании версии
(«подхват»), только переходы `DOCUMENTS_PROCESSING`/`DATA_CORRECTION` по загрузке тогда не увидеть.
### 7.2 Контрольные цифры сводной ОСВ (версия РБ 2024 Q3)
Значения ниже посчитаны вручную по файлам фикстур и совпадают со сквозным тестом `test_consolidation_fixtures`. Они
проверяют самое важное — **приведение квартальных ОСВ к 9 месяцам** (`05-domain-rules.md` §2): сальдо на начало берётся из
ОСВ за Q1, обороты складываются за Q1 + Q2 + Q3, сальдо на конец — из ОСВ за Q3.
`GET …/osv/summary`:
| Поле | Ожидание |
|---|---|
| `rows_total` | 1385 |
| `unmapped_total` | 1381 до ручного сопоставления: 4 строки Счастливочной с ИНН 3528052718 (`60.01`, `60.02`, `62.01`, `62.02`) сопоставлены автоматически |
| `documents_total` | 22 |
| `gaps` | ровно 7: РБ — `01`, `58.03`, `66`, `67`, `76`; Счастливочная — `01`, `02`; у всех `missing_quarters = ["Q1","Q2"]` |
Строки свода (`GET …/osv?legal_entity_id=…&q=…`), суммы в рублях:
| Компания, счёт, контрагент | Сальдо нач. Дт | Сальдо нач. Кт | Обороты Дт | Обороты Кт | Сальдо кон. Дт | Сальдо кон. Кт | Признак |
|---|---|---|---|---|---|---|---|
| Счастливочная, `60.01`, «Русский бисквит», ИНН 3528052718 | — | 4 454 151.93 | 128 058 143.65 | 125 950 450.77 | — | 2 346 459.05 | `GSK`, `match_kind = INN` |
| Счастливочная, `60.02`, «Русский бисквит» | 0 | 0 | 9 745 019.62 | 9 745 019.62 | 0 | 0 | `GSK`; контрагента нет в Q2 — квартал считается нулями |
| РБ, `60.01`, «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ», без ИНН | — | 38 806 758.00 | 307 986 284.00 | 375 490 642.46 | — | 106 311 116.46 | `EXTERNAL` до сопоставления, после K14 — `GSK` |
| Счастливочная, `62.01`, «Русский бисквит» | 40 211 938.00 | — | 398 018 012.46 | 330 517 504.00 | 107 712 446.46 | — | `GSK`; в каждом квартальном файле под `62.01` **две** строки с этим контрагентом — свод их суммирует, `entry_ids` = 6 |
| Счастливочная, `62.02`, «Русский бисквит» | — | 24 100 000.00 | 9 900 000.00 | 0 | — | 14 200 000.00 | `GSK` |
Как проверить любую строку самому: откройте три квартальных файла этого ЮЛ по этому счёту, найдите контрагента под тем же
субсчётом и сложите обороты по трём файлам; сальдо на начало сравните с первым файлом, на конец — с третьим. Тождество
«начало + обороты Кт − обороты Дт = конец» (для кредитового сальдо) должно сходиться: 4 454 151.93 + 125 950 450.77 −
128 058 143.65 = 2 346 459.05. Строка свода содержит `entry_ids` всех трёх исходных записей и `is_cumulative = false`;
`period = "2024 Q3"`.
Строка РБ по `60.01` зеркальна одной из двух позиций Счастливочной под `62.01` (в файлах: обороты 375 490 642.46 /
307 986 284.00, сальдо на конец 106 311 116.46) — внутригрупповые обороты сходятся копейка в копейку. В своде эта позиция
видна только в сумме с соседней (строка `62.01` выше); разнесение по позициям и сопоставление ВГО — Срез 5.
Что ещё проверить руками:
- контрагент, которого нет в одном из кварталов (как «Русский бисквит» на `60.02`): в своде одна строка, пропущенный квартал
дал нули, `entry_ids` — две записи;
- `GET …/osv?match_kind=INN` — только строки с ИНН в файле (у Счастливочной есть колонка ИНН, у РБ нет), `match_stored = true`;
- `GET …/osv?unmapped=true&legal_entity_id=$LE_RB&q=счастлив` — две строки (`60.01`, `62.…`) до сопоставления; после K14 —
ни одной, а `summary.unmapped_total` уменьшился ровно на число изменившихся строк свода (на этих данных — на 4, при
`propagated = 17` записей `osv_entry`: записи кварталов и пар-пробелов 66/76 считаются отдельно);
- пробелы не зависят от фильтров свода: `gaps` одинаковы при любом `GET …/osv/summary`.
### 7.3 Сверка экспорта с API и Excel
1. `GET …/osv/export` без фильтров → `svod.xlsx`; лист «Сводная ОСВ»: число строк без заголовка = `rows_total`, порядок — как
в API (ЮЛ по имени, счёт, номер строки). Суммы — числа с двумя знаками (не текст): выделите колонку «Обороты Дт» и
посмотрите сумму в строке состояния Excel; формат ячейки `#,##0.00`.
2. Найдите строку «Русский бисквит» `60.01` Счастливочной и сверьте шесть сумм с таблицей из 7.2 и с ответом `GET …/osv`.
3. Колонки «Сопоставлено с» и «Способ сопоставления»: для сопоставленных — имя ЮЛ из справочника и `INN`/`MANUAL`/`PROPAGATED`,
для несопоставленных — пусто.
4. Лист «Пробелы»: 7 строк, как в `summary.gaps`; с фильтром `?legal_entity_id=` в экспорте лист «Пробелы» всё равно полный.
5. Экспорт с `?unmapped=true` после полного сопоставления — лист «Сводная ОСВ» только с заголовком, лист «Пробелы» заполнен.
6. Текст контрагента, начинающийся с `=`, `+`, `-`, `@` (сделайте такой в своём файле), в Excel показывается как текст, а не
вычисляется как формула.
## 8. Известные ограничения (не заводить как баги)
- UI только для администрирования; справочники — через API.
- Вход без пароля, открытый `/docs`, CORS `*` — dev-режим.
- Email `*.local` через API/UI отклоняется, при этом dev-пользователи именно такие (созданы seed напрямую).
- Удалять алиасы и пустые ГСК/ГСЗ может только ADMIN (право `DIRECTORY_DELETE`).
- Граница «дата перевода не раньше сегодня» считается по UTC: до 03:00 МСК «сегодня» ещё вчера. Будет исправлено.
- Повторный `make seed-dev` возвращает права, снятые администратором в матрице.
- Профиль в шапке не перечитывается после правки собственных ролей.
- В консоли браузера сообщение `Store does not have a valid reducer` — каркас, не влияет.
- Таблица пользователей показывает первые 200.
- При импорте ИНН, записанный в Excel числом с ведущим нулём, теряет ноль и даёт `INVALID_INN`.
- Консолидация (Срез 4): только API; маршрут согласования (`CONSOLIDATION_RF` и далее), «Добавить версию», гудвилл, шахматка — следующие
срезы; формы ББ/ОФР в сводной ОСВ не участвуют (только в чек-листе); блокировка версии не истекает по времени; `period_check` документов остаётся `UNKNOWN`;
`PROCESSING` в чек-листе ставится по всем недостающим столбцам ЮЛ, пока файл не смаплен (OQ-119, OQ-120, OQ-122).
- Ручное сопоставление контрагента меняет строку ОСВ для всех версий, куда входит документ (OQ-123); рекомендация кандидата — только
при точном совпадении нормализованного имени (OQ-127).
- PDF-путь (Срез 3a): эмулятор отвечает только на PDF из `fixtures/ocr`, остальные —
`INVALID_DOCUMENT` (раздел 6.8, OQ-151); редакция `fsbu4` не заполнена (OQ-150).
- Парсер ОСВ: границы и «не баги» — в конце раздела 6.7 (счёт 91 — OQ-132; скользящий период — OQ-131).
- Удаление алиаса одновременно с его переносом ручным сопоставлением даёт 404 вместо 204 (arm-core #26) — так задумано.
- Код `60.АВ` (синтетика s09) и буквенные забалансовые вида `РВ.1` в план счетов не резолвятся (arm-core #28, C23-1/C23-2).
Полный список отложенных находок — Gitea: `RSHB/arm-core` issues «Follow-ups from #5», «Follow-ups from #18» (#21), #28;
`RSHB/arm-tabular-parser` #18.
## 9. Сброс стенда
```bash
docker compose -f ../docs/dev/docker-compose.yml down -v # БД и бакет с нуля
make up && docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio
make migrate && make seed-dev
```
Бэкенд и фронт перезапускать не нужно, но сессии в браузере лучше начать заново.
Файлы, загруженные в 6.6, удаляются из БД командой `DELETE /files/{id}`, но объекты в MinIO остаются (им ставится только
`expires_at`) — `down -v` стирает и их.
## 10. Как заводить баги
Gitea, репозиторий `RSHB/arm-core` (для документации — `RSHB/arm-ka-docs`). Один дефект — одна issue.
Шаблон:
```
Срез: 1 / Сценарий: D16 (номер из гайда, если есть)
Пользователь: ka_go@armka.local (роль KA_GO)
Шаги: 1. … 2. …
Ожидание: 409 MEMBER_OF_OTHER_GROUP
Факт: 500 (тело ответа ниже)
Запрос: метод, путь, тело (без токена)
Ответ: статус и тело
Лог бэкенда: строки с трейсбэком, если есть
Окружение: ОС, версии docker/node, коммит arm-core (`git rev-parse --short HEAD`)
```
Вопросы по ожидаемому поведению (не ясно, баг ли) — тоже issue, с пометкой «вопрос» в заголовке.
## 11. Типичные проблемы при запуске
| Симптом | Причина и решение |
|---|---|
| `make up` → порт занят | локальный PostgreSQL/MinIO; остановить или сменить `*_PORT` в `docs/dev/.env` и те же порты в `arm-core/api/.env` |
| `docker compose up --wait` без списка сервисов возвращает 1 | нормально из-за `minio-init`; используйте команду со списком из 4.1 |
| `make migrate` → connection refused | PostgreSQL ещё не `healthy`; подождать и повторить |
| `make venv` не находит Python 3.11 | `uv` скачает его сам при наличии интернета; проверьте `uv python list` |
| Фронт открылся, но после входа пусто или ошибки сети | забыты `REACT_APP_API_URL`/`REACT_APP_ROOT_PATH` при `npm run start` |
| Страница «Вход выполняется через РАИСА» | это production-сборка; запускайте `npm run start`, а не `build` |
| Импорт падает на записи файла в S3 | MinIO не поднят или не создан бакет: `docker compose logs minio-init` |
| `/healthcheck` показывает `nats: down` | NATS не поднят; на сценарии Среза 1 не влияет, но загрузка ОСВ (6.6) без NATS не дойдёт до парсера |
| Файл завис в `UPLOADED`/`PROCESSING` | не запущен tabular-parser (`curl localhost:8001/healthcheck`) или ядро запущено без `NATS_ENABLED=true`; без ack файл через ~2 мин станет `ERROR` |
Подробнее об инфраструктуре — `docs/dev/README.md`, о бэкенде — `arm-core/README.md`.