Andrey Epifancev fb491a3e93 openspec: архив statement-template-lines (#80)
Спеки documents-api и statement-checks обновлены дельтой change'а; change перенесён в
openspec/changes/archive/2026-10-09-statement-template-lines/. Реализация — PR #81.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:55:14 +04:00

21 KiB
Raw Blame History

Design: полный состав строк ББ/ОФР на экране проверки

Context

Форма ББ/ОФР в ядре — financial_statement_meta → financial_statement_column (колонки-периоды) → financial_statement_line (только строки с кодом, (meta_id, line_code) уникален) → financial_statement_value ((line_id, column_id) уникален). Строки и ячейки пишет только маппинг (repositories/statements.insert_statement), ровно то, что пришло в statementDocument от scan-gateway или tabular-parser. Экран проверки читает форму GET /documents/{id}/statement (services/statements.StatementService.build_out, порядок строк — по коду), правит PATCH …/statement (_apply_cells: lock_cells по (line_id, column_id) под FOR UPDATE; чужая или отсутствующая ячейка — 422 UNKNOWN_CELL). Проверки §7 (domain/statement_checks.run_checks) считают цель присутствующей, если её код есть среди строк формы; пустая сумма — 0. Статус D-6 (statement_status) учитывает values_ok по всем ячейкам и has_lines. Редакция формы — edition_for(document.reporting_date): 66n (≤ 2024-12-31) / fsbu4 / None. Раскладку «как на бумаге» по разделам делает фронт (arm-web/src/widgets/DocumentReview/formLayout.ts).

Справочник «код → наименование» по редакциям есть только в scan-gateway (api/rules/statement_lines.yaml): 66n заполнен (из старого ядра), fsbu4 пуст (OQ-150).

Goals / Non-Goals

Goals: аналитик видит все строки формы своей редакции и может заполнить любую, включая потерянную источником; уже загруженные документы получают это без миграции; распознанные данные не меняются; пустые строки не меняют статус, проверки и автоподтверждение.

Non-Goals: см. proposal.

Decisions

D-1. Достраивать в ответе, а не в БД (подход B, решение владельца 09.10.2026)

БД хранит только пришедшее из источника; достройка — чистая функция _lines_out в schemas/statements.py (R-9) поверх прочитанной формы в build_out; справочник и выбор строк по редакции — domain/statement_lines.py. Альтернатива «дописать строки при маппинге» отклонена: нужна data-миграция для уже загруженных документов, в БД появляются строки, которых не было в первичке, и проверки §7 начали бы видеть пустые итоги (пустая 1700 при непустых 1300–1500 — жёсткое нарушение), то есть изменился бы статус документов.

D-2. Справочник — данные ядра api/rules/statement_lines.yaml

editions:
  - id: 66n
    forms:
      BALANCE: [{code: "1110", name: "Нематериальные активы"}, ...]   # порядок формы
      INCOME:  [...]
  - id: fsbu4
    forms: {BALANCE: [...], INCOME: [...]}

Список, а не словарь — порядок строк формы сохраняется (пригодится фронту и будущему порядку в ответе; сейчас ответ сортируется по коду, как раньше). Загрузка — лениво, один раз на процесс (lru_cache у load_line_catalog), проверки вручную в load_line_catalog/_form_lines (ошибка формата — ValueError): коды — четыре цифры, без повторов внутри формы, обе формы в обеих редакциях. Границы редакций в yaml не хранятся: редакция выбирается существующим edition_for — новой логики выбора нет.

Состав (решение владельца 09.10.2026 — из шаблонов ../fixtures/statements-xlsx/templates/):

  • 66n, ББ («ББ до 01.01.2025»): 1110, 1120, 1130, 1140, 1150, 1160, 1170, 1180, 1190, 1100, 1210, 1220, 1230, 1240, 1250, 1260, 1200, 1600, 1310, 1320, 1340, 1350, 1360, 1370, 1300, 1410, 1420, 1430, 1450, 1400, 1510, 1520, 1530, 1540, 1550, 1500, 1700 (37).
  • 66n, ОФР («ОФР до 01.01.2025»): 2110, 2120, 2100, 2210, 2220, 2200, 2310, 2320, 2330, 2340, 2350, 2300, 2410, 2411, 2412, 2460, 2400, 2510, 2520, 2500, 2900, 2910 (22).
  • fsbu4, ББ («ББ после 01.01.2025»): 1110, 1150, 1160, 1170, 1180, 1190, 1100, 1210, 1215, 1220, 1230, 1240, 1250, 1260, 1200, 1600, 1310, 1320, 1340, 1350, 1360, 1370, 1300, 1410, 1420, 1430, 1450, 1400, 1510, 1520, 1540, 1550, 1500, 1700 (34).
  • fsbu4, ОФР («ОФР после 01.01.2025»): 2110, 2120, 2100, 2210, 2220, 2200, 2310, 2320, 2330, 2340, 2350, 2300, 2410, 2411, 2412, 2420, 2400, 2510, 2520, 2530, 2500, 2900, 2910 (23).

Наименования — из тех же шаблонов; склейки с заголовками разделов и сносками исправляются (шаблон склеил объединённые ячейки): 1110 «АКТИВ» → «Нематериальные активы», 1210 «II. Оборотные активы» → «Запасы», 1310 «ПАССИВ» → «Уставный капитал (складочный капитал, уставный фонд, вклады товарищей)», 1410 «IV. Долгосрочные обязательства» и 1510 «V. Краткосрочные обязательства» → «Заемные средства», 2411 «в том числе:» → «текущий налог на прибыль», хвосты сносок («Выручка 5», «Налог на прибыль 7», «( / )») и префикс «Справочно» у 2900 убираются, переносы строк — в пробел.

Строки источника, которых нет в справочнике (пятизначные 11101, коды чужой редакции, 1105 «Гудвил» у OCR), остаются как есть: справочник только добавляет, ничего не убирает и не переименовывает.

D-3. Достройка ответа

Достройка (по факту — _lines_out в schemas/statements.py, R-9; логика та же):

  • для каждой строки из БД — недостающие ячейки по всем columns (id = null, amount = null, string_value = null, confidence = null, is_recognized = null, is_modified = false, original_amount = null, modified_* = null);
  • при edition ≠ None — строки справочника, кода которых нет среди строк формы: id = null, line_code, line_name из справочника, *_confidence = null, origin = "template", ячейки по всем колонкам как выше;
  • origin строки из БД: template, если line_code_original_value IS NULL (строка создана правкой, D-4), иначе source (маппинг всегда пишет line_code_original_value = line_code);
  • итог — по line_code (как сейчас). Форма без колонок (OCR без шапки) получает строки без ячеек — заполнить нечего, пока не появятся колонки (их ядро не создаёт; Non-goal).

D-4. Правка по коду строки

CellPatch: line_id: uuid | None, line_code: str | None (ровно одно, иначе 422 валидации тела), column_id, amount. _apply_cells (под существующим порядком блокировок «файл FOR UPDATE → документ FOR NO KEY UPDATE», шапка с refresh):

  1. Колонка должна принадлежать форме, иначе 422 UNKNOWN_CELL.
  2. line_id: строка формы (иначе UNKNOWN_CELL); если у неё нет ячейки в колонке — ячейка создаётся.
  3. line_code: если строка с таким кодом в форме уже есть — 422 UNKNOWN_CELL с сообщением «строка уже есть в форме — передайте line_id» (фронт после ответа знает id); если кода нет в справочнике редакции документа (или редакция не определена) — 422 UNKNOWN_CELL. Иначе создаётся financial_statement_line (line_code, line_name из справочника, line_code_original_value = NULL, line_name_original_value = NULL, *_confidence = NULL, *_modified = false) и ячейка. Две ячейки одной новой строки в одном запросе создают одну строку.
  4. Создаваемая ячейка: original_amount = NULL, is_recognized = NULL, confidence = NULL, string_value = NULL; затем общая логика правки: amount, is_modified = (amount ≠ original_amount), modified_by/at.
  5. amount = null для несуществующей ячейки — не изменение: ничего не создаётся, в аудит не попадает.
  6. Повтор ячейки в запросе (по (line_id|line_code, column_id)) — 422 DUPLICATE_CELL, как сейчас.

Гонки двух правок одной формы исключены блокировкой документа: второй запрос ждёт и видит строку, созданную первым (получит 422 «передайте line_id» и перечитает форму). Уникальный индекс (meta_id, line_code) — страховка.

Аудит — одно RECOGNIZED_DATA_EDIT на запрос, как сейчас; ключ созданной ячейки — тот же value:<id> (id новой ячейки), before = null.

D-5. Пустые шаблонные строки вне проверок и статуса

Достроенные строки в БД нет — statement_amounts, value_flags, has_lines, _line_amount (межформенная) их не видят. Созданная правкой строка (line_code_original_value IS NULL) участвует в statement_amounts, только если у неё есть хотя бы одна непустая сумма (фильтр в запросе). Так очищенная до пустоты строка снова «как будто её нет», а частично заполненная (1700 есть в текущей колонке, нет в прошлой) проверяется по всем колонкам, как строка источника, — расхождение в незаполненной колонке подскажет аналитику заполнить и её. values_ok её ячейки не тревожат: is_recognized = NULL и confidence = NULL — «без сомнений», правленные — is_modified.

D-6. Фронт (arm-web, PR в test)

statementGrid: ключ строки — line.id ?? "code-" + line_code (R-10), ключ ячейки — rowKey:column_id; draftToPatch отправляет line_id или line_code по ключу. Достроенная пустая ячейка (id = null) без подсветки «требует проверки» (is_recognized = null), введённое значение подсвечивается как правка. formLayout уже раскладывает строки по разделам — меняется только то, что разделы теперь всегда полные. Строки «БАЛАНС» (1600 по активу, 1700 по пассиву) выделяются цветом (просьба владельца 09.10.2026: сейчас итог баланса сливается с обычными строками и «Итого по разделу»): фон всей строки — светло-фиолетовый (#f9f0ff, purple-1 палитры antd): серый #f2f4f7 уже у заголовков разделов, светло-синий #e6f4ff — «исправлено», оранжевый #fff7e6 — «требует проверки»; шрифт 700 у наименования, кода и сумм. Подсветка ячейки «требует проверки» / «правлено» перекрывает фон строки — сигнал о данных важнее оформления. Признак — formLayout (LayoutRow.grand: true для 1600/1700 у BALANCE), цвет — StatementTable. В arm-web — свой OpenSpec change statement-template-lines (дельта document-review-ui). Сверка с макетом [41] (PNG docs/mockup/screens/) в Playwright и ручной прогон сценария: пустая строка → ввод → «Сохранить» → проверки пересчитаны.

Risks / Trade-offs

  • Две копии справочника (ядро и scan-gateway) расходятся: в ядре 66n — по шаблону банка (без 1105, 1215, 1330 и с «Доходные вложения в материальные ценности» у 1160), в scan-gateway — словарь старого ядра. Наименования строк источника не трогаются, так что расхождение видно только в наименовании достроенных строк. Follow-up: заполнить fsbu4 в scan-gateway и свести копии.
  • Шаблоны банка — не нормативный текст. Если в ФСБУ 4/2023 окажутся строки, которых нет в шаблоне, они не достроятся; правится данными (statement_lines.yaml), без кода.
  • Ответ GET растёт на 30–40 строк × колонки — несущественно.

Decisions made without discussion

# Дата Решение Обоснование Цена ошибки
R-1 09.10.2026 Состав 66n в ядре — из шаблонов банка «до 01.01.2025», а не копия словаря scan-gateway (в чате согласовано «копия») В словаре scan-gateway у 66n коды и имена эпохи ФСБУ (1105 «Гудвил», 1215, 1160 «Инвестиционная недвижимость»), а шаблон банка — тот же источник, что выбран для fsbu4; одна методика для обеих редакций Неверное наименование или лишняя/недостающая достроенная строка у отчётности до 2025 года; правится данными
R-2 09.10.2026 Наименования склеенных строк шаблона исправлены по форме (1110, 1210, 1310, 1410, 1510, 2411, сноски) Склейка — артефакт объединённых ячеек шаблона, а не наименование строки Неточное наименование достроенной строки; правится данными
R-3 09.10.2026 Созданная правкой строка участвует в проверках §7, только пока у неё есть непустая сумма Решение владельца «пустая строка не влияет на проверки» должно сохраняться и после очистки Аналитик очистил итог — нарушение исчезло вместе с ним; так же ведёт себя отсутствующая строка источника
R-4 09.10.2026 Без редакции (нет даты периода) строки не достраиваются, недостающие ячейки — достраиваются Не из чего выбрать состав; дата периода нужна и для подтверждения (PERIOD_REQUIRED) — после её правки строки появятся Аналитик не видит пустых строк, пока не введёт дату отчёта
R-5 09.10.2026 line_code для кода, уже существующего в форме, — 422 UNKNOWN_CELL, а не тихая правка существующей строки Один способ адресации существующей ячейки (line_id), второй запрос в гонке не перезапишет чужую правку молча Лишний круг фронта: перечитать форму и повторить
R-6 09.10.2026 Ячейка, которой нет у существующей строки, создаётся при правке по line_id Та же проблема, что с отсутствующей строкой; отдельного API не требует —
R-7 09.10.2026 Цвет строк «БАЛАНС» — светло-фиолетовый #f9f0ff + жирный 700; подсветка ячейки перекрывает фон строки; ОФР не выделяется Владелец просил выделить баланс по активу и пассиву, цвет не задал; в макете [41] строки «БАЛАНС» не видно; фиолетовый не занят: серый — разделы, синий — «исправлено», оранжевый — «требует проверки» Цвет не понравится — правка одной константы
R-8 09.10.2026 has_lines (условие D-6 «в форме есть строки») учитывает и строки, созданные правкой Форму, у которой источник не дал строк (не найдена колонка кодов), аналитик может заполнить вручную — это его данные, как любая правка; статус всё равно требует пройденных проверок и завершённого задания Документ без строк источника станет RECOGNIZED после ручного заполнения — ожидаемо
R-9 09.10.2026 Достройка формы реализована функцией _lines_out в schemas/statements.py, а не domain/statement_lines.complete_form из D-3 План конкретнее, поведение то же Рефакторинг в domain позже
R-10 09.10.2026 Ключ достроенной строки на фронте code-<code>, а не code:<code> из D-6 ':' разделяет rowKey и columnId в cellKey Нет
R-11 09.10.2026 Адресация ячеек PATCH вынесена из _apply_cells в _resolve_cells (отклонение от кода плана ради ruff PLR0912/PLW2901); поведение и порядок проверок сохранены Линтер Нет
R-12 09.10.2026 Правка фронта шаблонных строк перенесена и во встроенный arm-core/web в этой же ветке (те же файлы, что в arm-web) Иначе после слияния встроенный фронт отправляет line_id "null" и дублирует ключи строк Дифф PR вырос; если arm-core/web не поддерживается — откатить
R-13 09.10.2026 M-2, M-3, M-4, M-5, M-6 финального ревью отложены в follow-up Не блокируют слияние M-2: форма без сумм может стать RECOGNIZED после ручного создания и очистки строки