Andrey Epifancev 89abf0a8ec openspec: архив change slice-3b-statements-api (2026-10-07) — спеки documents-api, osv-result-mapping, statement-checks и др.
Ссылки README и CLAUDE.md переведены на архив.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 15:23:35 +04:00

29 KiB
Raw Permalink 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 (в 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 (единицы на файл); чистка — отдельной задачей. Закрыто (fix wave, T8.2): комментарии удаляются вместе с документами в delete_file_rows — удаление файла и так обязано чистить зависимые от документов строки (формы ББ/ОФР, C-1), лишний DELETE дёшев, а осиротевшие строки не нужны никому
19 2026-10-07 R-9: значения ячеек CSV чек-листа (название ЮЛ, ИНН) не нейтрализуются от формул Excel (=, +, -, @) спека не требует; названия из справочника ЕЦП внутреннего контура теоретическая формула в названии ЮЛ откроется в Excel как формула
20 2026-10-07 R-11 (отозван): предложенное финальным ревью изменение «цель §7 присутствует в колонке, только если сумма не NULL» отклонено и откатано; присутствие цели — наличие строки в форме, пустая сумма (NULL) = 0, как _amounts в tabular-parser оба производителя (tabular-parser lines.py, scan-gateway normalizer/ocr/document.py) отдают значение на каждую колонку каждой кодированной строки, пустая ячейка — amount = null; парсер считает такую запись присутствующей, при ненулевых слагаемых даёт жёсткое нарушение; иное поведение отключало бы проверку итога правкой amount = null в обход акцепта — (поведение не менялось; тест фиксирует нарушение при пустой цели)
21 2026-10-07 R-12: PATCH header.inn = null → 422 INVALID_INN (очистить ИНН нельзя) иначе очистка ИНН переключала бы counterparty_check на сверку по имени и обходила MISMATCH; спека: «10 или 12 цифр, иначе 422» аналитик не может стереть мусорный ИНН — только заменить корректным
22 2026-10-07 R-13: при повторе column_index в columns[] результата или повторе пары (строка, колонка) в values[] оставляется первое, пишется предупреждение маппинга (документ NEEDS_CHECK) контракт повтор не запрещает; без этого IntegrityError ронял маппинг всего файла (I-1 финального ревью) потеря второго значения при ошибке парсера (виден в предупреждении)
23 2026-10-07 R-10: пакеты доработок A (тесты, PB.1/PB.2) и B отдельного task-ревью не проходили так же, как задачи (B — ревью получил, A — мутационная проверка 4 тестов) покрываются финальным ревью ветки пропуск слабого теста до финального ревью (финальное ревью прошло)
24 2026-10-07 R-14: откат M-1 (коммиты 51d322c, 6697194) отдельного ревью не проходил код statement_checks.py побайтно возвращён к состоянию до e851445 (только docstring и комментарии), откат предписан повторным opus-ревью, тест фиксирует поведение ошибка в новом тесте или docstring
25 2026-10-07 Автозамена файла для форм ББ/ОФР работает на уровне файла (ключ документа: тип, NULL-счёт, год, квартал): исправленный отдельный ОФР заменит прежнюю книгу «ББ + ОФР» того же периода, подтверждённый ББ из неё отвяжется от версий «в работе» поведение ОСВ (Срезы 2c/4) распространено на формы; замена по документам потребовала бы менять модель замены замена формы вместе с соседней; вынесено владельцу — OQ-154