M-7: вставка про достройку строк разорвала перечисление в 05-domain-rules.md §11, вынесена отдельным предложением. M-8: в api-contract-slice-3b.md у LineOut/ValueOut добавлены id | null и origin, у StatementPatch.values - line_id | line_code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
169 lines
26 KiB
Markdown
169 lines
26 KiB
Markdown
# Контракт 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 <JWT>`. Права: чтение формы, комментариев и исходного файла — `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=<uuid>,<uuid>…` | любой аутентифицированный | 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`
|
||
(`<a href>` и `<img src>` без заголовка не сработают). `Content-Type` — по типу файла (`application/pdf`, `image/png`,
|
||
`image/jpeg`, для xls/xlsx/xlsb — свои MIME). `Content-Disposition`: `attachment` (по умолчанию) или `inline` (для просмотра в
|
||
браузере) с ASCII-именем `file.<ext>` и `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` |
|