# Гайд тестировщика: локальный стенд АРМ КА (Срезы 0–2c, 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. Всё — через Swagger/curl, UI «Первичные документы» — подсрез 2d. - Консолидация через 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), ББ/ОФР, распознавания 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: . Сервер слушает только `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 ``` Откроется с формой «Вход для разработки». Две переменные окружения обязательны: без них фронт ищет 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= # опрос карточки файла (U2): повторяйте до status = PROCESSED curl -s "localhost:8000/api/v1/files/$FILE" -H "$H" | python3 -m json.tool DOC= 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. **Как делать свои файлы.** Возьмите любую реальную ОСВ и меняйте по одному признаку, после каждого — прогон и чек-лист: - сдвинуть таблицу: вставить 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`. - ИНН в колонке и ИНН в хвосте названия различаются — берётся колонка, предупреждения нет; строка-ИНН над счётом или договором (не контрагентом) склеивается, но её ИНН теряется молча (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`. ## 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=`; `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 …; 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). - Парсер ОСВ: границы и «не баги» — в конце раздела 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`.