2026-10-07 13:31:50 +04:00

182 lines
25 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.

# 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 как формула |