# 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` ```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 новой ячейки), `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:` из 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` после ручного создания и очистки строки |