# Контракт API Среза 3b (формы ББ/ОФР и путь пользователя до сводной ОСВ) Источник — реальный код arm-core (`api/src/schemas/statements.py`, `schemas/comments.py`, `api/endpoints/files/statements.py`, `comments.py`, `read.py`, `users.py`; Swagger — `/docs`) и спека `arm-core/openspec/changes/archive/2026-10-07-slice-3b-statements-api/` (design D-1…D-14, «Decisions made without discussion» №1–№22). Правила предметной области — `context/05-domain-rules.md` §7, §9.3, §9.5 (п. 2, 4, 9, 10); экраны — `context/10-user-journey.md` (пути 1–2, шаг 3.1), номера в `[…]` — снимки макета (`mockup/INDEX.md`). Общие правила (префикс, токен, формат ошибок, права) — как в `dev/api-contract-slice-4.md` §1; здесь — то, что добавил Срез 3b. Файлы, документы и справочники (схемы `FileOut`/`DocumentBrief`, загрузка, удаление) — `dev/api-contract-files-directories.md`. ## 1. Общее - Префикс `/api/v1`, `Authorization: Bearer `. Права: чтение формы, комментариев и исходного файла — `DOCUMENT_VIEW`; правка формы, акцепт проверок, комментарий, подтверждение — `DOCUMENT_EDIT`; CSV чек-листа — `CONSOLIDATION_VIEW`; `GET /users/brief` — любой аутентифицированный пользователь, отдельного права нет. Нет права — 403 строкой; нет токена — 401. - Порядок проверок на маршрутах документа: право (403) → валидация схемы запроса (422 массивом pydantic, до обращения к данным) → видимость (404) → состояние (409) → доменные 422 (`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL`). Документ, файл или ЮЛ вне области видимости неотличимы от несуществующих: **404 с кодом `NOT_FOUND`**. - Доменные ошибки: `{"detail": {"code": "...", "message": "на русском", ...доп. поля}}`; ветвиться клиент должен по `code`. 422 валидации тела/параметров схемы — массив pydantic (`detail: [{loc, msg, type}]`) без `code`; доменные 422 (`INVALID_INN`, `UNKNOWN_CELL`, …) — с `code`. Каталог кодов — `arm-core/README.md`, «Ошибки API». - **Деньги — строки JSON** (`"1234.50"`, `numeric(18,2)`), не `float`: `amount`, `original_amount`, `expected`, `actual`; в запросе `amount` — число с не более чем двумя знаками и не более 16 цифр до запятой, либо `null` (очистить ячейку). `confidence` — строка (`"100.00"`), `null` у ячеек, которых нет в результате распознавания. - Идентификаторы — uuid-строки; даты — ISO (`2025-09-30`); время — ISO с часовым поясом. - Типы форм: `document_type` — `BALANCE` (ББ), `INCOME` (ОФР); `DISCLOSURE` и `OTHER` — документ без данных, `…/statement` для них и для `OSV` — 409 `DOCUMENT_NOT_STATEMENT`. - `DocumentStatus` — `RECOGNIZED`, `NEEDS_CHECK`, `CONFIRMED`, `ERROR`; `DocumentCheckResult` (`counterparty_check`) — `MATCH`, `MISMATCH`, `UNKNOWN`; `PeriodQuarter` — `Q1`…`Q4`. ## 2. Эндпоинты | Метод и путь | Право | Запрос | Ответ | Ошибки | Экран | |---|---|---|---|---|---| | `GET /documents/{document_id}/statement` | VIEW | — | 200 `StatementOut` | 404 `NOT_FOUND`, 409 `DOCUMENT_NOT_STATEMENT` | [41] | | `PATCH /documents/{document_id}/statement` | EDIT | `StatementPatch {header?, values[]}` (`values[]` — `{line_id | line_code, column_id, amount}`) | 200 `StatementOut` (состояние после правки) | 404, 409 `FILE_SUPERSEDED` / `DOCUMENT_READONLY` / `DOCUMENT_NOT_CONFIRMABLE` / `DOCUMENT_NOT_STATEMENT`, 422 `INVALID_INN` / `UNKNOWN_CELL` / `DUPLICATE_CELL`, 422 (pydantic-массив) | [41-02] «Сохранить» | | `POST /documents/{document_id}/statement/checks/accept` | EDIT | `ChecksAcceptIn {comment}` (1–2000 символов после обрезки) | 200 `StatementOut` | 404, 409 `NOTHING_TO_ACCEPT` и те же 409, что у `PATCH`, 422 (pydantic-массив) | [41] блок «Логические ошибки» | | `POST /documents/{document_id}/confirm` | EDIT | — | 200 `DocumentOut` (как в Срезе 2c) | 404, 409 `FILE_SUPERSEDED` / `ALREADY_CONFIRMED` / `DOCUMENT_NOT_CONFIRMABLE` / **`PERIOD_REQUIRED`** / **`CHECKS_NOT_PASSED`** | [41] «Подтвердить данные» | | `POST /documents/{document_id}/comments` | EDIT | `CommentIn {text}` (1–4000 символов после обрезки пробелов) | 201 `CommentOut` | 404, 422 (pydantic-массив) | [41] «Добавить комментарий» | | `GET /documents/{document_id}/comments` | VIEW | — | 200 `CommentOut[]` по возрастанию `created_at`, без пагинации | 404 | [41] | | `GET /files/{file_id}/content` | VIEW | query `disposition` = `attachment` (по умолчанию) или `inline` | 200 поток байт файла | 404 `NOT_FOUND`, 404 `FILE_CONTENT_NOT_FOUND`, 503 `STORAGE_UNAVAILABLE`, 422 (pydantic-массив) при другом `disposition` | [41] «Исходный документ» | | `GET /users/brief?ids=,…` | любой аутентифицированный | query `ids` (обязателен, ≤ 200) | 200 `UserBriefOut[]` | 422 `TOO_MANY_IDS`, 422 `INVALID_IDS` | [39] «Загрузил» | | `GET /consolidations/versions/{version_id}/checklist?format=csv` | CONSOLIDATION_VIEW | query `format` = `json` (по умолчанию, `ChecklistOut`) или `csv` | 200 `text/csv; charset=utf-8` | 404 `NOT_FOUND`, 422 (pydantic-массив) при другом `format` | [37] «Скачать» | Изменения в уже существующих ответах: `comments_count` (целое, `0` по умолчанию) — у документа в карточке файла (`GET /legal-entities/{id}/files`, `GET /files/{id}`: `documents[]`) и в `DocumentOut` (`GET /documents/{id}`); `documents_count` (целое) — в `LegalEntityOut` (`GET /directories/legal-entities`, `GET …/{id}`): число документов файлов ЮЛ, кроме файлов в статусе `SUPERSEDED`. Подтверждение формы ББ/ОФР привязывает её к версиям консолидации «в работе» так же, как ОСВ (Срез 4): ББ/ОФР — отчёт на конец квартала и подходит версии за тот же квартал (форма за `Q1` — только версиям за `Q1`); ячейка «Бух. баланс» / «ОФР» чек-листа после этого — `CONFIRMED`. ## 3. Схемы Поля без `?` присутствуют всегда; `null`-able поля помечены `| null`. **`StatementOut`** `{document, edition, header, columns[], lines[], checks}`: - `document` — `StatementDocumentOut {id, document_type, status, period_year | null, period_quarter | null, reporting_date | null, counterparty_check | null, locator | null, file_id}`. - `edition` — `"66n"` (дата периода документа ≤ 2024-12-31), `"fsbu4"` (позже) или `null` (даты нет): для выбора шаблона строк формы; на проверки §7 не влияет (OQ-146). - `header` — `StatementHeaderOut`: `inn`, `report_date`, `units` — каждый `HeaderFieldOut {value | null, original_value | null, confidence | null, verified | null, modified | null}` (`confidence` есть только у ИНН; `report_date.value` — ISO-дата строкой; `original_value` — как распознано, при правке не меняется), плюс строки `organization_name`, `okud`, `okpo`, `okopf`, `okfs`, `address` (все `| null`). - `columns[]` — `ColumnOut {id, column_index, label | null, period_date | null, is_current | null}`; `is_current = true` у колонки с самой поздней датой. - `lines[]` — по коду строки (по возрастанию `line_code`): `LineOut {id | null, origin: 'source' | 'template', line_code | null, line_code_confidence | null, line_name | null, line_name_confidence | null, values[]}`; `values[]` — по одной записи на **каждую** колонку формы (`ValueOut {id | null, column_id, column_index, amount | null, string_value | null, confidence | null, is_recognized | null, is_modified, original_amount | null, modified_by | null, modified_at | null}`). `amount` — текущее значение (после правок); `original_amount` заполняется с первой правки; `string_value` — неразобранный текст ячейки (когда `amount` пуст). - `checks` — `ChecksOut {failed[], soft_failed[], cross_form | null, accepted | null}`: `FailedCheckOut {column_index, relation, expected, actual}` (`relation` — читаемая запись соотношения, `expected` и `actual` — суммы строками; `column_index = -1` — у межформенной проверки колонки нет); `accepted` — `{by, at, comment}` (в коде схема `ChecksAcceptedOut`) или `null`. **`StatementPatch`** `{header?: {inn?, report_date?, units?}, values?: [{line_id | line_code, column_id, amount}]}` — частичное тело, лишние поля запрещены (422). `header.inn` — строка из 10 или 12 цифр (любое другое значение, включая `null`, — 422 `INVALID_INN`: ИНН очистить нельзя); `header.report_date` — дата, `null` очищает; `header.units` — непустая строка после обрезки пробелов, ≤ 50 (`null` — «не менять», единицы очистить нельзя; пробельная строка — 422 pydantic-массивом). Поле шапки, которого нет в теле, не меняется. `values` — до 5000 ячеек: `{line_id | line_code, column_id, amount}` (ровно одно из `line_id`/`line_code`); одна ячейка дважды в запросе — 422 `DUPLICATE_CELL`; `line_id`/`column_id` не этого документа — 422 `UNKNOWN_CELL`. **Полный состав формы (arm-core#80):** в `StatementOut` `lines[].origin` (`source` | `template`), у достроенных по справочнику редакции строк и ячеек `id = null`; `line_code` создаёт строку (`line_code_original_value = NULL`) при первой непустой сумме. Новые случаи `UNKNOWN_CELL`: код уже есть в форме (передайте `line_id`), код вне справочника, нет редакции. **`ChecksAcceptIn`** `{comment}`. **`CommentIn`** `{text}`. **`CommentOut`** `{id, text, author: {id, full_name}, created_at}`. **`UserBriefOut`** `{id, full_name, is_active}`. **`DocumentOut`** (из Среза 2c) + `comments_count`. Ошибка `CHECKS_NOT_PASSED` несёт доп. поле `failures[] {column_index, relation, expected, actual}` (суммы — строками) внутри `detail`: `{"detail": {"code": "CHECKS_NOT_PASSED", "message": "...", "failures": [...]}}`. ## 4. Нюансы, которые легко пропустить 1. **Форма создаётся при маппинге, а не при загрузке.** Пока файл `UPLOADED`/`PROCESSING`, документов формы нет. Документ появляется в карточке файла (`documents[]`) после статуса файла `PROCESSED`; `GET …/statement` до этого — 404. 2. **Значение на каждую ячейку.** У каждой пары «строка × колонка» есть `ValueOut` с собственным `id`, даже если в результате распознавания ячейки не было (тогда `amount = null`, `confidence = null`, `is_recognized = null`). Для правки не нужно создавать значения: передавайте `line_id` и `column_id` существующей ячейки. Строк без кода (расшифровочных, «в том числе …») в ответе нет (OQ-36, OQ-141, OQ-149). 3. **Подтверждённая форма — только для чтения.** `PATCH` и `accept` по документу `CONFIRMED` — 409 `DOCUMENT_READONLY`; отмены подтверждения нет, исправление — новым файлом (автозамена, `05-domain-rules.md` §9.5 п. 3). По файлу `SUPERSEDED` — 409 `FILE_SUPERSEDED`; по документу в статусе `ERROR` — 409 `DOCUMENT_NOT_CONFIRMABLE`. Комментарии при этом разрешены всегда. 4. **Правка значения: `is_modified` снимается возвратом к исходному.** При первой правке ячейки `original_amount` = прежняя сумма; `is_modified = (amount ≠ original_amount)`. Верните исходную сумму — `is_modified = false`, `original_amount` остаётся. Правка шапки: `modified = true` у поля, `original_value` не меняется (возврат значения `modified` не снимает). 5. **Правка даты отчёта пересчитывает период.** После `PATCH header.report_date` в `document` меняются `period_year`, `period_quarter` (только если дата — последний день квартала, иначе `null`), `reporting_date`; `edition` — по новой дате. Правка `inn` пересчитывает `counterparty_check`. Статус документа (`RECOGNIZED`/`NEEDS_CHECK`) пересчитывается после каждой правки. 6. **Акцепт сбрасывается любой правкой формы.** После `PATCH`, который что-то изменил, `checks.accepted` снова `null`. Запрос, который ничего не меняет (те же значения, пустое тело), — 200 с неизменным состоянием, без записи в журнал и без сброса акцепта. Если акцепт был, в записи журнала `RECOGNIZED_DATA_EDIT` его прежнее значение сохраняется в `before.checks_accepted` (`by`, `at`, `comment`), поэтому текст сброшенного акцепта можно восстановить из аудита. 7. **`checks.failed` не очищается акцептом.** Акцепт не скрывает нарушения: `failed` остаётся заполненным, а `accepted` непусто — это сигнал «нарушения приняты аналитиком». Блокируют подтверждение только нарушения **без** акцепта. 8. **Что блокирует подтверждение** (`POST …/confirm`): период не определён — 409 `PERIOD_REQUIRED` (исправить датой отчёта); жёсткие нарушения без акцепта — 409 `CHECKS_NOT_PASSED` (список — в `detail.failures`). **Не блокируют:** мягкие нарушения (`soft_failed`, в том числе «в пределах округления»), межформенная проверка (`cross_form`), `counterparty_check = MISMATCH`, низкая уверенность значений. Порядок 409, если совпало несколько: `FILE_SUPERSEDED`, `ALREADY_CONFIRMED`, `DOCUMENT_NOT_CONFIRMABLE`, `PERIOD_REQUIRED`, `CHECKS_NOT_PASSED`. `DISCLOSURE`/`OTHER` подтверждаются без условий. 9. **Статус документа формы** — подсказка экрану, а не замок: `RECOGNIZED` только если нет жёстких нарушений без акцепта, контрагент не `MISMATCH`, период определён, все значения уверенные или правленые и нет предупреждений маппинга. Формы из OCR почти всегда `NEEDS_CHECK` (уверенность < 100, ИНН распознан неуверенно) — это ожидаемо; подтвердить можно и из `NEEDS_CHECK`. 10. **Межформенная проверка зависит от другого документа.** `cross_form` считается, только если у ЮЛ есть документ-пара другого типа формы за тот же год и квартал (файл не `SUPERSEDED`; подтверждённый предпочтительнее) и найдены обе колонки; иначе `null`. Появление ОФР отдельным файлом позже меняет `cross_form` у уже открытого ББ — перечитайте форму. Нарушение мягкое. 11. **Редакция формы (`edition`) — только подсказка.** Определяется ядром по дате периода документа; для документа без даты — `null`. Контракт обмена с сервисами распознавания редакцию не передаёт (OQ-145). 12. **Исходный файл.** `GET /files/{id}/content` отдаёт байты через ядро; прямой ссылки на S3 нет. Забирайте как blob с `Authorization` (`` и `` без заголовка не сработают). `Content-Type` — по типу файла (`application/pdf`, `image/png`, `image/jpeg`, для xls/xlsx/xlsb — свои MIME). `Content-Disposition`: `attachment` (по умолчанию) или `inline` (для просмотра в браузере) с ASCII-именем `file.` и `filename*=UTF-8''…` — исходным именем в percent-кодировке; берите имя из `filename*`. Ответ несёт `X-Content-Type-Options: nosniff` (браузер не угадывает тип по содержимому; тип задаёт `Content-Type`) и `Cache-Control: private, no-store` (не кэшируйте байты); при `disposition=inline` — ещё `Content-Security-Policy: sandbox` (активное содержимое файла в браузере не исполняется; проверьте, что выбранный просмотрщик PDF работает при таком заголовке). Работает и для файла `SUPERSEDED`. Объекта нет в хранилище (убран ретеншном) — 404 `FILE_CONTENT_NOT_FOUND` (не путать с `NOT_FOUND`: файл в БД есть, а байтов нет); хранилище не подключено — 503 `STORAGE_UNAVAILABLE`. Скачивание в журнал не пишется (OQ-153). 13. **Комментарии.** Только добавление и чтение; правки и удаления нет. `created_at` — порядок показа (по возрастанию). `text` обрезается по краям, пустой после обрезки или длиннее 4000 символов — 422 в формате pydantic. Автор — пользователь запроса. `comments_count` в карточках — для бейджа; список перечитывается отдельным запросом. Удаление файла (`DELETE /files/{id}`) удаляет и комментарии его документов, и данные форм ББ/ОФР (`financial_statement_*`): после этого `GET …/comments` и `GET …/statement` по документам файла — 404. 14. **Имена пользователей.** `GET /users/brief` нужен, чтобы показать «Загрузил» (`uploaded_by`) и авторов правок (`modified_by`, `checks.accepted.by`, `confirmed_by`) без права `USERS_ADMIN`. Отдаёт только найденные id (включая неактивных, `is_active = false`), упорядоченные по `id`, а не в порядке запроса; неизвестные пропускаются без ошибки; пустой `ids` — `[]`; больше 200 id — 422 `TOO_MANY_IDS`; не-UUID — 422 `INVALID_IDS`. Собирайте id пачкой и кэшируйте. 15. **CSV чек-листа.** `text/csv; charset=utf-8`, **BOM** в начале, разделитель `;`, переводы строк CRLF. Первая строка — «Организация;ИНН;<правило>…», где правило — «Бух. баланс», «ОФР», «ОСВ <счёт>» (в порядке `rules[]` чек-листа). Ячейки — подписи макета: `CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK` и `RECOGNIZED` «Требуется проверка», `ERROR` «Ошибка загрузки», `MISSING` «Отсутствует»; при пропущенных кварталах — с хвостом « (нет: Q1, Q2)». Заголовок ответа — `Content-Disposition: attachment; filename="checklist.csv"; filename*=UTF-8''checklist-<ГК>-<год>-<квартал>.csv` (ASCII-имя фиксированное; настоящее имя — в `filename*`, percent-кодировка целиком, кириллица и пробелы экранируются; берите имя оттуда). Забирайте как blob с `Authorization`. Правило подписи — по `document_type` правила чек-листа; ячейки с текстом (имя ЮЛ) в CSV не нейтрализуются от формул Excel (design №19, R-9): значение, начинающееся с `=`, `+`, `-`, `@`, Excel может принять за формулу. Без `format=csv` (по умолчанию `format=json`) маршрут отдаёт JSON `ChecklistOut` (Срез 4); значение `format`, кроме `json` и `csv`, — 422 (pydantic-массив); право — `CONSOLIDATION_VIEW`, версия вне видимости — 404 `NOT_FOUND`. 16. **`documents_count` у ЮЛ** считается на странице списка и в карточке одним запросом; заменённые файлы (`SUPERSEDED`) не входят. Число документов, а не файлов: один файл с ББ и ОФР даёт 2. 17. **Что не вошло в срез.** Правка кода и названия строки, добавление и удаление строк и колонок, отмена подтверждения, смена типа формы, склейка страниц одной формы из разных файлов (OQ-140), формулы §7 по редакциям (OQ-146). ## 5. Сквозной сценарий экрана «Проверка распознавания» [41] | Шаг | Вызовы | |---|---| | Открыть документ из панели [39] | список документов — `GET /legal-entities/{id}/files` (`documents[]`: `id`, `document_type`, `status`, `comments_count`); имена загрузивших — `GET /users/brief?ids=…` | | Левая панель «Исходный документ» | `GET /files/{file_id}/content?disposition=inline` (blob с токеном), `file_id` — из `StatementOut.document.file_id` или карточки файла | | Правая панель «Результат распознавания» | `GET /documents/{id}/statement`: шапка, колонки, строки со значениями; блок «Логические ошибки» — `checks.failed` / `soft_failed` / `cross_form`; подсветка — `confidence < 100`, `is_recognized = false`, `is_modified` | | Переход между документами организации (выпадающий список «Документ») | тот же `GET /documents/{id}/statement` по другому `document_id` | | «Редактировать» → «Сохранить» [41-02] | `PATCH /documents/{id}/statement {values:[…], header:{…}}` → в ответе новое состояние формы (проверки, статус, период пересчитаны) | | Нарушены жёсткие проверки, данные верны | `POST /documents/{id}/statement/checks/accept {comment}` → `checks.accepted` заполнен | | «Подтвердить данные» | `POST /documents/{id}/confirm`; 409 `PERIOD_REQUIRED` — предложить поправить дату отчёта; 409 `CHECKS_NOT_PASSED` — показать `detail.failures` | | «Добавить комментарий» | `POST /documents/{id}/comments {text}`, список — `GET /documents/{id}/comments` | | После подтверждения — чек-лист [37] | `GET /consolidations/versions/{id}/checklist` (ячейка «Бух. баланс»/«ОФР» — `CONFIRMED`), «Скачать» — тот же маршрут с `format=csv` |