22 KiB
Raw Blame History

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

  1. OQ-146: формулы §7 и справочники по редакциям 66n / fsbu4.
  2. OQ-152 / I-2 из 3a: повтор кода 2410 в OCR — ядро получает уже отфильтрованную строку.
  3. Нужен ли аудит скачивания первички (ТЗ 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; аналитик форму уже просмотрел