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