Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
23 KiB
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 (в details — список нарушений); 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). Прямая ссылка на 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
- OQ-146: формулы §7 и справочники по редакциям
66n/fsbu4. - OQ-152 / I-2 из 3a: повтор кода 2410 в OCR — ядро получает уже отфильтрованную строку.
- Нужен ли аудит скачивания первички (ТЗ 8.2 «выгрузка») — сейчас нет, как у экспорта.
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; 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 в этом тесте не сверяется (покрывается тестами форм) |