Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
182 lines
25 KiB
Markdown
182 lines
25 KiB
Markdown
# Design: Срез 3b — формы ББ/ОФР в ядре и API до сводной ОСВ
|
||
|
||
## Context
|
||
|
||
Срез 2c построил маппинг результата распознавания в ядре (фоновый цикл, `map_file`, автозамена, `counterparty_check`,
|
||
статусы документа) только для ОСВ. Срез 4 построил привязку подтверждённых документов к версиям консолидации и чек-лист;
|
||
обе механики уже различают `BALANCE`/`INCOME` (нарастающие, `Q1` только своей версии — решения №10, R-14a Среза 4).
|
||
Модели `financial_statement_*` и `comment` есть в ORM и миграциях (DBML v5.16), но нигде не пишутся. Срезы 2b и 3a
|
||
отдают `statementDocument` v1.2 из Excel и PDF; самопроверку §7 tabular-parser кладёт в `checks`, scan-gateway — нет
|
||
(3a D-5: проверки считает ядро для обоих источников).
|
||
|
||
## Goals / Non-Goals
|
||
|
||
Goals: формы ББ/ОФР из обоих источников в БД; API экрана проверки с правкой, проверками §7, акцептом и подтверждением;
|
||
закрыть дыры API пути пользователя до сводной ОСВ. Non-goals — `proposal.md`.
|
||
|
||
## Decisions
|
||
|
||
### D-1. Маппинг форм — ветка `map_file`, а не отдельный цикл (решение владельца, подход 1)
|
||
`domain/statement_mapping.py` — чистые функции (черновики документа, шапки, колонок, строк, значений; период; статус);
|
||
`map_file` после листов ОСВ вызывает их и вставляет строки в той же транзакции. Порядок: очистка прежних данных файла
|
||
(в т.ч. `financial_statement_*` его документов) → документы ОСВ и форм → строки → статус файла → автозамена →
|
||
`on_documents_mapped`. `_load_result` и `map_file` бросают `MappingFatal` только при пустом `documents`
|
||
(«в результате нет документов»). Отдельный цикл отвергнут: два цикла делили бы файл, его статус и автозамену.
|
||
|
||
### D-2. Документ формы, период, шапка
|
||
- `document`: `document_type` = тип формы, `osv_account_code = NULL`, `is_cumulative = NULL` (нарастающий, №10 Среза 4),
|
||
`locator` из результата, `period_check = UNKNOWN`.
|
||
- **Период**: дата = `header.report_date`, иначе самая поздняя `columns[].period_date`; `period_year` = год даты;
|
||
`period_quarter` по месяцу, если дата — последний день марта, июня, сентября или декабря; иначе квартал `NULL` и
|
||
предупреждение `<locator>: дата <дата> не конец квартала`. Без даты — год и квартал `NULL`, предупреждение.
|
||
`reporting_date` = эта дата.
|
||
- **Шапка** (`financial_statement_meta`): `statement_type` (`balance`/`income`); `inn` = `header.inn`,
|
||
`inn_original_value` = `header.inn`, `inn_confidence`, `inn_verified = (inn_confidence == 100)`; `organization_name`;
|
||
`report_date` = дата из шапки (не из колонок), `report_date_original_value = header.report_date_text`,
|
||
`report_date_verified = (report_date is not None)`; `units` = `units_original_value` = `header.units`,
|
||
`units_verified = (units is not None)`; `okud`, `okpo`, `okopf`, `okfs`, `address` — как пришли;
|
||
`*_modified = false`. Поля `*_original_value` заполняются при маппинге и больше не меняются (DBML: «как распознано»).
|
||
- **Колонки**: `column_index`, `label`, `period_date`; `is_current = true` у колонки с самой поздней `period_date`
|
||
(при равенстве — с меньшим `column_index`), у остальных `false`; без дат — у всех `NULL`.
|
||
- **Строки**: `line_code`, `line_code_original_value = line_code`, `line_code_confidence`, `line_name`,
|
||
`line_name_original_value = line_name`, `line_name_confidence`, `*_modified = false`. Повтор кода в результате (контракт
|
||
этого не запрещает) — остаётся первая строка, предупреждение (DBML: `(meta_id, line_code)` уникален).
|
||
- **Значения**: на каждую пару «строка × колонка» одна запись; из результата — `amount`, `string_value`, `confidence`,
|
||
`is_recognized`; ячейки нет в результате — `amount = NULL`, `string_value = NULL`, `confidence = NULL`,
|
||
`is_recognized = NULL`. `is_modified = false`, `original_amount = NULL` до первой правки (DBML: «значение до ручной
|
||
правки»). Значение с `column_index` вне колонок формы — пропускается с предупреждением.
|
||
- `checks` результата игнорируются: ядро считает §7 само (D-4).
|
||
|
||
### D-3. Повтор типа формы в файле, `DISCLOSURE`/`OTHER`
|
||
Индекс `document (stored_file_id, document_type, osv_account_code)` NULLS NOT DISTINCT разрешает один документ каждого
|
||
типа формы на файл. Второй `BALANCE`/`INCOME` того же файла не создаётся: предупреждение `<locator>: вторая форма
|
||
<тип> в файле пропущена (OQ-77)`, первый документ — `NEEDS_CHECK`. `DISCLOSURE` и `OTHER` — один документ на тип на файл:
|
||
`locator` — `locator` всех таких документов результата через запятую, статус `RECOGNIZED`, данных нет (OQ-23). Документ
|
||
`BALANCE`/`INCOME` без строк (форма без колонки кодов, `st03`) создаётся с шапкой и колонками (если они есть в результате), статус `NEEDS_CHECK`.
|
||
|
||
### D-4. Проверки §7 — перенос tabular-parser, словарь — копия
|
||
`domain/statement_checks.py`: `run_checks(form, columns, amounts, rules) -> ChecksReport` — дословный по семантике
|
||
перенос `tabular-parser/api/src/parser/statements/checks.py`: соотношение по каждой колонке, только если присутствует
|
||
его цель (левая часть); отсутствующий код = 0; допуск `tolerance` (1) в единицах формы; жёсткое нарушение не больше
|
||
`rounding_tolerance_percent` (0,001 %) от наибольшего по модулю числа соотношения — мягкое «в пределах округления»;
|
||
правая часть считается как записано и с `abs_terms` по модулю, выполнено, если хоть один вариант в допуске; мягкая
|
||
проверка «сумма пятизначных подстрок = родитель». Словарь — копия `tabular-parser/api/rules/statement_checks.yaml` в
|
||
`api/rules/statement_checks.yaml` с пометкой источника; тест идентичности между репозиториями не ставится (урок #33:
|
||
связка репозиториев ломала main), расхождение копий — известный риск, отмечается в `03-architecture.md`.
|
||
**Суммы** — текущие (после правок) `financial_statement_value.amount`.
|
||
|
||
### D-5. Межформенная проверка — мягкая
|
||
`1370(колонка отчётной даты ББ) − 1370(колонка 31 декабря прошлого года ББ) = 2400(колонка отчётного периода ОФР)` с
|
||
тем же допуском. Пара: другой документ формы того же ЮЛ, `period_year` и `period_quarter`, файла не `SUPERSEDED`
|
||
(подтверждённый предпочтительнее, затем самый поздний `created_at`). Считается, только если найдены пара и обе колонки;
|
||
нарушение — мягкое (в `checks.cross_form`), подтверждение не блокирует: ОФР часто приходит отдельным файлом позже.
|
||
|
||
### D-6. Статус документа формы
|
||
`RECOGNIZED`, только если одновременно: задание `done`; нет непройденных неакцептованных жёстких проверок;
|
||
`counterparty_check ≠ MISMATCH`; `period_year` и `period_quarter` определены; у каждого значения либо `is_modified`,
|
||
либо `is_recognized` не `false` и `confidence` равна 100 или `NULL`; нет предупреждений маппинга по документу; есть хотя
|
||
бы одна строка. Иначе `NEEDS_CHECK`. Статус пересчитывается при маппинге, после каждой правки и после акцепта (кроме
|
||
`CONFIRMED`).
|
||
|
||
### D-7. Правка формы — значения и три поля шапки, только до подтверждения (решения владельца, вопросы 1 и 2)
|
||
`PATCH /documents/{id}/statement`: `header.inn` (10 или 12 цифр, иначе 422 `INVALID_INN`; пересчёт
|
||
`counterparty_check`), `header.report_date` (дата; пересчёт `period_year`, `period_quarter`, `reporting_date` по D-2),
|
||
`header.units` (непустая строка ≤ 50); `values[] {line_id, column_id, amount}` (Decimal, 2 знака; `null` допустим).
|
||
Значение: если `original_amount` пуст и значение ещё не правилось — `original_amount` = прежняя сумма; `amount` =
|
||
новая; `is_modified = (amount != original_amount)`; `modified_by/at`. Поле шапки: `*_modified = true`, исходник в
|
||
`*_original_value` не меняется. Разрешено только для `RECOGNIZED`/`NEEDS_CHECK` незаменённого файла: `CONFIRMED` —
|
||
409 `DOCUMENT_READONLY`, файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`, документ не ББ/ОФР — 409 `DOCUMENT_NOT_STATEMENT`;
|
||
`line_id`/`column_id` не этого документа — 422 `UNKNOWN_CELL`. После правки: сброс акцепта, пересчёт проверок и
|
||
статуса; одно `audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` на запрос с `before/after` изменённых полей и ячеек.
|
||
Пустой запрос (ничего не меняет) — 200 без аудита. Блокировки — порядок подтверждения: файл `FOR UPDATE` → документ
|
||
`FOR NO KEY UPDATE`.
|
||
|
||
### D-8. Акцепт проверок
|
||
`POST /documents/{id}/statement/checks/accept {comment}` (`DOCUMENT_EDIT`; комментарий 1–2000 символов): разрешён при
|
||
непройденных жёстких проверках, иначе 409 `NOTHING_TO_ACCEPT`; те же 409, что у правки. Пишет `checks_accepted_by/at/
|
||
comment`, пересчитывает статус, `audit_event(RECOGNIZED_DATA_EDIT, RECOGNITION)` с `before/after` полей акцепта.
|
||
Отдельного `audit_action` нет; новое значение enum потребовало бы DBML v5.17 без пользы.
|
||
|
||
### D-9. Подтверждение формы
|
||
`POST /documents/{id}/confirm` для `BALANCE`/`INCOME` дополнительно: непройденные неакцептованные жёсткие проверки —
|
||
409 `CHECKS_NOT_PASSED` (в `detail.failures` — список нарушений); `period_year` или `period_quarter` пуст — 409
|
||
`PERIOD_REQUIRED`. Порядок проверок: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` →
|
||
`PERIOD_REQUIRED` → `CHECKS_NOT_PASSED`. Пишет `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента и
|
||
низкая уверенность подтверждение не блокируют (как у ОСВ). `DISCLOSURE`/`OTHER` — без условий.
|
||
|
||
### D-10. Отдача исходного файла
|
||
`GET /files/{id}/content?disposition=attachment|inline` (`DOCUMENT_VIEW`, policy по ЮЛ): поток объекта `uploads/…`
|
||
файла из S3 частями (без чтения целиком в память); `Content-Type` по `file_type` (`application/pdf`, `image/png`,
|
||
`image/jpeg`, xls/xlsx/xlsb — свои MIME); `Content-Disposition` — ASCII-имя `file.<ext>` и `filename*` RFC 5987 с
|
||
исходным именем (как экспорт сводной ОСВ); работает и для `SUPERSEDED`; объект истёк или пропал — 404
|
||
`FILE_CONTENT_NOT_FOUND`. Аудит не пишется (в отличие от экспорта сводной ОСВ в xlsx, который пишет `EXPORT`; см. OQ-153). Прямая ссылка на S3 отвергнута: в контуре фронт может
|
||
не видеть MinIO, а через ядро действует policy.
|
||
|
||
### D-11. Комментарии к документу
|
||
`comment(object_type = 'document', object_id, text, author_id, created_at)`. `POST /documents/{id}/comments {text}`
|
||
(`DOCUMENT_EDIT`, 1–4000 символов после обрезки пробелов) и `GET /documents/{id}/comments` (`DOCUMENT_VIEW`, по
|
||
`created_at`, без пагинации — комментариев к документу единицы) — ответ `{id, text, author: {id, full_name},
|
||
created_at}`. Правки и удаления нет. Комментировать можно документ в любом статусе и файла `SUPERSEDED`. В карточке
|
||
документа (`DocumentBrief` и `DocumentOut`) — `comments_count`. Аудит не пишется: `comment` сам хранит автора и время.
|
||
|
||
### D-12. Справочник имён пользователей
|
||
`GET /users/brief?ids=<uuid>,<uuid>…` — любой аутентифицированный пользователь; ≤ 200 id (иначе 422
|
||
`TOO_MANY_IDS`); ответ `[{id, full_name, is_active}]` по найденным id, включая неактивных; неизвестные id не
|
||
возвращаются. Админский `GET /admin/users` не меняется.
|
||
|
||
### D-13. Число документов у ЮЛ и CSV чек-листа
|
||
`LegalEntityOut.documents_count` — число документов файлов ЮЛ со статусом файла не `SUPERSEDED`, подзапросом на
|
||
страницу списка и в карточке. `GET …/versions/{id}/checklist?format=csv` — `text/csv; charset=utf-8` с BOM,
|
||
разделитель `;`, строка заголовка «Организация;ИНН;<правило>…» (правило — «Бух. баланс», «ОФР», «ОСВ <счёт>»), ячейки —
|
||
подписи макета: `CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK`/`RECOGNIZED` «Требуется проверка»,
|
||
`ERROR` «Ошибка загрузки», `MISSING` «Отсутствует» (+ « (нет: Q1, Q2)» при `missing_quarters`); имя файла
|
||
`checklist-<ГК>-<год>-<квартал>.csv` в RFC 5987. Аудит не пишется.
|
||
|
||
### D-14. Редакция формы в ответе
|
||
`GET …/statement` отдаёт `edition`: `66n`, если дата периода документа (D-2) ≤ 2024-12-31, `fsbu4` — если позже,
|
||
`null` без даты. Фронту — для выбора шаблона строк; на проверки §7 в 3b не влияет (OQ-146).
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- OCR-формы почти всегда `NEEDS_CHECK` (уверенность < 100, мусорный ИНН) — ожидаемо, аналитик правит.
|
||
- Копия словаря §7 может разойтись с tabular-parser; документировано.
|
||
- Правка даты отчёта может дать документу период, совпадающий с другим файлом ЮЛ; автозамена при правке не
|
||
запускается (только при маппинге) — в чек-листе привязывается подтверждённый позже.
|
||
- Межформенная проверка мягкая — расхождение 1370/2400 не блокирует подтверждение.
|
||
|
||
## Migration Plan
|
||
|
||
Миграции нет. Уже смапленные файлы с формами (до 3b формы игнорировались) форм не получают: повторный маппинг —
|
||
повторная загрузка файла пользователем (автозамена) или ручной сброс файла в `PROCESSING`; в dev-стенде — `make
|
||
seed-dev` с нуля.
|
||
|
||
## Open Questions
|
||
|
||
1. OQ-146: формулы §7 и справочники по редакциям `66n` / `fsbu4`.
|
||
2. OQ-152 / I-2 из 3a: повтор кода 2410 в OCR — ядро получает уже отфильтрованную строку.
|
||
3. Нужен ли аудит скачивания первички (ТЗ 8.2 «выгрузка») — сейчас нет (экспорт сводной ОСВ в xlsx пишет `EXPORT`, первичка и CSV — нет).
|
||
|
||
## Decisions made without discussion
|
||
|
||
| № | Дата | Решение | Обоснование | Цена ошибки |
|
||
|---|---|---|---|---|
|
||
| 1 | 2026-10-07 | Период формы без даты отчёта — по самой поздней дате колонок; квартал только по концу квартала | дата отчёта в OCR часто мусор (`aep_6`), колонки надёжнее; иначе привязка к неверной версии | неверный период при перепутанных колонках — аналитик правит дату |
|
||
| 2 | 2026-10-07 | `*_original_value` шапки заполняются при маппинге, `original_amount` — при первой правке | так в DBML: «как распознано» у шапки и «до ручной правки» у значения | — |
|
||
| 3 | 2026-10-07 | Значение на каждую ячейку «строка × колонка», отсутствующие — `NULL` | у любой ячейки есть `id` для правки; фронту не нужно создавать значения | лишние строки в БД (единицы процентов) |
|
||
| 4 | 2026-10-07 | Межформенная проверка мягкая | ОФР часто приходит позже; жёсткая блокировала бы подтверждение ББ | расхождение 1370/2400 не останавливает аналитика |
|
||
| 5 | 2026-10-07 | Акцепт сбрасывается любой правкой | данные изменились — проверку нужно смотреть заново | лишний повторный акцепт |
|
||
| 6 | 2026-10-07 | Аудит акцепта — `RECOGNIZED_DATA_EDIT` | нового `audit_action` без DBML v5.17 нет | фильтр журнала по акцепту — по `object_type`/полям |
|
||
| 7 | 2026-10-07 | Контрагент и уверенность не блокируют подтверждение | паритет с ОСВ; шум OCR в ИНН | подтверждение чужой формы — защищает статус `NEEDS_CHECK` и подсветка |
|
||
| 8 | 2026-10-07 | Скачивание файла, CSV, комментарии — без аудита | спека явно: аудит не пишется (экспорт xlsx пишет `EXPORT` — осознанное отличие, OQ-153); `comment` хранит автора | журнал не покажет скачивания (OQ в docs) |
|
||
| 9 | 2026-10-07 | `GET /users/brief` без отдельного права | имена нужны всем экранам; данные — только ФИО и активность | ФИО видны всем аутентифицированным |
|
||
| 10 | 2026-10-07 | Нет теста идентичности словаря §7 с tabular-parser | урок #33: межрепозиторные тесты ломают main | копии разойдутся незаметно |
|
||
| 11 | 2026-10-07 | R-1: признак «повтор формы в файле» при маппинге хранится множеством типов форм и передаётся в `statement_status` как предупреждение; первый документ типа — `NEEDS_CHECK` | поведение по спеке («Повтор формы в файле»); фрагмент плана с разбором строк предупреждений — иллюстрация | — |
|
||
| 12 | 2026-10-07 | R-2: пересчёт статуса формы после правки и акцепта не учитывает предупреждения маппинга (`warnings = ()`) | предупреждения маппинга в БД хранятся только на уровне файла (`metrics.mapping_warnings`) без привязки к документу; хранить их отдельно — новое поле, DBML v5.17 | документ с предупреждением маппинга (повтор кода, значение вне колонок, пропущенная вторая форма) после правки аналитиком может стать `RECOGNIZED`; аналитик форму уже просмотрел |
|
||
| 13 | 2026-10-07 | R-3: тест плана `test_per_column_and_null_as_zero` заменён тестами «отсутствующий код = 0 → нарушение» | тест плана противоречил спеке statement-checks: при цели 1600 и отсутствии 1700 соотношение `1600 = 1700` нарушено | потеряно покрытие «по колонкам/None» (досыпается в тест-доработке) |
|
||
| 14 | 2026-10-07 | R-4: формулировка «форма без строк создаётся с шапкой и колонками» уточнена до «колонками, если они есть в результате» | golden `st03_no_codes` содержит `columns = []` и `lines = []`; код создаёт форму с тем, что есть | — |
|
||
| 15 | 2026-10-07 | R-5: сверка golden ОСВ в `test_mapping_golden` ограничена документами `OSV` | смешанный снимок `expected_osv_parse/synthetic/s12_statement_and_osv.json` содержит BALANCE, которую теперь тоже маппит ядро; сравнение ОСВ-строк не ослаблено | форма из s12 в этом тесте не сверяется (покрывается тестами форм) |
|
||
| 16 | 2026-10-07 | R-6: документ `BALANCE`/`INCOME` без `financial_statement_meta` (создан до Среза 3b) подтверждается без условий D-9; `PATCH`/`GET …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT` | проверять нечего: формы в БД нет; блокировать подтверждение старых документов 3b не вправе | старый документ без данных формы подтверждается без проверок периода/§7 |
|
||
| 17 | 2026-10-07 | R-7: список нарушений `CHECKS_NOT_PASSED` отдаётся в `detail.failures` (строки сумм), не в отдельном ключе `details` | единый формат ошибок ядра `{"detail": {"code","message", **extra}}` | — |
|
||
| 18 | 2026-10-07 | R-8: при удалении файла (`DELETE /files/{id}`) комментарии `comment(object_type='document')` удалённых документов остаются в таблице | связь полиморфная, FK нет; чтение идёт через видимый документ, наружу строки не попадают; спека о судьбе комментариев молчит | осиротевшие строки `comment` (единицы на файл); чистка — отдельной задачей |
|
||
| 19 | 2026-10-07 | R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (`=`, `+`, `-`, `@`) | спека не требует; названия из справочника ЕЦП внутреннего контура | теоретическая формула в названии ЮЛ откроется в Excel как формула |
|