arm-ka-docs/dev/frontend-api-guide.md
Andrey Epifancev d6c616a118 docs(dev): форма ЮЛ одним запросом в гайдах фронта и QA (arm-core #84)
POST/PATCH /directories/legal-entities принимают aliases, gsk_id, gsz_id
(arm-core PR #85): строка в «Что изменилось», §5.3, экраны [51-02]/[79],
«Что перечитать», каталог ошибок (WRONG_GROUP_KIND, ALIAS_EXISTS в форме);
в гайде тестировщика — проверки D25–D30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:57:43 +04:00

671 lines
93 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.

# Гайд фронтенд-разработчика: API arm-core
Состояние — `arm-core` на `main` от 08.10.2026 (коммит `d8bf5b2`: после PR #46 «API до сводной ОСВ», #51 «Основной РФ объектом»,
#52 «КПП ЮЛ», #53 «Схема БД настройкой», #59 «Алиас приоритетнее имени» и #60 «Предупреждение автозамены»).
Схема сверена с `GET /openapi.json` (62 операции на 49 путях), коды ошибок — с `api/src/services/*` и `arm-core/README.md` «Ошибки API».
Это **точка входа**: здесь всё, что нужно, чтобы писать клиент, — подключение, общие правила, каждый маршрут, ошибки, сценарии
экранов, известные ограничения и то, что изменится в ближайших PR. Подробные контракты по областям остаются источником деталей
и на них даны ссылки:
| Документ | Что там подробнее |
|---|---|
| [`frontend-auth-guide.md`](frontend-auth-guide.md) | вход, токен, `/me`, 401/403, эталонный клиент `web/src/{api,auth}` |
| [`api-contract-files-directories.md`](api-contract-files-directories.md) | файлы и документы ЮЛ, чтение справочников, сборка строки drawer [39] |
| [`api-contract-slice-3b.md`](api-contract-slice-3b.md) | формы ББ/ОФР, комментарии, исходный файл, `users/brief`, CSV чек-листа |
| [`api-contract-slice-4.md`](api-contract-slice-4.md) | консолидации: версии, блокировка, периметр, чек-лист, сводная ОСВ, сопоставление |
| `context/10-user-journey.md`, `mockup/INDEX.md` | пользовательский путь; номера экранов `[NN]` — снимки макета |
Если этот гайд и OpenAPI расходятся в **схемах и полях** — прав OpenAPI работающего стенда (`/openapi.json`), а расхождение —
повод для issue в `RSHB/arm-ka-docs`. Текстовые описания ошибок в OpenAPI местами неточны (например, «`VERSION_NOT_FOUND`» у
загрузки — на деле код `NOT_FOUND`; у загрузки 422 `ENTITY_NOT_IN_PERIMETER` приходит объектом `detail`, а не `UploadResult`):
коды ошибок сверяйте по §9.
---
## 1. Что изменилось за последние дни (05–09.10.2026)
Если клиент писался по более ранним контрактам, пройдитесь по этому списку.
| Когда, PR | Что изменилось в API | Что сделать в клиенте |
|---|---|---|
| 09.10, arm-core #85 (#84) | **Форма ЮЛ одним запросом** (макет [51-02], [79]): `POST` и `PATCH /directories/legal-entities` принимают `aliases: string[]` (1–500 символов, ≤ 100), `gsk_id`, `gsz_id` и применяют их в той же транзакции, что и поля ЮЛ (ошибка — откат всего). `POST`: алиасы и членства создаются вместе с ЮЛ. `PATCH`: не переданное поле не меняется; `aliases` — **итоговый набор** (сверка по `normalized_alias`: совпавшие не трогаются, лишние удаляются — нужно право `DIRECTORY_DELETE`, иначе 403 строкой; `aliases: null` — 422), `gsk_id`/`gsz_id` — итоговая группа (`null` — выйти, другая — сменить). Новый код 422 `WRONG_GROUP_KIND`; дубль в списке — 409 `ALIAS_EXISTS`. Ответ обоих — **`LegalEntityDetail`** (было `LegalEntityOut`; поля прежние + `memberships`, `aliases`) | «Добавить»/«Сохранить» в модалках ЮЛ — один запрос со всей формой; в `PATCH` слать `aliases` и группы целиком, как они на экране; после ответа брать карточку из него, без `GET`; удаление тега-алиаса без `DIRECTORY_DELETE` (`/me.permissions`) не предлагать — сервер ответит 403 |
| 08.10, arm-core #75 (#74) | **Отмена П-1** (решение владельца 08.10.2026): «несопоставленная» строка свода — нет ЮЛ после резолва **и** нет ИНН из файла. `unmapped_total`, `GET …/osv?unmapped=true` и экспорт с этим фильтром больше не включают строки с ИНН, которого нет в справочнике (`unmapped=false` — все остальные строки). Новое поле `ConsolidatedRowOut.counterparty_entity_inn` — ИНН сопоставленного ЮЛ справочника (П-8; `counterparty_inn` — по-прежнему ИНН из файла) | «Указать» — только у строк без `counterparty_id` и без `counterparty_inn`; у строки, сопоставленной без ИНН в файле, показывать `counterparty_entity_inn` с возможностью пересопоставить (макет [32-osv-no-inn], `osv-inn-mapped-button`; повторный `POST …/counterparty` разрешён) |
| 08.10, arm-core #60 (#58) | **Предупреждение автозамены** (OQ-154): если новый файл заменил прежний целиком, а части документов прежнего в нём нет (книга «ББ + ОФР» → отдельный ОФР), в `job.mapping_warnings` **нового** файла приходит одна строка на заменённый файл: «автозамена: файл «…» (версия N) заменён этим файлом целиком, но в новом файле нет документов прежнего: …; привязки сняты с версий консолидации: …» — с пометкой `CONFIRMED` у подтверждённых документов. Схемы ответов не менялись; поведение замены прежнее | показать предупреждение в карточке нового файла заметно (не только в общем списке подсказок): аналитик должен догрузить выпавшие документы; текст не разбирать |
| 08.10, arm-core #59 (#57) | **Алиас приоритетнее имени** (OQ-128): при ЮЛ-тёзке по нормализованному имени строка с совпавшим алиасом теперь сопоставляется с владельцем алиаса (`match_kind = ALIAS`; в своде — `match_stored = false`, если найдено на лету). После ручного сопоставления `unmapped_total` может уменьшиться больше чем на 1 + `propagated` | после ручного сопоставления перечитывать и страницу, и `…/osv/summary` (как и раньше), не вычислять «Осталось N» на клиенте |
| 07.10, arm-core #52 (#45) | **КПП ЮЛ** (DBML v5.17, `context/05-domain-rules.md` §13.5): поле `kpp: string \| null` в `LegalEntityCreate`, `LegalEntityUpdate`, `LegalEntityOut`/`Detail`, `LegalEntityHistoryOut`. Формат — 9 символов (4 цифры, 2 цифры или латинские A–Z, 3 цифры; пробелы убираются, буквы — в верхний регистр), иначе 422 `INVALID_KPP`; у ИП КПП запрещён (422), у ЮЛ необязателен; уникален только ИНН. Поиск `q` в списке ЮЛ ищет и по КПП; смена типа на ИП у ЮЛ с КПП без очистки КПП — 422 `INVALID_KPP`; `kpp: null` в `PATCH` очищает КПП; в импорте ЕЦП — необязательная колонка «КПП» и код строки `INVALID_KPP` | поле КПП в формах ЮЛ и в карточке; в форме ИП поле скрыть; ошибку `INVALID_KPP` — на поле «КПП» |
| 07.10, arm-core #53 (#38) | Схема БД задаётся настройкой `DB_SCHEMA` — **API не изменился** | — |
| 07.10, arm-core #51 (#47) | «Основной РФ» приходит **объектом** `{id, name, is_head_office}` или `null`: `regional_office` в `LegalEntityOut`/`Detail`/`HistoryOut`, `base_regional_office` в `CompanyGroupOut`/`HistoryOut` (в т. ч. в ответах `POST`/`PATCH`/`move`/`withdraw`/`archive`). Поля `*_id` остались; неактивный филиал тоже отдаётся; входные схемы прежние (`*_id`) | брать имя филиала из объекта, а не склеивать с `GET /directories/regional-offices` |
| 07.10, arm-core #50 (#43), #49 (#44) | Зависимости под контур (PyJWT 2.8) и настройки из OpenBao — **API не изменился** | — |
| 07.10, arm-core #46 (#42) | `POST /consolidations` принимает необязательный `legal_entity_ids` (≤ 500): выбранный в модалке [42] периметр. Без поля или `null` — состав ГСК; `[]` — явный пустой периметр → 422 `PERIMETER_TOO_SMALL`; ЮЛ не из ГК → 422 `ENTITY_NOT_IN_GROUP` | модалка [42]: все ЮЛ ГК, отмечены члены ГСК, отправлять отмеченные (§6, строка [42]) |
| 07.10, #46 | Ячейка чек-листа `ChecklistCellOut` стала **самоописывающей**: `document_type` и `account` своего правила | искать ячейку по `document_type`/`account`, а не по индексу |
| 07.10, #46 | `VersionCard.members[]` (`MemberOut`): новые `entity_type` (`"ЮЛ"`/`"ИП"`/`null`) и `osv_accounts[]` — счета ОСВ, загруженные к периоду версии | колонки «Тип» и «ОСВ» панели «Организации» [34] |
| 07.10, решения владельца П-1/П-2/П-3 | ~~«Осталось N» / «Показать ЮЛ без ИНН» = **все** несопоставленные строки (`unmapped_total`), а не только без ИНН~~ (П-1 отменён 08.10, см. arm-core #75); ГСК в версии **не хранится**; «К отчётности» доступна в любом статусе | баннер — по `unmapped_total`; колонку «ГСК» на [34] не заполнять; кнопку «К отчётности» не прятать по статусу |
| 07.10, решение владельца | Фильтра «файлы ЮЛ в контексте версии» нет и не будет | drawer [39] показывает все файлы ЮЛ; подходящесть — по `document_ids` ячеек чек-листа |
| 07.10, arm-core #39 (Срез 3b) | Новые маршруты: `GET/PATCH /documents/{id}/statement`, `POST …/statement/checks/accept`, `GET/POST /documents/{id}/comments`, `GET /files/{id}/content`, `GET /users/brief`, `GET …/checklist?format=csv` | экран [41], комментарии, просмотр первички, имена пользователей, «Скачать чек-лист» |
| 07.10, #39 | `POST /documents/{id}/confirm` для ББ/ОФР — новые 409 `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` (`detail.failures[]`) | обработать оба кода на «Подтвердить данные» |
| 07.10, #39 | Новые поля: `comments_count` у документа (в `FileOut.documents[]` и `DocumentOut`), `documents_count` у ЮЛ | бейджи комментариев, колонка «Документы» |
| 07.10, #39 (фиксы ревью) | `PATCH …/statement`: `header.inn: null` → 422 `INVALID_INN` (ИНН не очищается); `header.units` — непустая строка ≤ 50, пробельная → 422; `DELETE /files/{id}` удаляет и формы ББ/ОФР, и комментарии документов файла; `GET /files/{id}/content` отдаёт `nosniff`, `Cache-Control: private, no-store`, при `inline` — `Content-Security-Policy: sandbox` | не слать `inn: null`; после удаления файла не открывать его документы; проверить просмотрщик PDF под CSP sandbox |
| 06.10, arm-core #26 | Верхние границы входов: `legal_entity_ids` ≤ 500 (создание и `PUT …/members`), `StatusIn.comment` ≤ 1000, `q` у сводной ОСВ и экспорта ≤ 500 → иначе 422 **pydantic-массивом** (без `code`). Удаление алиаса, который в этот момент переносится сопоставлением, — 404 `NOT_FOUND` | ограничить ввод теми же числами; на 404 удаления алиаса — перечитать алиасы |
| 06.10, arm-core #30 | В `openapi.json` есть схема `bearerAuth` — в Swagger (`/docs`) работает кнопка **Authorize** | токен вставлять без слова `Bearer` |
| 06.10, arm-core #19 + фиксы R-14/R-14a | Срез 4 целиком; ОСВ за Q1 подходит версии **любого** квартала года, ББ/ОФР за Q1 — **только** версии Q1 | не вычислять подходящесть на клиенте — брать из чек-листа |
| 06.10, arm-core #32, #25 | `make seed-dev` создаёт dev-ГК под документы `fixtures` («Русский Бисквит» и три ГК пакета ДКБ); план счетов 1С — 488 счетов (больше строк ОСВ с `account_id`) | экраны не пустые сразу после сида |
| 05.10, arm-core #10 | В проде (`DEBUG=false`) нет `/docs` и `/openapi.json`, CORS — только `CORS_ORIGINS`; неверный метод — 405 с `Allow`; даты «не раньше сегодня» — по Москве | генерировать типы со стенда разработки |
| docs #51 | Задание распознавания бывает `timeout` и `dlq`; поле multipart — `files` (повторяется), не `files[]`; `MemberOut.entity_type` nullable | учесть в разборе статусов файла и в форме загрузки |
На клиенте нужно обойти одно ограничение сервера: кросс-доменно не читается `Content-Disposition` скачиваемых файлов (§7, §10 п. 9).
---
## 2. Подключение
| Что | Значение |
|---|---|
| База API | `${REACT_APP_API_URL}/api/v1`; в разработке `REACT_APP_API_URL=http://localhost:8000`, `REACT_APP_ROOT_PATH=/` (обе обязательны при `npm run start`) |
| Аутентификация | заголовок `Authorization: Bearer <JWT>` на каждом запросе к `/api/v1/*`; cookie и сессий нет |
| Токен в разработке | `POST /api/v1/dev/token {"email": "ka_rf@armka.local"}` → `{access_token, token_type}`, HS256 на 8 часов; работает только при `DEBUG=true` и `AUTH_DEV_ISSUER=true` |
| Токен в проде | SSO РАИСА; параметры неизвестны (OQ-54) — держите слой «получить токен» отдельно от слоя «использовать токен» |
| Dev-пользователи | `admin@`, `ka_go@`, `curator_go@`, `expert@`, `ka_rf@`, `rka@`, `client_manager@armka.local` — по одному на роль (таблица — `frontend-auth-guide.md` §3.1) |
| Схема и Swagger | `/openapi.json`, `/docs`, `/redoc` — **только** при `DEBUG=true`; в проде 404 |
| Живость | `GET /healthcheck` (без префикса и без токена) → `{"uptime": "<время старта процесса, ISO>", "version": "1.0.0", "nats": "ok" \| "down" \| "off"}` — это не признак «вошёл» |
| CORS | при `DEBUG=true` — `*` без credentials; в проде — источники из `CORS_ORIGINS` |
| Стенд целиком | `qa-guide.md` (docker compose, сиды, запуск бэка и фронта) |
**Типы из схемы.** Эталонный клиент `arm-core/web/src/api/types.ts` пишет типы руками и покрывает только `/me` и `/admin`.
Для остальных экранов удобнее генерировать типы со стенда разработки, например `npx openapi-typescript
http://localhost:8000/openapi.json -o src/api/schema.d.ts` (инструмент — на ваш выбор; это рекомендация, а не соглашение
репозитория). Схемы в OpenAPI названы так же, как в этом гайде (`VersionCard`, `FileOut`, …).
---
## 3. Общие правила
### 3.1 Порядок проверок на сервере
Гарантированно: **право (403) раньше видимости (404) и раньше проверки полей тела (422 массивом)**, а видимость объекта из пути
(404) — раньше проверок состояния и блокировки (409). Поэтому по 404 нельзя понять, «нет объекта» или «нет доступа к нему».
Исключения и вариации:
- Синтаксически битый JSON или multipart разбирается **до** проверки токена и права: такой запрос получает 422 (или 400 у
multipart) даже без токена.
- Доменные 422 и 409 идут в разном порядке на разных маршрутах. Например, `POST /directories/legal-entities`: `INVALID_INN`
(422) → ГК не видна (404) → `INN_EXISTS`/`CRM_ID_EXISTS` (409) → `OFFICE_*` (422); `POST /consolidations`: 422 периметра и ГСК →
409 `CONSOLIDATION_EXISTS`; ручное сопоставление: блокировка (409) → строка или ЮЛ не найдены (404). Не стройте логику на
порядке — обрабатывайте каждый код независимо.
### 3.2 Три формы тела ошибки
Тело ошибки всегда `{"detail": …}`, но `detail` бывает трёх видов:
| Форма `detail` | Когда | Пример |
|---|---|---|
| **строка** | 401, 403, 404 несуществующего маршрута, 405, 413, 409 нарушения целостности БД, ошибки `POST /dev/token` | `{"detail": "недостаточно прав: требуется CONSOLIDATION_EDIT"}` |
| **объект** `{code, message, …}` | доменные ошибки 404/409/422/503 | `{"detail": {"code": "LOCKED_BY_OTHER", "message": "…", "locked_by": "…", "locked_at": "…"}}` |
| **массив** `[{loc, msg, type, …}]` | 422 валидации тела, query и path (pydantic) | `{"detail": [{"loc": ["body", "comment"], "msg": "…", "type": "string_too_long"}]}` |
И **одно исключение**: `POST /legal-entities/{id}/files`, когда не принят ни один файл, отвечает 422 телом `UploadResult`
`{accepted: [], rejected: […]}` — **без** `detail` (§5.5).
```ts
type DomainDetail = { code: string; message: string; [extra: string]: unknown };
type ValidationItem = { loc: (string | number)[]; msg: string; type: string };
function parseError(status: number, body: unknown): { status: number; code: string; message: string; extra?: DomainDetail; fields?: ValidationItem[] } {
const detail = (body as { detail?: unknown } | null)?.detail;
if (typeof detail === 'string') return { status, code: `HTTP_${status}`, message: detail };
if (Array.isArray(detail)) return { status, code: 'VALIDATION', message: 'Проверьте поля формы', fields: detail as ValidationItem[] };
if (detail && typeof detail === 'object' && 'code' in detail) {
const d = detail as DomainDetail;
return { status, code: d.code, message: d.message, extra: d };
}
return { status, code: `HTTP_${status}`, message: 'Неизвестная ошибка' }; // в т. ч. 422 UploadResult — разбирайте отдельно
}
```
Правила обработки:
- **Ветвиться только по `code`** (и HTTP-статусу), никогда по тексту `message`: тексты по-русски и могут меняться.
- Доп. поля доменной ошибки — в том же объекте (`locked_by`, `owners`, `failures`, `legal_entity_ids`, `missing`, `roles`,
`permissions`, `related_group_id`, `legal_entity_id`, `from_status`, `to_status`). Какие у какого кода — §9.
- 401 — глобально, одним обработчиком в HTTP-клиенте: сбросить токен, показать вход.
- 403 строкой бывает двух видов: «недостаточно прав: требуется <КОД>» и «пользователь не зарегистрирован в АРМ / учётная
запись отключена / неоднозначная учётная запись» (токен валиден, человека нет в АРМ). Различать — `frontend-auth-guide.md` §3.3.
- 422 массивом — показывать как ошибки полей формы: `loc` последним элементом называет поле; если `loc` = `["body"]` — ошибка
всего тела (например, «поле entity_type нельзя обнулить»), текст — в `msg`.
### 3.3 Видимость
Пользователь головного офиса (`GET /me → regional_office.is_head_office = true`) видит всё; пользователь филиала — ГК своего
филиала, их ЮЛ, ГСК/ГСЗ, версии консолидации и монозаёмщиков своего филиала. Списки уже отфильтрованы сервером. Объект вне
видимости по id — **404 `NOT_FOUND`** с тем же телом, что у несуществующего. Клиент правила видимости не дублирует.
Исключение: кандидаты сопоставления `GET …/counterparty-candidates?scope=all` ищут по **всему** справочнику без фильтра
видимости (контрагент может быть ЮЛ чужого филиала).
### 3.4 Типы значений
| Что | Как приходит | Как работать |
|---|---|---|
| Деньги (`numeric(18,2)`) | **строка** с двумя знаками: `"1234.50"`, `"0.00"`, или `null` | не парсить во `float`; для сумм — decimal-библиотека (`decimal.js`, `big.js`) или работа со строкой; форматировать для показа отдельно |
| Деньги в запросе (`PATCH …/statement`, `amount`) | число или строка, не более 2 знаков после точки и 16 цифр до; `null` — очистить | слать **строкой** (`"1234.50"`), чтобы не терять точность |
| `confidence` | строка `"0.00"`…`"100.00"` или `null` | подсветка неуверенных: `< 100` |
| Идентификаторы | uuid-строки | — |
| Дата | `"2025-09-30"` (ISO) | — |
| Дата-время | ISO 8601 с поясом, обычно UTC (`"2026-10-07T12:34:56.789000Z"`) | показывать в часовом поясе пользователя (МСК) |
| Квартал | `"Q1"`…`"Q4"` | подписи «1 кв.» и т. п. — на клиенте |
| Период строки свода | `"2024 Q3"` | — |
| Перечисления | строки, регистр как в §3.7 (у `RelatedGroupKind` и `RecognitionStatus` — нижний) | — |
| `EntityType` | `"ЮЛ"` / `"ИП"` — **кириллица** | сравнивать с кириллическими литералами |
### 3.5 Страницы и лимиты
Страница — `{items, total, limit, offset}`; `offset ≥ 0`, больше предела — 422 массивом.
| Список | `limit` (по умолчанию / максимум) | Порядок |
|---|---|---|
| ГК, ЮЛ, пользователи администратора, файлы ЮЛ | 50 / 200 | ГК и ЮЛ — по имени; пользователи — по ФИО; файлы — новые первыми |
| Строки ОСВ документа, сводная ОСВ версии | 500 / 2000 | строки документа — по `row_number`; свод — имя ЮЛ, счёт (натурально), номер строки |
**Простые массивы без пагинации:** филиалы, ГСК/ГСЗ одной ГК, история ГК/ЮЛ, комментарии, периоды консолидации ГК,
кандидаты (до 100), роли и права, `users/brief`.
Если нужен «весь список» (например, все ЮЛ ГК для модалки [42], а в ГК их может быть больше 200) — дочитывайте страницы по
`offset`, пока `offset + items.length < total`.
### 3.6 Запросы на изменение
- **PATCH — частичный**: поле, которого нет в теле, не меняется; поле с явным `null` — «очистить», если поле это допускает:
`crm_id` (ГК, ЮЛ), `created_date` (ГСК/ГСЗ), `employee_no`, `business_segment` (пользователь), `report_date` (шапка формы),
`base_regional_office_id` (ГК) и `regional_office_id` (ЮЛ). Филиал очищается `null` только у пользователя головного офиса: у
пользователя филиала `null` превращается в его собственный филиал.
- Явный `null` у обязательных полей — **422 массивом** с `loc: ["body"]`: `name` (ГК, ЮЛ, ГСК/ГСЗ), `entity_type` (ЮЛ),
`full_name`, `regional_office_id`, `is_active` (пользователь). У шапки формы `inn: null` — 422 `INVALID_INN`, а `units: null`
означает «не менять» (пробельная строка — 422). Не отправляйте форму целиком «как есть» — шлите только изменённые поля.
- **Лишние поля** запрещены (422 `extra_forbidden`) в телах создания и правки ГК, ЮЛ (в т. ч. `move`, `withdraw`), ГСК/ГСЗ и
их членов, пользователей, ролей и прав, правки формы, акцепта и комментариев. Игнорируются — в теле алиаса, `dev/token` и во
всех телах консолидаций. Не полагайтесь на второе.
- **PUT заменяет набор целиком**: `PUT …/members` (периметр версии), `PUT /admin/users/{id}/roles`, `PUT /admin/roles/{code}/permissions`.
- **Повтор того же состояния** — обычно 200 без изменений и без записи в журнал (PATCH теми же значениями, `archive` уже
архивной ГК, повторный `POST …/lock` своей блокировки).
- Каждая мутация пишет `audit_event`; фронту для этого ничего делать не нужно.
### 3.7 Перечисления
| Тип | Значения (подпись UI из макета, где она есть) |
|---|---|
| `ConsolidationStatus` | `INITIATED` «Инициация», `DOCUMENTS_PROCESSING` «Загрузка и распознавание документов», `DATA_CORRECTION` «Корректировка данных», `CONSOLIDATION_RF` «Консолидация РФ», `ADJUSTMENT`, `VERIFICATION_RF` «Верификация РФ», `CONSOLIDATION_GO` «Консолидация ГО», `VERIFICATION_GO`, `VERIFIED` «Верифицирован», `CANCELLED` «Отмена КФО», `REJECTED`, `ARCHIVED`. Сейчас достижимы только первые три и `CANCELLED`; «в работе» = первые три (`VersionCard.is_in_work`) |
| `ConsolidationScenario` / `ConsolidationApproach` | `CREATE`, `CONFIRM` / `BASIC`, `EXTENDED` (сейчас всегда `CREATE` и `BASIC`) |
| `CellStatus` (ячейка чек-листа) | `CONFIRMED` «Загружен», `NEEDS_CHECK` и `RECOGNIZED` «Требуется проверка», `PROCESSING` «Обработка», `ERROR` «Ошибка загрузки», `MISSING` «Отсутствует» |
| `FileStatus` | `UPLOADED`, `PROCESSING`, `PROCESSED`, `ERROR`, `SUPERSEDED` |
| `RecognitionStatus` (задание, `FileOut.job.status`) | `queued`, `published`, `processing`, `done`, `needs_check`, `error`, `timeout`, `dlq` |
| `DocumentType` | `BALANCE` (ББ), `INCOME` (ОФР), `OSV`, `DISCLOSURE` (расшифровка), `OTHER` |
| `DocumentStatus` | `RECOGNIZED`, `NEEDS_CHECK`, `CONFIRMED`, `ERROR` |
| `DocumentCheckResult` | `MATCH` ✓, `MISMATCH` ✗, `UNKNOWN` — |
| `CounterpartyType` | `GSK` «ГСК», `GK` «ГК», `EXTERNAL` «Внешний» |
| `CounterpartyMatchKind` | `INN`, `NAME`, `ALIAS` (автоматически), `MANUAL` (вручную), `PROPAGATED` (автоприменение по имени) |
| `EntityType` | `ЮЛ`, `ИП` |
| `RelatedGroupKind` | `gsk`, `gsz` |
| `LeHistoryEvent` | `CREATE`, `UPDATE`, `MOVE_TO_GROUP`, `CLOSE`, `REMOVE_FROM_GROUP` |
| `OsvGroupType` / `OsvDataType` | `ACCOUNT`, `DEPARTMENT`, `COUNTERPARTY`, `CONTRACT`, `OTHER` / `BU`, `NU`, `CURRENCY`, `QUANTITY`, `SUM` |
| `BusinessSegment` | `DKB`, `DMSB`, `DM` |
| `PermissionObject` / `PermissionAction` | `DIRECTORY`, `DOCUMENT`, `CONSOLIDATION`, `KFO`, `EX`, `EXPORT`, `USERS`, `AUDIT` / `VIEW`, `EDIT`, `APPROVE`, `VERIFY`, `DELETE`, `EXPORT`, `ADMIN` |
| `OnExisting` (импорт) | `skip`, `update` |
**Автоподтверждение документа (OQ-157).** После маппинга чистый документ сразу приходит `CONFIRMED` с `confirmed_by: null`: кнопку «Подтвердить» показывать неактивной; повторный `POST …/confirm` — 409 `ALREADY_CONFIRMED`. Удаление файла с таким документом возможно (204), кроме случаев, когда документ подтвердил пользователь или документ привязан к версии вне корректировки данных (не `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION`): тогда 409 `FILE_HAS_CONFIRMED_DOCUMENTS`.
---
## 4. Права
UI прячет элементы по `GET /me → permissions` (объединение прав всех ролей); код роли для ветвления **не используется**.
Сервер всё равно проверяет право и отвечает 403.
| Область | Чтение | Изменение | Особые |
|---|---|---|---|
| Справочники `/directories/*` | `DIRECTORY_VIEW` | `DIRECTORY_EDIT` | удалить ГСК/ГСЗ и алиас — `DIRECTORY_DELETE`; импорт ЕЦП — `DIRECTORY_ADMIN` |
| Файлы и документы `/legal-entities/{id}/files`, `/files/*`, `/documents/*` | `DOCUMENT_VIEW` | `DOCUMENT_EDIT` (загрузка, удаление, подтверждение, правка формы, акцепт, комментарий) | — |
| Консолидации `/consolidations*`, `/company-groups/{id}/consolidations` | `CONSOLIDATION_VIEW` (в т. ч. экспорт xlsx и CSV чек-листа) | `CONSOLIDATION_EDIT` (создание, блокировка, статус, периметр, сопоставление) | перенос чужого алиаса при сопоставлении (`move_alias`) права `DIRECTORY_DELETE` не требует |
| Администрирование `/admin/*` | `USERS_ADMIN` | `USERS_ADMIN` | без права — 403 до любых проверок тела |
| `/me`, `/users/brief` | любой вошедший | — | — |
Права `KFO_*`, `EX_VIEW`, `EXPORT`, `AUDIT_VIEW` есть в каталоге, но маршрутов под них пока нет. Полный каталог прав и ролей —
`frontend-auth-guide.md` §4.
---
## 5. Маршруты
Префикс `/api/v1` опущен. Колонка «Ошибки» — доменные коды (и 422 массивом там, где у маршрута есть явные границы); 401/403 и
404 `NOT_FOUND` у маршрутов с объектом в пути есть всегда и не повторяются. Описание схем — §5.8.
### 5.1 Профиль, пользователи, вход
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `POST /dev/token` | без токена; только dev | `{email}` | `{access_token, token_type: "bearer"}` | 404 строка «пользователь не найден или отключён», 409 строка «неоднозначная учётная запись»; в проде маршрута нет — 404 `{"detail":"Not Found"}` |
| `GET /me` | любой вошедший | — | `MeResponse {id, email, full_name, regional_office {id, name, is_head_office}, business_segment?, roles[], permissions[]}` | 401; 403 строкой «не зарегистрирован / отключена / неоднозначная» |
| `GET /users/brief?ids=<uuid>,<uuid>` | любой вошедший | `ids` через запятую, ≤ 200 | `UserBriefOut[] {id, full_name, is_active}` — только найденные (включая неактивных), по `id`, не в порядке запроса; `?ids=` (пусто) → `[]`; без параметра — 422 массивом | 422 `INVALID_IDS` (проверяется первым), 422 `TOO_MANY_IDS` (дубли считаются) |
`/users/brief` — способ показать имена вместо uuid (`uploaded_by`, `confirmed_by`, `modified_by`, `checks.accepted.by`,
`changed_by`, `history[].changed_by`) без права администратора. Собирайте id со всей страницы в один запрос и кэшируйте.
`locked_by_name` блокировки, `initiator.name` версии и `author.full_name` комментария уже приходят с именем.
### 5.2 Справочники: чтение
| Метод и путь | Query | Ответ |
|---|---|---|
| `GET /directories/regional-offices` | — | `RegionalOfficeOut[] {id, name, is_head_office, is_active}` — все, включая Головной офис, по имени |
| `GET /directories/company-groups` | `q` (подстрока `name`/`crm_id`/`code`), `is_archived`, `limit`, `offset` | `Page[CompanyGroupOut]` |
| `GET /directories/company-groups/{group_id}` | — | `CompanyGroupOut` |
| `GET /directories/company-groups/{group_id}/history` | — | `CompanyGroupHistoryOut[]` (прошлые снимки, новые первыми) |
| `GET /directories/company-groups/{company_group_id}/related-groups` | — | `RelatedGroupOut[]` — ГСК и ГСЗ ГК (`kind`), по имени, с `members_count` |
| `GET /directories/related-groups/{group_id}` | — | `RelatedGroupDetail` (+ `members[] {legal_entity_id, inn, name, valid_from}` — текущий состав) |
| `GET /directories/legal-entities` | `company_group_id`, `outside_group` (монозаёмщики), `inn` (точно), `q` (подстрока имени / ИНН / КПП), `closed`, `limit`, `offset` | `Page[LegalEntityOut]` |
| `GET /directories/legal-entities/{entity_id}` | — | `LegalEntityDetail` (+ `memberships[]` в ГСК/ГСЗ, `aliases[]`) |
| `GET /directories/legal-entities/{entity_id}/history` | — | `LegalEntityHistoryOut[]` (снимки с `event_type` и `effective_date`, новые первыми) |
Нюансы:
- «Основной РФ» приходит объектом: `base_regional_office {id, name, is_head_office}` у ГК, `regional_office` у ЮЛ и в снимках
истории (`null`, если филиала нет; неактивный филиал тоже отдаётся — имя нужно для истории). `*_id` рядом остаются.
`GET /directories/regional-offices` нужен только для выпадающих списков выбора филиала.
- `LegalEntityOut.outside_group` = нет ГК (монозаёмщик); `closed` = заполнен `close_date`. Оба вычисляются сервером.
- `documents_count` у ЮЛ — число **документов** (не файлов) его незаменённых файлов: файл с ББ и ОФР даёт 2.
- Членства ЮЛ в ГСК в `LegalEntityOut` нет: для колонки «ГСК» в списке берите `related-groups/{id}` каждой ГСК ГК (их немного)
или `LegalEntityDetail.memberships`.
- Поиска ГК по имени филиала нет.
### 5.3 Справочники: изменение
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `POST /directories/company-groups` | EDIT | `{name (1–500), crm_id?, base_regional_office_id?}` | 201 `CompanyGroupOut` (код `GK-NNNNNN` назначает сервер) | 422 `OFFICE_NOT_FOUND`, 422 `OFFICE_NOT_ALLOWED` (филиал не свой), 409 `CRM_ID_EXISTS` |
| `PATCH /directories/company-groups/{group_id}` | EDIT | `{name?, crm_id?, base_regional_office_id?}` (частично) | `CompanyGroupOut` | те же |
| `POST /directories/company-groups/{group_id}/archive` · `/unarchive` | EDIT | — | `CompanyGroupOut` (повтор — без изменений) | — |
| `POST /directories/legal-entities` | EDIT | `{inn, entity_type, name, kpp?, company_group_id?, regional_office_id?, crm_id?, aliases?, gsk_id?, gsz_id?}`; без `company_group_id` — монозаёмщик (тогда без ГСК/ГСЗ); `aliases` — строки 1–500, ≤ 100 | 201 `LegalEntityDetail` (с `aliases`, `memberships`) | 422 `INVALID_INN` (ЮЛ — 10 цифр, ИП — 12; пробелы внутри убираются), 422 `INVALID_KPP` (формат или КПП у ИП), 404 (ГК не видна), 409 `INN_EXISTS`, 409 `CRM_ID_EXISTS`, 422 `OFFICE_NOT_FOUND` / `OFFICE_NOT_ALLOWED` — в этом порядке; затем алиасы: 422 `EMPTY_ALIAS`, 409 `ALIAS_EXISTS` (повтор в списке), 409 `ALIAS_TAKEN` (`legal_entity_id`); затем ГСК, ГСЗ: 404 (группа не видна), 422 `WRONG_GROUP_KIND`, 422 `DIFFERENT_COMPANY_GROUP`. Любая ошибка — ничего не создано |
| `PATCH /directories/legal-entities/{entity_id}` | EDIT | `{name?, entity_type?, crm_id?, regional_office_id?, kpp?, aliases?, gsk_id?, gsz_id?}`; `kpp: null` очищает КПП; ИНН и ГК так не меняются; `aliases` — итоговый набор (совпавшие по `normalized_alias` остаются как были, лишние удаляются, новые добавляются); `gsk_id`/`gsz_id` — итоговая группа, `null` — выйти; не переданное поле не меняется | `LegalEntityDetail` | 422 `INVALID_INN` (ИНН не подходит новому типу), 422 `INVALID_KPP` (формат; КПП у ИП, в т. ч. смена типа на ИП без очистки КПП), 422 `OFFICE_*`, 409 `CRM_ID_EXISTS`; `name`/`entity_type`/`aliases: null` — 422 массивом; алиасы и группы — коды как у `POST`; удаление алиаса без `DIRECTORY_DELETE` — 403 строкой «недостаточно прав: требуется DIRECTORY_DELETE». Любая ошибка — ничего не изменено |
| `POST /directories/legal-entities/{entity_id}/move` | EDIT | `{company_group_id, effective_date}` (дата ≥ сегодня по Москве) | `LegalEntityOut`; текущие членства ГСК/ГСЗ закрываются | 422 `EFFECTIVE_DATE_IN_PAST`, 404 (ГК назначения не видна), 409 `LEGAL_ENTITY_CLOSED`, 409 `SAME_COMPANY_GROUP` |
| `POST /directories/legal-entities/{entity_id}/withdraw` | EDIT | `{event: "CLOSE" \| "REMOVE_FROM_GROUP", effective_date, comment?}` | `LegalEntityOut` | 422 `EFFECTIVE_DATE_IN_PAST`, 409 `ALREADY_CLOSED`, 409 `NOT_IN_COMPANY_GROUP` |
| `POST /directories/legal-entities/{entity_id}/aliases` | EDIT | `{alias (1–500)}` — как в ОСВ | 201 `AliasOut {id, alias, normalized_alias, created_at}` | 422 `EMPTY_ALIAS`, 409 `ALIAS_EXISTS`, 409 `ALIAS_TAKEN` (`legal_entity_id` владельца) |
| `DELETE /directories/legal-entities/{entity_id}/aliases/{alias_id}` | **DELETE** | — | 204 | 404, если алиас уже убран/перенесён — перечитать алиасы |
| `POST /directories/company-groups/{company_group_id}/related-groups` | EDIT | `{kind: "gsk" \| "gsz", name, created_date?}` | 201 `RelatedGroupOut` (код `GSK-`/`GSZ-NNNNNN`) | — |
| `PATCH /directories/related-groups/{group_id}` | EDIT | `{name?, created_date?}` (вид и ГК не меняются) | `RelatedGroupOut` | — |
| `DELETE /directories/related-groups/{group_id}` | **DELETE** | — | 204 | 409 `GROUP_NOT_EMPTY` (сначала исключите членов) |
| `POST /directories/related-groups/{group_id}/members` | EDIT | `{legal_entity_id}` | 201 `RelatedGroupMemberOut` | 422 `DIFFERENT_COMPANY_GROUP`, 409 `MEMBER_EXISTS`, 409 `MEMBER_OF_OTHER_GROUP` (`related_group_id` — где состоит) |
| `DELETE /directories/related-groups/{group_id}/members/{legal_entity_id}` | EDIT | — | 204 (членство закрывается, остаётся в истории) | — |
| `POST /directories/import` | **ADMIN** | multipart: `file` (xlsx/csv), `dry_run` (по умолчанию `true`), `on_existing` (`skip` по умолчанию / `update`) | `ImportReportOut {rows, groups_*, entities_*, memberships_created, offices_created, errors, lines[] {row, inn, code, message}}` | 413 строкой (> 20 МиБ), 422 `MISSING_COLUMNS` (`missing[]`), 422 `UNSUPPORTED_FILE` |
Импорт — всегда два шага: пробный прогон (`dry_run=true`, ничего не пишет, отчёт тот же), затем запись тем же файлом
(`dry_run=false`). Коды строк отчёта (`ImportCode`) и формат файла — `arm-core/README.md` «Импорт из ЕЦП CRM» и
`context/05-domain-rules.md` §13. Кнопку «Импорт» показывать только с `DIRECTORY_ADMIN`.
При гонке двух одинаковых запросов 409 приходит с тем же кодом, но **без доп. полей** (`legal_entity_id`, `related_group_id`;
у `LOCKED_BY_OTHER` гонки взятия блокировки — без `locked_by`/`locked_at`) — не рассчитывайте, что доп. поля есть всегда.
### 5.4 Администрирование (`USERS_ADMIN`)
| Метод и путь | Запрос | Ответ | Ошибки |
|---|---|---|---|
| `GET /admin/users` | `q` (email/ФИО), `is_active`, `limit`, `offset` | `Page[UserOut]` (по ФИО; `regional_office` — объект целиком, `roles[]` — коды) | — |
| `POST /admin/users` | `{email, full_name, regional_office_id, employee_no?, business_segment?, roles? (по умолчанию [])}` | 201 `UserOut` | 409 `EMAIL_EXISTS` (без учёта регистра), 422 `UNKNOWN_ROLE` (`roles`), 422 `OFFICE_NOT_FOUND`; email на `*.local` — 422 массивом (валидатор email) |
| `PATCH /admin/users/{user_id}` | `{full_name?, employee_no?, regional_office_id?, business_segment?, is_active?}` | `UserOut` | 409 `LAST_ADMIN`, 422 `OFFICE_NOT_FOUND` |
| `PUT /admin/users/{user_id}/roles` | `{roles[]}` — набор целиком | `UserOut` | 422 `UNKNOWN_ROLE`, 409 `LAST_ADMIN` |
| `GET /admin/roles` | — | `RoleOut[] {code, name, description?, is_system, is_active, permissions[]}` | — |
| `GET /admin/permissions` | — | `PermissionOut[] {code, object_type, action, name, description?}` | — |
| `PUT /admin/roles/{code}/permissions` | `{permissions[]}` — набор целиком | `RoleOut` | 404 (роли нет), 422 `UNKNOWN_PERMISSION` (`permissions`), 409 `LAST_ADMIN` |
Профиль `/me` после смены своих ролей не перечитывается сам — перезагрузка страницы. Форма создания пользователя берёт филиалы
из `GET /directories/regional-offices` (нужно `DIRECTORY_VIEW`). Эталонная реализация экрана — `arm-core/web/src/widgets/Admin/`.
### 5.5 Файлы и документы
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `POST /legal-entities/{legal_entity_id}/files` | EDIT | multipart, поле **`files`** повторяется на каждый файл (не `files[]`); query `consolidation_version_id?` | 201 `UploadResult {accepted[] {file_id, job_id, name}, rejected[] {name, code, message, file_id?}}`, если принят ≥ 1 файл; **422 тем же телом**, если ни одного | 404 (ЮЛ или версия), 409 `VERSION_NOT_EDITABLE`, 422 `ENTITY_NOT_IN_PERIMETER` (объектом `detail`), 503 `STORAGE_UNAVAILABLE` |
| `GET /legal-entities/{legal_entity_id}/files` | VIEW | `limit`, `offset`, `include_superseded` (по умолчанию `false`) | `Page[FileOut]`, новые первыми | — |
| `GET /files/{file_id}` | VIEW | — | `FileOut` | — |
| `DELETE /files/{file_id}` | EDIT | — | 204 (удаляет документы, строки ОСВ, формы ББ/ОФР, комментарии, задания) | 409 `FILE_HAS_CONFIRMED_DOCUMENTS`, 409 `FILE_SUPERSEDES_OTHERS` |
| `GET /files/{file_id}/content` | VIEW | `disposition` = `attachment` (по умолчанию) / `inline` | поток байт; `Content-Type` по типу файла | 404 `FILE_CONTENT_NOT_FOUND` (в БД есть, в хранилище нет), 503 `STORAGE_UNAVAILABLE` |
| `GET /documents/{document_id}` | VIEW | — | `DocumentOut` | — |
| `GET /documents/{document_id}/entries` | VIEW | `limit` (≤ 2000, 500), `offset` | `Page[EntryOut]` по `row_number`; дерево — по `parent_entry_id` | — |
| `POST /documents/{document_id}/confirm` | EDIT | — | `DocumentOut` | 409 `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`; для ББ/ОФР — `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (`failures[]`) — именно в этом порядке, если совпало несколько |
| `GET /documents/{document_id}/comments` | VIEW | — | `CommentOut[] {id, text, author {id, full_name}, created_at}` по возрастанию | — |
| `POST /documents/{document_id}/comments` | EDIT | `{text}` (1–4000 после обрезки) | 201 `CommentOut` | 422 массивом |
Загрузка:
- Принимаются `xls`, `xlsx`, `xlsb`, `pdf`, `png`, `jpg` (и `jpeg`) до 50 МиБ на файл (по умолчанию, настройка `UPLOAD_MAX_BYTES`).
Тип и период **не спрашиваются** — их определяет парсер по содержимому.
- Коды `rejected[].code`: `UNSUPPORTED_FILE` (тип не из списка, сигнатура не совпала с расширением, пустое имя),
`FILE_TOO_LARGE`, `DUPLICATE_FILE` (тот же файл у ЮЛ уже есть — `file_id` указывает на него), `STORAGE_UNAVAILABLE`.
- Два разных тела 422: все файлы отклонены — `UploadResult` (смотрите `rejected[]`); ЮЛ вне периметра версии —
`{"detail": {"code": "ENTITY_NOT_IN_PERIMETER", …}}`. Различайте по наличию `detail`.
- С `consolidation_version_id` версия должна быть видима, «в работе» и содержать ЮЛ; первый принятый файл переводит версию
`INITIATED` → `DOCUMENTS_PROCESSING`. Блокировка версии для загрузки **не нужна**.
- Общий размер запроса приложение не ограничивает (лимит ставит ingress), число файлов — только предел Starlette: больше 1000
в одном запросе — 400 строкой; большие пачки лучше слать по
нескольку файлов.
Жизненный цикл файла и что показывать (подписи — как в чек-листе):
| `FileOut.status` | Что происходит | Строка в drawer [39] |
|---|---|---|
| `UPLOADED` | принят, ждёт отправки на распознавание | «Обработка», опрашивать |
| `PROCESSING` | распознаётся или маппится; `documents[]` ещё пуст | «Обработка», опрашивать |
| `PROCESSED` | документы созданы | статус — по документу: `NEEDS_CHECK` и `RECOGNIZED` (старые и формы после правки) «Требуется проверка», `CONFIRMED` «Загружен» (в т.ч. автоподтверждённый), `ERROR` «Ошибка» |
| `ERROR` | ошибка распознавания (`job.status` `error`/`timeout`/`dlq`, причина — `job.error_code`/`error_message`) или маппинга (`job.mapping_error`) | «Ошибка» + причина |
| `SUPERSEDED` | заменён более новым файлом с теми же документами (автозамена); в списке скрыт без `include_superseded=true` | — |
Опрос: перечитывайте `GET /legal-entities/{id}/files` (или `GET /files/{id}`), пока есть файлы `UPLOADED`/`PROCESSING`;
интервал 3–5 с. Предупреждения — `job.warnings` (сервис распознавания) и `job.mapping_warnings` (ядро), у документа —
`DocumentOut.warnings`; это подсказки, тексты не разбирать. Предупреждение автозамены (строка `job.mapping_warnings`,
начинается с «автозамена:») — исключение по важности: в нём документы, которые выпали из заменённого файла и сняты с
версий консолидации, — показывать его заметно.
Документ:
- Один файл = N документов: лист ОСВ по счёту — документ `OSV` (`osv_account_code`, `locator` — лист), форма ББ/ОФР — документ
`BALANCE`/`INCOME`, расшифровки — один документ `DISCLOSURE`/`OTHER` на тип (без данных; статус `RECOGNIZED` при маппинге, затем автоподтверждается → `CONFIRMED`).
- Период: `period_year` + `period_quarter` + `is_cumulative`. Квартальный — «2024 К1», нарастающий — «2024 · 9 мес.»;
`period_quarter = null` — «период не определён» (например, ОСВ со скользящим периодом) — не подставлять квартал самим.
- Проверки: `counterparty_check` — сверка шапки с ЮЛ-владельцем; `period_check` **всегда `UNKNOWN`** — показывать «—»
(OQ-120). Подходит ли документ версии — только по чек-листу версии.
- Подтверждённый документ необратим (отмены нет); исправление — загрузкой нового файла (автозамена).
- Строки ОСВ (`entries`) — только чтение; экрана правки ОСВ в макете нет.
### 5.6 Формы ББ/ОФР
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `GET /documents/{document_id}/statement` | VIEW | — | `StatementOut` | 409 `DOCUMENT_NOT_STATEMENT` (ОСВ, расшифровка, ББ/ОФР без данных формы) |
| `PATCH /documents/{document_id}/statement` | EDIT | `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` (до 5000 ячеек; для достроенной строки с `id = null` — `line_code`) | `StatementOut` — состояние после правки | 409 `FILE_SUPERSEDED`, `DOCUMENT_READONLY` (уже `CONFIRMED`), `DOCUMENT_NOT_CONFIRMABLE` (статус `ERROR`), `DOCUMENT_NOT_STATEMENT`; 422 `INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL`; 422 массивом |
| `POST /documents/{document_id}/statement/checks/accept` | EDIT | `{comment}` (1–2000) | `StatementOut` | 409 `NOTHING_TO_ACCEPT` и те же 409 |
Главное (подробно — `api-contract-slice-3b.md` §3–§5):
- `StatementOut = {document, edition, header, columns[], lines[], checks}`. У **каждой** пары «строка × колонка» есть `ValueOut`.
Ядро достраивает полный состав формы редакции (справочник `statement_lines.yaml`, arm-core#80): `lines[].origin` —
`source` (из источника) или `template` (достроена); у достроенных строк и ячеек `id = null`, в БД их нет. Для правки
передавайте `{line_id | line_code, column_id, amount}`: у существующей строки — `line_id`; у достроенной (`id = null`) —
`line_code`, первая непустая сумма создаёт строку и ячейку (`is_modified = true`). Очистка (`amount: null`) возвращает
созданную ячейку в пустую. `UNKNOWN_CELL` — и новые случаи: `line_code` уже есть в форме (передайте `line_id`), код вне
справочника редакции, у документа нет редакции (нет даты периода — строки не достраиваются; текст ошибки тот же, что для кода вне справочника). Пустые достроенные строки
не участвуют в проверках §7 и статусе.
- Подсветка: `confidence < 100`, `is_recognized = false`, `is_modified = true` (исходное — `original_amount`). Возврат ячейки
к исходному значению снимает `is_modified`.
- Правка даты отчёта пересчитывает `document.period_*`, `reporting_date` и `edition`; правка ИНН — `counterparty_check`;
любая правка пересчитывает статус и **сбрасывает акцепт**. Запрос без изменений — 200 без эффектов.
- `checks.failed` — жёсткие нарушения (блокируют подтверждение, пока не акцептованы), `soft_failed` — мягкие (в т. ч. «в
пределах округления»), `cross_form` — межформенная ББ↔ОФР (мягкая, `column_index = -1`), `accepted` — акцепт или `null`.
Акцепт **не очищает** `failed`: «нарушения есть и приняты» = `failed` непуст и `accepted` не `null`.
- Подтверждение блокируют только `PERIOD_REQUIRED` (поправить дату отчёта) и `CHECKS_NOT_PASSED` (показать `detail.failures[]`
или акцептовать). Не блокируют: мягкие и межформенная проверки, `counterparty_check = MISMATCH`, низкая уверенность. Формы из
OCR почти всегда `NEEDS_CHECK` — подтверждать из него можно.
- `cross_form` зависит от второго документа ЮЛ за тот же период: появилась ОФР отдельным файлом — перечитайте ББ.
- Документ формы появляется после того, как файл стал `PROCESSED`; до этого `…/statement` — 404.
### 5.7 Консолидации
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `POST /consolidations` | EDIT | `{company_group_id, related_group_id, year (2000–2100), quarter, legal_entity_ids? (≤ 500)}` | 201 `VersionCard` (версия №1, `INITIATED`, `lock = null`) | 404 (ГК/ГСК не видна), 409 `CONSOLIDATION_EXISTS`, 422 `RELATED_GROUP_MISMATCH` (ГСК чужой ГК или `gsz`), 422 `ENTITY_NOT_IN_GROUP` (`legal_entity_ids`), 422 `PERIMETER_TOO_SMALL` |
| `GET /company-groups/{group_id}/consolidations` | VIEW | `in_work_only` (по умолчанию `false`) | `ConsolidationPeriodOut[]` — периоды по убыванию, в каждом `versions[]` (`VersionBrief`) по убыванию номера | — |
| `GET /consolidations/versions/{version_id}` | VIEW | — | `VersionCard` | — |
| `POST /consolidations/versions/{version_id}/lock` | EDIT | тело необязательно: `{takeover_reason? (≤ 1000)}` | 201 `LockOut` (взята или перехвачена) / 200 `LockOut` (уже ваша) | 409 `VERSION_NOT_EDITABLE` (`CANCELLED`), 409 `LOCKED_BY_OTHER` (`locked_by`, `locked_by_name`, `locked_at`) |
| `DELETE /consolidations/versions/{version_id}/lock` | EDIT | — | 204 (и когда блокировки нет) | 409 `NOT_LOCK_OWNER` (`locked_by`, `locked_at`) |
| `POST /consolidations/versions/{version_id}/status` | EDIT | `{to_status, comment? (≤ 1000)}` | `VersionCard` | 409 `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`, `LOCKED_BY_OTHER`, `TRANSITION_NOT_ALLOWED` (`from_status`, `to_status`); 422 `COMMENT_REQUIRED` |
| `PUT /consolidations/versions/{version_id}/members` | EDIT | `{legal_entity_ids[] (≤ 500)}` — новый состав целиком | `VersionCard` | 409 `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`, `LOCKED_BY_OTHER`; 422 `ENTITY_NOT_IN_GROUP`, `PERIMETER_TOO_SMALL` |
| `GET /consolidations/versions/{version_id}/checklist` | VIEW | `format` = `json` (по умолчанию) / `csv` | `ChecklistOut {rules[], rows[]}` или файл CSV | — |
| `GET /consolidations/versions/{version_id}/osv` | VIEW | `limit` (≤ 2000, 500), `offset`, `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый: `?match_kind=NAME&match_kind=ALIAS`), `q` (≤ 500; имя **контрагента** — подстрока без регистра, ИНН контрагента — префикс) | `Page[ConsolidatedRowOut]` | — |
| `GET /consolidations/versions/{version_id}/osv/summary` | VIEW | — | `OsvSummaryOut {rows_total, unmapped_total, auto_matched_total, documents_total, gaps[]}` | — |
| `GET /consolidations/versions/{version_id}/osv/export` | VIEW | те же фильтры без `limit`/`offset` | файл xlsx: листы «Сводная ОСВ» и «Пробелы» | — |
| `GET /consolidations/versions/{version_id}/counterparty-candidates` | VIEW | `q` (≤ 500), `scope` = `group` (по умолчанию: периметр и ГК) / `all` (весь справочник), `entry_id` (для `recommended`) | `CandidateOut[]` (до 100; рекомендуемые первыми) | 422 `QUERY_TOO_SHORT` (`scope=all` и `q` < 3 символов) |
| `POST /consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty` | EDIT | `{legal_entity_id, move_alias? = false}` | `MapCounterpartyOut {row?, propagated, alias_created, alias_moved_from?}` | 409 `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`, `LOCKED_BY_OTHER`, `ALIAS_TAKEN` (`owners[] {legal_entity_id, name}`) |
Правила, без которых экран будет работать неправильно (полный список — `api-contract-slice-4.md` §4):
1. **Блокировка обязательна для правок версии** (`PUT …/members`, `POST …/status`, сопоставление): статус «в работе» **и** ваша
активная блокировка. Без неё — 409 `LOCK_REQUIRED`, чужая — 409 `LOCKED_BY_OTHER`. Создание версию **не** блокирует —
«Взять в работу» отдельным `POST …/lock`. Блокировка не истекает по времени; `CANCELLED` её снимает. Загрузка файлов и
подтверждение документов блокировкой не ограничены. Чтение — без блокировки и в любом статусе, включая `CANCELLED`.
2. **`LOCKED_BY_OTHER` из правок — без `locked_by_name`** (только `locked_by`, `locked_at`); имя возьмите из `lock` карточки версии
или `users/brief`. Из `POST …/lock` имя есть.
3. **Перехват**: `POST …/lock {takeover_reason: "…"}` при чужой блокировке; пустая причина или из пробелов — как отсутствующая.
4. **Ручные переходы** сейчас два: `CANCELLED` из любого «в работе» (с непустым `comment`, иначе 422 `COMMENT_REQUIRED`) и
возврат в `INITIATED` из `DOCUMENTS_PROCESSING`/`DATA_CORRECTION`. Остальное — 409 `TRANSITION_NOT_ALLOWED` (маршрут
согласования — Срез 7). Автоматические переходы: загрузка с версией → `DOCUMENTS_PROCESSING`; документ `NEEDS_CHECK` по
версии или подтверждение привязанного документа → `DATA_CORRECTION`.
5. **404 в консолидациях — всегда `NOT_FOUND`**; отдельных кодов «версия/строка/ГСК не найдена» нет.
6. **Ячейки чек-листа самоописывающие**: ищите по `document_type`/`account` («Бух. баланс» — `BALANCE`, «ОФР» — `INCOME`,
«ОСВ 60» — `OSV` + `"60"`). Порядок `rules`: ББ, ОФР, ОСВ по коду счёта натурально (`58 < 58.03 < 60`). ОСВ по субсчёту
`58.03` закрывает только своё правило. `PROCESSING` ставится по всем недостающим ячейкам ЮЛ, пока его файл не смаплен (OQ-122).
7. **`osv_accounts` участника** — «что загружено» к периоду версии (любой статус документа, кроме ошибки, незаменённые файлы),
а не «что готово»: готовность — в ячейках `checklist`.
8. **Сводная ОСВ — чистая функция**, в БД не хранится; одна и та же для страницы, сводки и экспорта. `match_stored = false` —
контрагент найден на лету (в БД не записан). Склеенная из кварталов строка несёт несколько `entry_ids` (от раннего к
позднему), её `match_kind` — от последнего квартала. На лету совпадение по алиасу приоритетнее совпадения по имени: при
ЮЛ-тёзке берётся владелец алиаса (`ALIAS`).
9. **Ручное сопоставление**: отправляйте **последнюю** `entry_id` строки (`entry_ids.at(-1)`), тогда в ответе `MANUAL`.
`propagated` — число записей `osv_entry`, а не строк свода. `row` может быть `null` (строка привязанного документа в свод не
попадает) — тогда перечитайте страницу и сводку. При `ALIAS_TAKEN` спросите пользователя и повторите с `move_alias: true`.
10. **Баннер «Осталось N»** = `unmapped_total` = число строк `GET …/osv?unmapped=true` — строки без сопоставленного ЮЛ **и** без
ИНН из файла. Строка с ИНН, которого нет в справочнике, сопоставления не требует (отмена П-1, решение владельца 08.10.2026).
11. **`gaps[]`** — пары (ЮЛ, счёт) без ОСВ за какие-то кварталы; строк таких пар в своде нет. Лист «Пробелы» экспорта от
фильтров не зависит.
12. **ГСК в версии не хранится**: `related_group_id` нужен только при создании. Колонку «ГСК» на [34] оставляйте пустой.
«Ответственный» на [34] — `lock.locked_by_name`, иначе `initiator.name`; «Утверждён в РФ» / «Верифицирован в ГО» — «—».
13. **`warnings` строк** — диагностика, не контракт: показывать как подсказку.
### 5.8 Схемы ответов (кратко)
Поля с `?` могут быть `null`, но ключ присутствует всегда. Полные схемы — `/openapi.json` и контракты по областям.
- **`CompanyGroupOut`** `{id, code, crm_id?, name, base_regional_office_id?, base_regional_office? {id, name, is_head_office}, is_archived, valid_from, legal_entities_count, created_at?, updated_at?}`.
- **`LegalEntityOut`** `{id, inn, kpp?, crm_id?, entity_type?, name, company_group_id?, regional_office_id?, regional_office? {id, name, is_head_office}, close_date?, valid_from, created_at?, updated_at?, documents_count, outside_group, closed}`; **`LegalEntityDetail`** — + `memberships[] {related_group_id, code, kind, name, valid_from}`, `aliases[] {id, alias, normalized_alias, created_at?}`.
- **`RelatedGroupOut`** `{id, code, company_group_id, kind, name, created_date?, members_count}`; **`RelatedGroupDetail`** — + `members[] {legal_entity_id, inn, name, valid_from}`.
- **`FileOut`** `{id, legal_entity_id, original_name?, file_type, status, version_no, size_bytes?, content_hash?, uploaded_at?, uploaded_by?, superseded_by_file_id?, job?, documents[]}`; **`JobBrief`** `{id, status, error_code?, error_message?, mapping_error?, warnings[], mapping_warnings[], finished_at?}`; **`DocumentBrief`** `{id, document_type, osv_account_code?, period_year?, period_quarter?, is_cumulative?, status, period_check?, counterparty_check?, locator?, comments_count}`.
- **`DocumentOut`** = поля `DocumentBrief` + `{file_id, legal_entity_id, confirmed_by?, confirmed_at?, group_schema? [{type, name?, has_inn?}], warnings[]}`.
- **`EntryOut`** `{id, row_number?, level?, parent_entry_id?, row_value?, group_type?, group_name?, data_type?, account_code?, account_root?, account_id?, counterparty_name?, counterparty_inn?, counterparty_id?, match_kind?, opening_debit?, opening_credit?, turnover_debit?, turnover_credit?, closing_debit?, closing_credit?}`.
- **`VersionBrief`** `{id, version_no, status, scenario, approach, lock?, created_at?, members_count}`; **`LockOut`** `{locked_by, locked_by_name, locked_at, taken_over_from?, takeover_reason?}`.
- **`VersionCard`** = `VersionBrief` + `{consolidation_id, year, quarter, reporting_date, company_group {id, name}, initiator {id, name}, initiator_office {id, name}, goodwill_enabled, is_in_work, previous_version_id?, members[], history[]}`; **`MemberOut`** `{id, name, inn, entity_type?, is_head_entity, checklist[] (ChecklistCellOut), osv_accounts[]}`; **`HistoryOut`** `{from_status?, to_status, changed_by, changed_at, comment?}`.
- **`ChecklistOut`** `{rules[] {document_type, account?, is_mandatory}, rows[] {legal_entity_id, legal_entity_name, legal_entity_inn, cells[]}}`; **`ChecklistCellOut`** `{document_type, account?, status, missing_quarters[], document_ids[]}`.
- **`ConsolidatedRowOut`** `{entry_ids[], legal_entity_id, legal_entity_name, legal_entity_inn, account_code?, account_root?, period, is_cumulative, row_number?, counterparty_name?, counterparty_inn?, counterparty_id?, counterparty_entity_name?, counterparty_entity_inn?, counterparty_type, match_kind?, match_stored, opening_debit?, opening_credit?, turnover_debit?, turnover_credit?, closing_debit?, closing_credit?, warnings[]}`.
- **`CandidateOut`** `{id, inn, name, company_group?, gsk?, gsz? ({id, name}), counterparty_type, recommended}`.
- **`StatementOut`** — `api-contract-slice-3b.md` §3.
---
## 6. Экраны: какие вызовы и в каком порядке
| Экран | Вызовы |
|---|---|
| Старт приложения | токен → `GET /me` (профиль и права в контекст); `GET /directories/regional-offices` — только для выбора филиала в формах (кэш на сессию) |
| [03] Группы компаний | `GET /directories/company-groups?q=&is_archived=&limit=&offset=`; «Основной РФ» — `base_regional_office.name` |
| Карточка ГК / состав ГК | `GET /directories/company-groups/{id}`, `GET /directories/legal-entities?company_group_id=…`, `GET …/company-groups/{id}/related-groups`; правки — §5.3 |
| [51-02] Добавить ЮЛ / [79] Изменить ЮЛ | списки «Входит в ГСК/ГСЗ» — `GET …/company-groups/{id}/related-groups` (по `kind`); «Добавить» — `POST /directories/legal-entities` со всей формой (`aliases`, `gsk_id`, `gsz_id`); «Сохранить» — `PATCH …/{id}` с итоговыми `aliases` и группами; форму заполнять из `GET …/{id}` (`aliases[].alias`, `memberships[]` по `kind`) |
| [60] Монозаёмщики | `GET /directories/legal-entities?outside_group=true`; «Документы» — `documents_count`, статусы форм — `GET /legal-entities/{id}/files` по ЮЛ |
| [34] Консолидации ГК | `GET /company-groups/{id}/consolidations?in_work_only=`; клик по версии — `GET /consolidations/versions/{id}` (панель «Организации»: `members[]` — тип, ячейки ББ/ОФР, `osv_accounts`) |
| [42] Новая консолидация | ГК → ГСК (`…/related-groups`, `kind = gsk`) → все незакрытые ЮЛ ГК (`legal-entities?company_group_id=<ГК>&closed=false`, дочитывать по `offset`) с отметками членов ГСК (`related-groups/{id}` → `members[]`) → «Создать» (активна при ≥ 2 отмеченных) → `POST /consolidations` с `legal_entity_ids` |
| [35] Взять в работу | `POST …/versions/{id}/lock`; на 409 `LOCKED_BY_OTHER` — диалог перехвата → тот же запрос с `takeover_reason`; «Отпустить» — `DELETE …/lock` |
| [33-01] «Изменить статус» / «Отмена КФО» | `POST …/versions/{id}/status {to_status, comment}` |
| [36] Состав | `GET …/versions/{id}` (члены) + все ЮЛ ГК → «Сохранить» — `PUT …/members` |
| [37] Чек-лист | `GET …/checklist`; «Скачать» — `GET …/checklist?format=csv` (blob, §7) |
| [39] Первичные документы ЮЛ | `GET /legal-entities/{id}/files` (+ опрос при `UPLOADED`/`PROCESSING`); «Загрузил» — `GET /users/brief?ids=…`; удалить — `DELETE /files/{id}` |
| [40] Загрузить документы | `POST /legal-entities/{id}/files?consolidation_version_id=…` → показать `accepted`/`rejected` → опрос списка |
| [41] Проверка распознавания ББ/ОФР | `GET /documents/{id}/statement`; исходник — `GET /files/{file_id}/content?disposition=inline` (blob); «Сохранить» [41-02] — `PATCH …/statement`; акцепт — `POST …/statement/checks/accept`; «Подтвердить данные» — `POST /documents/{id}/confirm`; комментарии — `GET/POST /documents/{id}/comments` |
| Строки ОСВ документа | `GET /documents/{id}/entries` (только чтение) |
| [30] Сводная ОСВ | `GET …/osv` с фильтрами, `GET …/osv/summary`; «Экспорт» — `GET …/osv/export` (blob) |
| [32-osv-no-inn], [32-osv-map-counterparty] | `GET …/osv?unmapped=true` → `GET …/counterparty-candidates?entry_id=…&q=…` (`scope=all` для поиска по всему справочнику) → «Связать» — `POST …/osv/entries/{entry_id}/counterparty` |
| [31-00] Уход с вкладки | `GET …/osv/summary` → предупредить, если `unmapped_total > 0` |
| `/admin` | §5.4 |
### 6.1 Что перечитать после действия
Сервер не присылает событий; после действия перечитайте то, что могло измениться.
| Действие | Ответ уже содержит | Перечитать |
|---|---|---|
| Загрузка файлов | `accepted`/`rejected` | список файлов ЮЛ (опрос до завершения), карточку версии (статус мог стать `DOCUMENTS_PROCESSING`, потом `DATA_CORRECTION`), чек-лист |
| Подтверждение документа | новый `DocumentOut` | чек-лист и карточку версии (документ привязался ко всем версиям «в работе» ГК; статус мог стать `DATA_CORRECTION`), список файлов (статус строки) |
| Правка формы / акцепт | новый `StatementOut` | ничего (кроме статуса строки в drawer, если он открыт) |
| Удаление файла | — (204) | список файлов, чек-лист |
| Автозамена (новый файл тех же документов) | — | прежний файл исчезает из списка (`SUPERSEDED`); его документы отвязаны от версий — перечитать чек-лист |
| Сопоставление контрагента | `row` (может быть `null`), `propagated` | `GET …/osv/summary`; страницу свода, если `propagated > 0` или `row = null` |
| Смена периметра / статуса | новый `VersionCard` | список версий ГК (`…/consolidations`), если он на экране |
| Взятие / снятие блокировки | `LockOut` / — | карточку версии (или обновить `lock` локально) |
| Правки справочника | новый объект (у `POST`/`PATCH` ЮЛ — карточка с алиасами и членствами) | списки, где он показан; карточку ЮЛ — после отдельных ручек алиасов и состава ГСК/ГСЗ; состав ГСК/ГСЗ (`related-groups/{id}`), если он на экране, — после смены групп в форме ЮЛ |
---
## 7. Скачивание файлов
Три маршрута отдают файл: `GET /files/{id}/content`, `GET …/checklist?format=csv`, `GET …/osv/export`. Все требуют
`Authorization`, поэтому `<a href>` и `<img src>` **не работают** — забирайте `fetch` → blob. Имя файла — из `filename*`
заголовка `Content-Disposition` (RFC 5987, UTF-8, percent-кодировка); `filename=` там — ASCII-заглушка (`file.pdf`,
`checklist.csv`, `svod-osv.xlsx`).
**Кросс-доменно заголовок не виден.** CORS ядра не выставляет `Access-Control-Expose-Headers`, поэтому при `npm run start`
(фронт на `:3000`, API на `:8000`) `res.headers.get('Content-Disposition')` вернёт `null`. В сборке, которую раздаёт само ядро
(SPA и API на одном источнике), заголовок читается. Пока это не исправлено (§10 п. 9), держите запасное имя на клиенте —
его можно собрать из того, что уже есть на экране (`checklist-<ГК>-<год>-<квартал>.csv`, `svod-osv_<ГК>_<год>-<Qn>_v<n>.xlsx`,
`FileOut.original_name`).
```ts
async function download(path: string, fallbackName: string): Promise<void> {
const res = await fetch(`${API}/api/v1${path}`, { headers: { Authorization: `Bearer ${getToken()}` } });
if (!res.ok) throw parseError(res.status, await res.json().catch(() => null));
const cd = res.headers.get('Content-Disposition') ?? '';
const star = /filename\*=UTF-8''([^;]+)/i.exec(cd);
const name = star ? decodeURIComponent(star[1]) : fallbackName;
const url = URL.createObjectURL(await res.blob());
const a = Object.assign(document.createElement('a'), { href: url, download: name });
a.click();
URL.revokeObjectURL(url);
}
```
Особенности:
- **Первичка** (`/files/{id}/content`): `disposition=inline` — для просмотра (PDF/PNG/JPG во фрейме или просмотрщике из blob URL);
ответ несёт `Content-Security-Policy: sandbox` — проверьте, что выбранный просмотрщик PDF под ним работает. `Cache-Control:
private, no-store` — не кэшируйте байты. Работает и для `SUPERSEDED`. 404 `FILE_CONTENT_NOT_FOUND` — запись есть, байтов в
хранилище нет (истёк срок хранения): «Файл больше недоступен».
- **CSV чек-листа**: UTF-8 с BOM, разделитель `;`, CRLF; подписи статусов как в UI; имя `checklist-<ГК>-<год>-<квартал>.csv`.
- **xlsx свода**: имя `svod-osv_<ГК>_<год>-<Qn>_v<n>.xlsx`; строки — после тех же фильтров, что на экране, без пагинации.
---
## 8. Деньги, проверки и прочие тонкости, которые ломают экраны
- Склеенные квартальные строки свода не содержат `null` (пустое = `"0.00"`); строки нарастающего документа — как в файле,
могут быть `null`. Показывайте `null` как пусто, а не «0,00».
- `FailedCheckOut.expected`/`actual` — строки-суммы; `relation` — готовая читаемая запись соотношения («1600 = 1100 + 1200»).
- `edition` формы (`"66n"`, `"fsbu4"`, `null`) — только подсказка для шаблона строк, на проверки не влияет.
- Строки формы без кода (расшифровочные «в том числе») в ответ не попадают.
- ОСВ со скользящим периодом: `period_year` есть, `period_quarter` и `is_cumulative` — `null`; документ можно подтвердить, но в
версии он не участвует.
- Строки `osv_entry` общие у версий одного документа: сопоставление контрагента в одной версии видно в другой (OQ-123).
- Даты `effective_date` (перевод и вывод ЮЛ) сравниваются с «сегодня» по Москве; ограничьте датапикер минимумом «сегодня (МСК)».
---
## 9. Каталог доменных кодов ошибок
| Код | HTTP | Где | Доп. поля | Что делать в UI |
|---|---|---|---|---|
| `NOT_FOUND` | 404 | любой объект в пути или теле, вне видимости или несуществующий | — | «Не найдено»; не ветвиться по тексту |
| `OFFICE_NOT_FOUND` | 422 | ГК, ЮЛ, пользователь: филиала нет | — | ошибка поля «Филиал» |
| `OFFICE_NOT_ALLOWED` | 422 | ГК/ЮЛ: пользователь филиала закрепляет за чужим филиалом | — | ошибка поля «Филиал» |
| `INVALID_INN` | 422 | ЮЛ (10/12 цифр по типу), шапка формы (10 или 12) | — | ошибка поля «ИНН» |
| `INVALID_KPP` | 422 | ЮЛ: формат КПП, КПП у ИП (в т. ч. смена типа на ИП); в импорте — код строки отчёта | — | ошибка поля «КПП» |
| `EFFECTIVE_DATE_IN_PAST` | 422 | перевод/вывод ЮЛ | — | ошибка поля «Дата» |
| `EMPTY_ALIAS` | 422 | алиас из одних ОПФ и знаков | — | ошибка поля |
| `DIFFERENT_COMPANY_GROUP` | 422 | член ГСК/ГСЗ из другой ГК (в т. ч. ГСК/ГСЗ в форме монозаёмщика) | — | сообщение |
| `WRONG_GROUP_KIND` | 422 | форма ЮЛ: в `gsk_id` передана ГСЗ или в `gsz_id` — ГСК | — | ошибка поля «Входит в ГСК/ГСЗ» |
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | администрирование | `roles` / `permissions` | подсветить коды |
| `MISSING_COLUMNS` | 422 | импорт | `missing[]` (поле есть и у `UNSUPPORTED_FILE` импорта — пустое) | перечислить колонки |
| `UNSUPPORTED_FILE` | 422 | импорт (файл целиком); загрузка — в `rejected[]` | — | сообщение |
| `FILE_TOO_LARGE`, `DUPLICATE_FILE` | — | только в `rejected[]` загрузки | `file_id` у дубля | строка «не принят» со ссылкой на существующий файл |
| `STORAGE_UNAVAILABLE` | 503 / в `rejected[]` | загрузка, первичка | — | «Хранилище недоступно, повторите позже» |
| `CRM_ID_EXISTS`, `INN_EXISTS`, `EMAIL_EXISTS` | 409 | создание/правка | — | ошибка поля |
| `LEGAL_ENTITY_CLOSED`, `SAME_COMPANY_GROUP` | 409 | перевод ЮЛ | — | сообщение |
| `ALREADY_CLOSED`, `NOT_IN_COMPANY_GROUP` | 409 | вывод ЮЛ | — | сообщение |
| `GROUP_NOT_EMPTY` | 409 | удаление ГСК/ГСЗ | — | «Сначала исключите членов» |
| `MEMBER_EXISTS` | 409 | ЮЛ уже в этой группе | — | сообщение |
| `MEMBER_OF_OTHER_GROUP` | 409 | ЮЛ уже в другой группе этого вида | `related_group_id` (не при гонке) | сообщение со ссылкой на группу |
| `ALIAS_EXISTS` | 409 | алиас у этого ЮЛ уже есть; в форме ЮЛ — два тега совпадают после нормализации | — | сообщение (в форме — подсветить повтор) |
| `ALIAS_TAKEN` | 409 | алиас у другого ЮЛ | справочник: `legal_entity_id`; сопоставление: `owners[] {legal_entity_id, name}` | в сопоставлении — предложить «Перенести алиас» (`move_alias: true`) |
| `LAST_ADMIN` | 409 | администрирование | — | «Нельзя оставить систему без администратора» |
| `ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение, правка формы | — | перечитать документ |
| `PERIOD_REQUIRED` | 409 | подтверждение формы | — | «Укажите дату отчёта» → режим правки шапки |
| `CHECKS_NOT_PASSED` | 409 | подтверждение формы | `failures[] {column_index, relation, expected, actual}` | показать нарушения, предложить акцепт |
| `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement` не по форме ББ/ОФР | — | не открывать экран [41] для такого документа |
| `DOCUMENT_READONLY` | 409 | правка/акцепт подтверждённой формы | — | перевести экран в просмотр |
| `UNKNOWN_CELL`, `DUPLICATE_CELL` | 422 | правка формы | — | ошибка клиента (перечитать форму); `UNKNOWN_CELL` также: `line_code` уже есть в форме (слать `line_id`), код вне справочника редакции, нет редакции |
| `NOTHING_TO_ACCEPT` | 409 | акцепт без нарушений | — | перечитать форму |
| `FILE_HAS_CONFIRMED_DOCUMENTS`, `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла | — | скрывать «Удалить» у таких файлов (есть документ, подтверждённый пользователем, или автоподтверждённый документ в версии вне корректировки данных (не `INITIATED`/`DOCUMENTS_PROCESSING`/`DATA_CORRECTION`), см. §3.7; или на файл ссылаются `superseded_by_file_id`) |
| `FILE_CONTENT_NOT_FOUND` | 404 | первичка | — | «Файл больше недоступен» |
| `TOO_MANY_IDS`, `INVALID_IDS` | 422 | `users/brief` | — | ошибка клиента (бейте на пачки ≤ 200) |
| `CONSOLIDATION_EXISTS` | 409 | создание консолидации | — | «Консолидация за этот период уже есть» → открыть её |
| `PERIMETER_TOO_SMALL` | 422 | создание, `PUT …/members` | — | «Нужно минимум два ЮЛ» |
| `RELATED_GROUP_MISMATCH` | 422 | создание | — | ошибка поля «ГСК» |
| `ENTITY_NOT_IN_GROUP` | 422 | создание с `legal_entity_ids`, `PUT …/members` | `legal_entity_ids[]` | подсветить ЮЛ |
| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с версией | — | «ЮЛ не входит в периметр версии» |
| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all` | — | «Введите не менее 3 символов» |
| `COMMENT_REQUIRED` | 422 | отмена версии | — | ошибка поля «Комментарий» |
| `VERSION_NOT_EDITABLE` | 409 | правки версии, загрузка с версией, взятие `CANCELLED` | — | перевести экран в просмотр |
| `LOCK_REQUIRED` | 409 | правки версии | — | «Возьмите версию в работу» |
| `LOCKED_BY_OTHER` | 409 | взятие и правки версии | `locked_by`, `locked_at`, из `POST …/lock` ещё `locked_by_name` | «Версию держит <имя>», предложить перехват |
| `NOT_LOCK_OWNER` | 409 | снятие блокировки | `locked_by`, `locked_at` | перечитать карточку |
| `TRANSITION_NOT_ALLOWED` | 409 | ручной статус | `from_status`, `to_status` | перечитать карточку |
Не доменные ответы: 401 строкой (`WWW-Authenticate: Bearer`), 403 строкой, 405 строкой с `Allow`, 404 `{"detail": "Not Found"}`
(нет маршрута), 400 строкой (не разобран multipart, больше 1000 файлов), 413 строкой (импорт > 20 МиБ), 409 строкой «нарушение
целостности данных в БД» (редкая гонка), 500 строкой («внутренняя ошибка сервера», «ошибка базы данных»).
---
## 10. Известные ограничения и ошибки на `main`
1. **`period_check` всегда `UNKNOWN`** (OQ-120) — показывать «—»; подходящесть документа версии — только чек-лист.
2. **Ячейка `PROCESSING` неточна** (OQ-122): пока файл ЮЛ не смаплен, «Обработка» стоит во всех недостающих ячейках ЮЛ.
3. **`/me` не перечитывается** после смены ролей администратором — нужна перезагрузка страницы.
4. **CSV чек-листа не обезвреживает формулы Excel** (R-9): имя ЮЛ, начинающееся с `=`, `+`, `-`, `@`, Excel может принять за
формулу. xlsx свода этим не страдает (ячейки пишутся текстом).
5. **Статус формы после правки** может стать `RECOGNIZED`, даже если у файла были предупреждения маппинга (arm-core#40, R-2) —
ориентируйтесь на проверки и предупреждения, а не только на статус.
6. **Комментарии и правки нельзя удалить/отменить**; подтверждение необратимо.
7. **Нет в API** (показывать «—» или не показывать): маршрут согласования после `DATA_CORRECTION` (Срез 7), шахматка и ВГО
(Срез 5), КФО и ЕХ (Срезы 6–7), «Добавить версию», гудвилл, журнал аудита, шапка формы ОКЕИ/ОПФ/форма собственности/аудит
(П-4).
8. **В проде нет `/openapi.json`** — генерируйте типы со стенда разработки.
9. **`Content-Disposition` не читается кросс-доменно** (нет `Access-Control-Expose-Headers` в CORS ядра): при фронте на
другом порту/домене имя скачиваемого файла берите запасное (§7). Нужна правка в arm-core (`expose_headers=["Content-Disposition"]`).
---
## 11. Что изменится в ближайших PR
| Задача | Что поменяется для фронта | Как подготовиться |
|---|---|---|
| OQ-54 — SSO РАИСА | Меняется только получение токена (вместо dev-формы) | держать слой получения токена изолированным (`frontend-auth-guide.md` §9) |
Состояние этих задач — в Gitea (`RSHB/arm-core`, `RSHB/arm-ka-docs`); после слияния каждой этот раздел переезжает в §1.
---
## 12. Чек-лист клиента
1. Единый HTTP-клиент: база, `Bearer`, глобальный 401, разбор трёх форм `detail` (§3.2), сетевая ошибка отдельно.
2. Один `GET /me` при старте; видимость элементов — по `permissions`, не по ролям.
3. Ветвление по `code`, не по тексту; 404 — «не найдено» без уточнений.
4. Деньги — строками и decimal-библиотекой; в `PATCH` формы — строкой.
5. PATCH — только изменённые поля; не обнулять обязательные (`name`, `entity_type`, `inn` формы).
6. Ограничения ввода как на сервере: ≤ 500 ЮЛ периметра, комментарий статуса ≤ 1000, акцепта ≤ 2000, комментарий документа ≤ 4000,
поиск ≤ 500, `takeover_reason` ≤ 1000, `users/brief` ≤ 200 id.
7. Длинные списки — дочитывать по `offset`; лимит 200 (справочники, файлы) или 2000 (строки ОСВ).
8. Правки версии — только со своей блокировкой; состояние кнопок — по `is_in_work` и `lock`.
9. Ячейки чек-листа — по `document_type`/`account`.
10. Файлы — `fetch` → blob с токеном; имя из `filename*`.
11. После действий — перечитать по таблице §6.1; во время распознавания — опрос 3–5 с.