2026-10-17 19:41:47 +04:00

213 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Независимое ревью design.md — 17.10.2026
Ревьюер: отдельный агент (модель Fable), без права правки. Задание: дыры в целевой картине — противоречия, разрывы
цепочки, необработанные входы, регрессии, граничные случаи `needs_review`/`done`, следствия для ядра и БД,
сопровождаемость Р-1, пропущенные различия. Решения Р-1…Р-7 не пересматривались.
Сверка перед передачей владельцу (сессия): подтверждены по коду и данным F-1 (`abs_terms` в
`tabular-parser/api/rules/statement_checks.yaml:22-25`; golden `fixtures/ocr/expected_normalize/aep_3.json` —
2120 = +7744536, `is_recognized=false`), F-2 (`null` → 0: `arm-core/.../statement_checks.py:202`,
`tabular-parser/.../checks.py:27-34`), F-3 (OQ-36/OQ-141 в §12 п. 5, строка 688 и 1137 `05-domain-rules.md`),
F-5 (`statement_headers.yaml:39` `code_caption: '^код(ы)?$'`), F-6 (поле `date` у ИС: aep_3 «2|0|2|4|», aep_6 и
«БО 1 кв 2025» — название организации), F-7 (`$comment` схемы: смена типа = MAJOR), F-19 (§11.1 п. 1, строка 967).
Остальные находки — по тексту агента, выборочно не перепроверялись.
Отчёт агента ниже без сокращений.
---
# Ревью дизайна `unified-statement-normalization/design.md`
Проверено: документ целиком; §7, §11, §11.1, §12 `05-domain-rules.md`; контракт v1.2 (`$defs.statementDocument`, `failedRelation`, `money`, корень с `$comment` о версионировании); DBML v5.18 (`document`, `financial_statement_meta/column/line/value`, `recognition_job`, enum `document_status`); код scan-gateway (`normalizer/ocr/*`, `rules.py`, `contract/*`), tabular-parser (`parser/statements/*`, `book.py`, `grid.py`, `numbers.py`, `rules.py`, `api/rules/*.yaml`, `tests/statements_manifest.yaml`), arm-core (`domain/statement_mapping.py`, `domain/statement_checks.py`, `domain/statement_lines.py`, `services/result_mapping.py`, `services/statements.py`, `services/auto_confirm.py`, `services/documents.py`, `repositories/statements.py`, `schemas/statements.py`, `models/statements.py`, `contract/*`, фронт `DocumentReview/statementGrid.ts`, `StatementTable.tsx`, `ChecksPanel.tsx`, `formLayout.ts`, `types/api.ts`); фикстуры `fixtures/ocr/*.json` и golden-эталоны `fixtures/ocr/expected_normalize/*` (скриптом); `docs/scripts/sync-web.sh`, `03-architecture.md` (политика версий контракта, риск копий словарей).
Числа из фикстур, на которые опираются находки:
- Позиций ячеек под колонками не хватает: 0 из 2224 (8 таблиц, 4 json) — предпосылка Р-2 верна.
- Уверенность ячеек **сумм** (не кодов и не наименований): 100 — 1109, 70–89 — 54, 1–69 — 75, 0 — 19, 90–99 — **1** (`aep_3` ОФР, «2100 I 150416», 92,3) — предпосылка Р-5 по сути верна.
- Строк «код пуст, в значениях есть число» (кандидаты в `missing`): aep_3 ББ 1 / ОФР 2; aep_6 ББ 18 / ОФР 2; «БО 1 кв 2025» ББ 20 / ОФР 2; «Русский бисквит 1 кв.25» ББ **40** / ОФР 12 — это строки «в том числе: …», «Оборудование к установке», «Товар (41.01)».
- Строк с кодом не из 4–5 цифр (`unreadable`): aep_3 ББ 8 («НИ», «изо :», «| 140», «[190 :», «1 100», «1220 ,», «12 /», «154л)», «420»), ОФР 1 («2.10», «24 12»); в чистых сканах 2–4 на таблицу.
- Доля кодов в столбце 1 по правилу парсера (4–5 цифр в диапазоне, порог 0,6): aep_3 ББ 0,78 (31 код), ОФР 0,87 — порог запасного прохода aep_3 не теряет.
- В aep_3 ОФР коды 2110 и 2100 стоят в колонке **значений** («211(1 |», «2100 I 150416»), ячейка кода пуста.
- Golden шлюза по aep_3 сегодня: ББ 28 строк (в т. ч. код «420» с уверенностью 99), ОФР 13 строк, 2120 = **+7744536** (`is_recognized=false`), статус `needs_check`.
---
## Critical
### F-1 — critical — «Подтверждение проверкой», Р-7 + `abs_terms`: проверка не видит знак, а Р-7 снимает подсветку → ошибка знака автоподтверждается
**Где:** шаг 8 «Подтверждение проверкой» (абзац про «(7744536}»), Р-7, шаг 7 п. 1.
**Дыра.** Соотношения ОФР считаются дважды — «как записано» и с модулем членов `abs_terms` (`tabular-parser/api/src/parser/statements/checks.py:57-74`, `arm-core/api/src/domain/statement_checks.py:127-138`, словарь: `2100 = 2110 - 2120, abs_terms: [2120]`, `2200 … abs_terms [2210, 2220]`, `2300 … [2330, 2350]`, `2400 … [2410]`). Для члена из `abs_terms` соотношение **сходится при любом знаке** значения: 2110 − (+7744536) и 2110 − |−7744536| дают одно и то же. Значит, сошедшееся жёсткое соотношение не может подтвердить знак такой ячейки, а Р-7 именно по сошедшемуся соотношению снимает подсветку с `is_recognized = false`.
**Сценарий.** ОФР: 2110 = 8 096 738 (100), 2120 = «(7744536}» — закрывающая скобка `}` минус не ставит (§11.1 п. 7, `amounts.py:58-80`; golden aep_3 — `+7744536`, `is_recognized=false`), 2100 = 352 202 (100). `2100 = 2110 − 2120` «как записано»: 8 096 738 − 7 744 536 = 352 202 — сошлось. По Р-7 ячейка 2120 покрыта → `needs_review = false`, подсветки нет, документ может стать `done` и автоподтвердиться с **положительной** себестоимостью. Проверка 2200 при этом ничего не ловит: `abs` применяется к каждому соотношению независимо, смесь знаков в документе проходит. В консолидацию уходит ошибка знака, которую сегодня видно по `is_recognized=false`.
**Текст дизайна прямо утверждает обратное:** «„(7744536}“ → −7 744 536 при 2100 = 2110 − 2120 верна» — при текущем `parse_amount` результат +7 744 536, и соотношение верно для обоих знаков.
**Как закрыть.** Либо (а) Р-7 не распространяется на ячейки-члены `abs_terms`, когда соотношение сошлось только вариантом `abs` или когда знак ячейки отличается от знака остальных членов `abs_terms` в колонке (одна конвенция знаков на документ: все расходы либо в скобках, либо с минусом); либо (б) `parse_amount` принимает `}`/`]` как закрывающую скобку (OQ-147), и покрытие считается только по варианту «как записано» с единой конвенцией знаков на колонку. В любом случае спека должна зафиксировать, что проверка §7 с `abs_terms` знак не подтверждает.
### F-2 — critical — «Подтверждение проверкой»: тривиально сошедшееся соотношение (все члены `null` → 0 = 0) «покрывает» нечитаемые ячейки
**Где:** шаг 8 «Подтверждение проверкой», первый пункт («значение не покрыто сошедшимся жёстким соотношением»).
**Дыра.** В обеих реализациях `null` в соотношении = 0 и соотношение проверяется, если строка-цель есть в документе (`statement_checks.py:200-206`: `amounts = {code: values.get(col) or 0}`, пропуск только при отсутствии строки; парсер `checks.py:27-34`). Соотношение, у которого в колонке все члены `null` (включая цель), сходится как 0 = 0. По дизайну такое «сошедшееся жёсткое соотношение» подтверждает все свои ячейки независимо от `confidence` и `is_recognized`.
**Сценарий.** Правая (третья) колонка скана обрезана или залита полосой: все ячейки раздела IV — мусор (`amount = null`, `is_recognized = false`, уверенность 0–40; в aep_3 такие ячейки есть: 1430 «! : г ’ .» 20, 1450 «# -» 33, 1540 «<» 0). `1400 = 1410 + 1420 + 1430 + 1450` → 0 = 0 → сошлось → все четыре ячейки `needs_review = false`. Если так по всей колонке (`1600 = 1100 + 1200` → 0 = 0), колонка из `null` не подсвечивается, не мешает `done` и автоподтверждается — тихая потеря периода, ровно то, что Р-6 запрещает для колонок без даты. Сегодня это невозможно: `values_ok` требует `is_recognized` у каждой ячейки (`statement_mapping.py:211-218`).
**Как закрыть.** Соотношение считается подтверждающим только если хотя бы один его член в колонке непуст (`amount` не `null`) — то же правило, что уже принято для «родитель = Σ подстрок» (AURORA-1527, `run_checks` 207-210); ячейка с `is_recognized = false` и `string_value` не пустым (мусор, а не прочерк) не считается покрытой, если соотношение сошлось за счёт её `null`.
### F-3 — critical — шаг 3 п. 4 / шаг 8 п. 1: строки `missing` делают `done` недостижимым для реальных форм; отменяет OQ-36/OQ-141 без упоминания; нет действия аналитика
**Где:** шаг 3 (выход `RawLine[]`, `missing — кода нет, суммы есть`), шаг 3 п. 4, шаг 8 п. 1 («все строки `ok`»), Р-5 («иначе `needs_review` у строки, документ — `needs_check`»).
**Дыра.** Строки «в том числе: …» и прочие расшифровки под строками формы **законно** не имеют кода. Сегодня они не отдаются ни одним сервисом по решениям OQ-36/OQ-141 (§12 п. 5: «Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются»; §11.1 п. 4). Дизайн превращает их в строки `missing`, требует для `done` «все строки `ok`» и не даёт аналитику способа сказать «это расшифровка без кода, так и должно быть». Изменение решения OQ-36/141 нигде не зафиксировано (ни в тексте, ни в «Decisions made without discussion»).
**Сценарий.** «Русский бисквит 1 кв.25» (чистый скан: ИНН 100, организация 100, дата «за январь - март 2025 г.» разбирается, коды почти все 100): 40 строк без кода с суммами в ББ и 12 в ОФР → 52 строки `missing` → документ никогда не `done`, цель Р-7 («такой документ может получить `done` и автоподтвердиться») недостижима ни для одной из четырёх OCR-фикстур. Для Excel — регрессия: книга НПЗ (`fixtures/statements-xlsx/npz`, манифест строки 47–48, сегодня `done` по 5 и 3 листам) содержит строки без кода с суммами (§12 п. 5) → `needs_check`.
**Как закрыть.** Разделить «строка без кода» на два случая: расшифровка под строкой с кодом (наименование начинается с «в том числе»/стоит под родителем — у OCR и Excel это определимо по позиции и тексту) — статус `detail`, в `done` и проверки не входит, хранится ради показа; и «строка формы без кода» (наименование совпадает со строкой формы по словарю ядра либо суммы не укладываются под родителя) — `missing`. Плюс действие аналитика на экране: «назначить код» / «это расшифровка» (см. F-14). И явно записать отмену OQ-36/OQ-141 в раздел решений.
### F-4 — critical — шаги 3/5/8, «Что уходит из ядра»: порядок строк и `ref` не хранятся — строки `missing`/`unreadable` и предупреждения с `line_ref` теряют место после маппинга
**Где:** шаг 3 (`ref`), шаг 5 («порядок как в файле»), шаг 8 п. 5 (`line_ref`, «экран показывает у места»), «Что уходит из ядра» («Экран показывает строки как в файле», «строки `missing`/`unreadable` показываются как пришли»).
**Дыра.** `financial_statement_line` (DBML v5.18) не имеет ни `row_index`, ни `ref`; ядро читает строки `ORDER BY line_code, id` (`repositories/statements.py:289`), ответ сортирует по коду (`schemas/statements.py:221`: `(line_code is None, line_code or "")`), фронт раскладывает по сотням кода, а строки без 4-значного кода уносит в «Прочие строки» в конец (`formLayout.ts:107-111`, `incomeLayout` 122). Дизайн обещает DBML v5.19 только для снятия уникального индекса.
**Сценарий.** aep_6 ББ: 18 строк `missing` («в том числе: Сырьё и материалы 35 465», «Готовая продукция 2 827», «Расчёты с поставщиками 29 526» …) и 4 `unreadable`. После маппинга все 22 строки — в конце формы, без привязки к 1210/1230/1520; аналитик не может понять, какой код назначать и куда строка относится. Предупреждение шага 8 с `line_ref` (OCR `row_index` / Excel A1) не к чему привязать: у сохранённой строки нет `ref`. Обещание «пользователь видит причины статуса на экране у места» после сохранения не выполняется.
**Как закрыть.** DBML v5.19: `financial_statement_line.row_index smallint` (порядок в документе, одинаково для OCR и Excel) и `source_ref varchar` (локатор ячейки кода), контракт — `lines[].ref`; `load_form`/`statement_out` — порядок по `row_index`; `formLayout` — строки без кода выводить под предыдущей строкой с кодом, а не в конец. Это же даёт родителя для «родитель = Σ подстрок» (сегодня `subline_parents` опирается на лексикографический порядок кодов, `statement_checks.py:147-148`).
### F-5 — critical — шаг 2 п. 1, проход (1) по заголовку «Код» без порога: в выгрузках 1С подпись «Коды» титульного блока даст ложный столбец кодов; молча исчезает поиск заголовка по периодам
**Где:** шаг 2 п. 1 (два прохода), шаг 2 п. 4, решение № 3.
**Дыра.** Словарь `statement_headers.yaml: code_caption: '^код(ы)?$'` совпадает с «Коды» — подписью мини-таблицы реквизитов в правом верхнем углу формы 66н («Форма по ОКУД 0710001», «Дата (число, месяц, год)», «по ОКПО», «ИНН», «по ОКЕИ 384»). Парсер сегодня защищён порядком: сначала столбец кодов по подсчёту (`columns.py:74-95`, порог), потом заголовок — только подпись **в этом столбце** и только последняя над первым кодом (`_find_header_row` 130-136). Дизайн разворачивает порядок: «есть строка заголовка с ячейкой «Код» → столбец кодов под ней, без порога». В титульном блоке под «Коды» стоят «0710001», «31 12 2024», «12345678», «3528052718», «384» — ни один не код; при выборе этого столбца каждая строка таблицы становится `unreadable`/`missing`, настоящие коды уходят в «наименование»/«значения».
Второе: сегодня заголовок без подписи «Код» находится по «две и более ячейки-периода правее кодов» (`_find_header_row` 137-142) — шаблон банка и самодельные таблицы с «На 31.12.2024 | На 31.12.2023» без слова «Код». Дизайн такой ветки не содержит: это становится «строка заголовка не найдена» → `error` → `needs_check`, колонки без дат.
**Сценарий.** Любая выгрузка 1С «Регламентированный отчёт» (все `unagrande-*`, `mezheninovskaya`, `npz`): первая ячейка «Коды» на листе — титульный блок. Результат: документ из 50 строк `unreadable`, статус `needs_check`, а по дизайну это «так шлюз сейчас».
**Как закрыть.** Проход (1) принимает подпись «Код» только если в столбце под ней есть хотя бы один код из диапазона, и берёт последнюю такую подпись над первой строкой с кодом; либо сохранить порядок парсера (подсчёт → подпись → заголовок по периодам). Ветку «заголовок по двум периодам» перенести в общее правило явно.
---
## Important
### F-6 — important — шаг 8 п. 1 требует «дата отчёта», шаг 2 п. 6 и ядро живут периодом «из даты отчёта, без неё — из колонок»: OCR-документы без разобранного `date` никогда не `done`
**Где:** шаг 8 п. 1 (`done` ⇔ … ∧ дата отчёта ∧ …), шаг 2 п. 6 («период формы берётся из даты отчёта, без неё — из самой поздней даты остальных колонок»), «Что уходит из ядра» (`statement_status`).
**Дыра.** У ИС поле `date` в 3 из 4 успешных фикстур — не дата: aep_6 и «БО 1 кв 2025» — «Общество с ограниченной ответственностью…», aep_3 — «2|0|2|4|». Колонки при этом разбираются («На 31 марта 2025 г.» → 2025-03-31). Ядро определяет период именно так (`statement_mapping.py:73-80`). Критерий `done` по «дате отчёта» делает «БО 1 кв 2025» (ИНН 100, организация 100, колонки с датами) вечным `needs_check` при любом качестве цифр — и обесценивает Р-7.
**Как закрыть.** В критерии `done`: «период определён» = дата отчёта **или** самая поздняя дата колонок (как `statement_period`); отсутствие даты отчёта при наличии дат колонок — `info`. Относительные подписи (Р-6) считать от этой же «эффективной даты».
### F-7 — important — шаг 9: v1.3 не MINOR по собственным правилам контракта; порядок раскатки не задан → при обновлении сервиса раньше ядра файлы уходят в `ERROR`
**Где:** шаг 9, «Что уходит из ядра».
**Дыра.** `$comment` схемы: MINOR — только необязательные поля и новые значения enum; MAJOR — удаление/переименование полей, смена типов или семантики; «новый MINOR раскатывается сначала на потребителей». Дизайн в v1.3 делает `line_code` nullable (смена типа), `metrics.warnings[]` и `document.warnings[]` объектами вместо строк (смена типа), убирает `duplicate_line_codes` (удаление), `checks` становится обязательным (семантика). В ядре `StatementLine.line_code: str` (`arm-core/api/src/contract/result.py:161`), `Metrics.warnings: list[str]`, модели `strict=True` (`contract/common.py:51`); незнакомые поля игнорируются (`extra="ignore"`), но `null` в `line_code` и объект в `warnings` — `ValidationError` → `MappingFatal` «результат не соответствует схеме» (`result_mapping.py:195`) → файл `ERROR`.
**Сценарий.** Выкладка контура: tabular-parser обновлён, arm-core ещё нет (разные PR, разные репозитории); первая же Excel-книга со строкой `unreadable` → `ERROR`.
**Как закрыть.** Либо v2.0 с поднятием `SUPPORTED_MAJOR` и двойным приёмом в ядре на время перехода, либо аддитивный v1.3: `line_code` остаётся строкой (для `unreadable`/`missing` — пустая строка, а статус и сырой текст — в новых полях), `metrics.warnings` остаётся строками, структурные предупреждения — новое поле `issues[]`, `duplicate_line_codes` остаётся всегда пустым. И в любом варианте — явный порядок: сначала ядро.
### F-8 — important — ядро/БД: `needs_review`, `line_status`, `line_code_text`, уровень и привязка предупреждений, `checks` из результата — негде хранить; пересчёт после PATCH не воспроизводит `done`
**Где:** шаг 8 («после PATCH ядро пересчитывает `checks` и `needs_review` общим кодом»), «Что уходит из ядра» (§7 при загрузке, `statement_status`, экран), Р-4 (DBML v5.19 только про индекс).
**Дыра.**
1. В DBML v5.18 `needs_review` сознательно убран (v5.13, заметка у `financial_statement_value.confidence`: «считается, не хранится»), `financial_statement_meta` — «проверки не хранятся», у `financial_statement_line` нет `line_status`/`line_code_text` (есть `line_code_original_value`, которое ядро использует как признак «строка из источника», `schemas/statements.py:157-159`, `repositories/statements.py:131,155-159`). Структурные предупреждения документа хранить негде (`document_out` отдаёт предупреждения маппинга файла, `services/documents.py:135`). Дизайн ни одного из этих полей в DBML не добавляет.
2. Критерий `done` включает «нет предупреждений уровня `error`». После PATCH `recompute_status` передаёт `warnings=()` (`services/statements.py:152-168`, задокументировано как ограничение) — ядро не знает, были ли у документа `error`-предупреждения (колонка без даты, сегменты не совпали) → правка одной ячейки может перевести документ в `RECOGNIZED` при сохраняющейся структурной проблеме.
3. Общий код считает `needs_review` из `confidence`/`is_recognized`/§7, но ничего не говорит про `is_modified`. Сегодня правка аналитика снимает сомнение (`values_ok`: `modified or …`, `statement_mapping.py:211-218`). Без этого исправленная ячейка строки, не входящей ни в одно соотношение (2460, 2900, 29xx, шапка, расшифровки), останется `needs_review` навсегда → `done` недостижим правкой.
4. Акцепт нарушений (`checks_accepted_*`) в критерии `done` не упомянут: после акцепта ядро должно считать жёсткие нарушения снятыми (сегодня `statement_hard_failures` так и делает) — общий код должен принимать это параметром.
**Как закрыть.** DBML v5.19: `financial_statement_line.line_status`, `line_code_text` (или переопределить `line_code_original_value`), `row_index` (F-4); `financial_statement_value.needs_review` (хранить результат сервиса и пересчитывать ядром после PATCH) либо явное решение «считается на каждом GET общим кодом»; таблица `document_issue` (код, уровень, сообщение, `line_id`/`column_id`) или `jsonb` на `financial_statement_meta`. Правило: `is_modified = true` ⇒ `needs_review = false`; акцепт ⇒ жёсткие нарушения не считаются. Критерий `done` реализовать одной функцией общего кода с входами «статусы строк, needs_review, предупреждения `error`, акцепт».
### F-9 — important — Р-5 «код стоит по возрастанию внутри раздела» ложно для итоговых строк и требует порядок формы по редакции — противоречит шагу 5 п. 2 («редакция в сервисах не определяется»)
**Где:** Р-5, абзац «Код и наименование строки», шаг 5 п. 2–3.
**Дыра.** В формах 66н/ФСБУ 4 итоги стоят **после** слагаемых: 1110…1190, **1100**, 1210…1260, **1200**, **1600**; в ОФР 2110, 2120, **2100**, 2210, 2220, **2200** (так и в словаре ядра `arm-core/api/rules/statement_lines.yaml`). «По возрастанию» нарушает каждая итоговая строка → по Р-5 все 1100/1200/1300/1400/1500/1600/1700/2100/2200/2300/2400 получают `needs_review` строки → документ всегда `needs_check`. Правильный порядок — порядок бланка, а он различается по редакциям (fsbu4: 1215, 2411/2412, 2460, иные составы) — то есть сервису нужен перечень кодов **по редакции** и дата для её выбора, что дизайн относит только к ядру (шаг 5 п. 2, Р-3). При Р-4 равные соседние коды тоже надо допустить явно.
**Как закрыть.** «Перечень кодов форм» в общем коде сделать упорядоченным списком по форме и редакции (без наименований, Р-3 не нарушается); редакцию сервис выбирает по эффективной дате (F-6) с запасным вариантом «обе редакции» — подтверждён код, стоящий на своём месте хотя бы в одной; равные соседние коды — допустимы.
### F-10 — important — Р-6 относительные подписи: после правки даты отчёта в ядре `period_end` колонок не пересчитывается
**Где:** шаг 2 п. 3, п. 6, Р-6; «Что уходит из ядра» (пересчёт после PATCH — только `checks`/`needs_review`).
**Дыра.** Для бланка без впечатанных дат («На отчётную дату», «На 31 декабря предыдущего года») сервис при отсутствии даты отчёта отдаёт `period_date = null`. Аналитик вводит дату отчёта через PATCH шапки; `_apply_header` (`services/statements.py:258-266`) пересчитывает период документа из `meta.report_date` и **сохранённых** `column.period_date`, но сами колонки не трогает и словарь периодов не знает. Колонки остаются без дат: `is_current` `null`, `pair_amounts(current=True)`/`period_date` (`repositories/statements.py:306-327`) ничего не находят → межформенная проверка молча не считается, гудвилл «на отчётную дату» (DBML, заметка у `financial_statement_column.period_date`) не находит колонку.
**Как закрыть.** Либо сервис отдаёт у колонки признак «подпись относительная» + её вид (`relative: report_date | prev_year_end | prev_prev_year_end`), а ядро после PATCH даты считает `period_date` общим кодом; либо PATCH шапки в ядре прогоняет `label` колонок через общий словарь периодов с новой датой.
### F-11 — important — шаг 1 п. 6 и допущение владельца о нескольких json: несколько кандидатов одного типа на файл, а `document` уникален по `(stored_file_id, document_type)` — вторая форма молча пропадает
**Где:** шаг 1 п. 6 («расхождение — отдельный кандидат + предупреждение»), шаг 1.1 (несколько json на файл), «Что уходит из ядра».
**Дыра.** DBML `document`: `(stored_file_id, document_type, osv_account_code) [unique, NULLS NOT DISTINCT]` — один ББ и один ОФР на файл. Ядро второй документ того же типа пропускает с предупреждением «вторая форма … пропущена (OQ-77)» и ставит первому `NEEDS_CHECK` (`result_mapping.py:363-366`). Дизайн порождает такие пары штатно: лист-продолжение с маркером другого типа → отдельный кандидат; PDF с двумя периодами → два json → два ББ. Сервис считает данные доставленными, ядро их теряет; в «Что уходит из ядра» это не упомянуто.
**Как закрыть.** Записать явно: либо остаётся OQ-77 (потеря + `needs_check`), либо DBML v5.19 снимает уникальность по типу и вводит порядковый номер формы в файле; в обоих случаях — предупреждение `error` у первого документа с указанием `locator` потерянного.
### F-12 — important — шаг 8 п. 2 / решение № 1: таблица уровней неполная, у нескольких существующих блокираторов `done` уровень не задан → регрессии решений № 28 и «лист потерян»
**Где:** шаг 8 п. 2, «Decisions made without discussion» № 1.
**Дыра.** Сегодня статус `done` блокируют: ошибочный json набора при успешном соседе (`ocr/__init__.py:61-66`, решение № 28: потерянный ОФР не должен остаться под `done`); `BACKEND_PARSING_ERROR` таблицы (§11.1 п. 9, `__init__.py:92-95`); «нет шапки и столбца кода» таблицы (`__init__.py:96-99`); у парсера — потерянный лист-продолжение без колонки кодов (`statement.py:96-105`, `parse_sheets` `dropped`). В перечне уровней их нет; по умолчанию дизайн относит к `info` всё, что «не меняет данные или их полноту», и эти случаи под формулировку не попадают однозначно. Также не задан уровень для «ИНН не распознан / неуверенно», «дата отчёта не разобрана», «формат чисел xlsb не читается» (после tp#42 значения в рублях при единицах «тыс.» — сегодня документ `done`, и дизайн это сохраняет).
**Как закрыть.** Полная таблица уровней в спеке, как минимум `error`: ошибка json набора (файл), `BACKEND_PARSING_ERROR`, таблица/лист без колонки кодов при непустом содержимом, колонки сегмента не совпали, колонка без даты, строка заголовка не найдена, тип по кодам противоречит заголовку; и явное решение по xlsb/дробным.
### F-13 — important — «Подтверждение проверкой», второй пункт: «не сошлось» не определено для округления (±0,001 %) и мягких соотношений → NPZ и Унагранде из `done` уходят в `needs_check`
**Где:** шаг 8 «Подтверждение проверкой» (пункт «`true` у всех ячеек соотношения в колонке, где оно не сошлось»), шаг 7.
**Дыра.** Жёсткое соотношение с расхождением больше ±1, но в пределах 0,001 %, уходит в `soft_relations_failed` (`checks.py:131-137`, `statement_checks.py:215-220`) и статус не трогает (OQ-138). Мягкие соотношения (2400, 2500, подстроки) тоже попадают в `soft`. Формулировка «соотношение не сошлось» без уточнения охватывает оба случая → все их ячейки `needs_review = true` → по критерию `done` («нет значений `needs_review`») документ `needs_check`.
**Сценарий.** Манифест парсера: НПЗ ББ (`1600 = 1700` расходится на 1,94/9,02/2,48 при ~17 млрд, строка 47) и ОФР (48), Унагранде ОФР (мягко 2400 и 2410 = 2411 + 2412, строки 18, 39) — сегодня `done`, по дизайну `needs_check`; Excel-путь теряет автоподтверждение.
**Как закрыть.** «Не сошлось» = жёсткое нарушение вне допуска и вне округления; соотношение в пределах округления считается сошедшимся для покрытия и не подсвечивает; мягкие соотношения на `needs_review` не влияют (только предупреждение `info`).
### F-14 — important — шаг 3 п. 1 / п. 4, AURORA-1462: «аналитик назначает 1250» — в API и UI нет операции назначения кода строке
**Где:** шаг 3 п. 1, шаг 5, «Что уходит из ядра» (достройка).
**Дыра.** `StatementPatch` — только `header` и `values` (`schemas/statements.py:298-308`, `types/api.ts:175-178`); `CellPatch.line_code` адресует **шаблонную** строку и отвергается, если код уже есть (`_resolve_cells`, `services/statements.py:216-246`, `LINE_EXISTS`). Поля `line_code_modified`/`line_code_original_value` в DBML есть, но операции нет. Переход `line_status: unreadable → ok` после назначения, аудит (`RECOGNIZED_DATA_EDIT` с `before/after` кода), пересчёт проверок и `needs_review` строки не описаны; что делать с `missing` строкой, которая и должна быть без кода (F-3), — тоже.
**Как закрыть.** В спеку ядра: `PATCH …/statement` с `lines: [{line_id, line_code | null, detail: bool}]`, правила: назначенный код может совпадать с существующим (Р-4), `line_status` → `ok` (или `detail`), аудит, пересчёт; на фронте — поле кода у строк `unreadable`/`missing`.
### F-15 — important — Р-4 в ядре: кроме индекса, по уникальности кода живут чтения «одна строка на код»
**Где:** Р-4, «Что уходит из ядра» (повторы кодов).
**Дыра.** `_line_amount` берёт `LIMIT 1` по коду (`repositories/statements.py:306-327`) — межформенная `1370 − 1370 = 2400` при дубле 1370/2400 возьмёт одну из строк, не сумму; `statement_amounts` кладёт значение по ключу кода с перезаписью (`:154-159`) — последняя строка побеждает, не сумма; `subline_parents`/`parent_code` при повторном четырёхзначном родителе относит подстроки к последнему по порядку (`statement_checks.py:141-162`); `formLayout` выводит детей один раз под первым родителем (`formLayout.ts:149`); `_resolve_cells` `codes = {line.line_code}` включает `None` (F-14). `metrics.fields_*` считаются по значениям — семантика при `needs_review` меняется.
**Как закрыть.** Перечислить в «Что уходит из ядра» все точки агрегации по коду и ввести одну функцию «агрегат формы по коду» (сумма по колонке, список `line_id`), которой пользуются проверки, межформенная проверка, подтверждение, КФО и выгрузки.
### F-16 — important — шаг 1 п. 6 (Excel): склейка листов в одну сетку до раскладки ломает продолжения без строки заголовка — позиции столбцов на листах разные, сегмент открывается только шапкой
**Где:** шаг 1 п. 6, шаг 2 п. 2 (сегменты «по признаку шапки»), «проверка числа колонок уходит».
**Дыра.** Сегодня продолжение получает **свою** раскладку (`find_layout` на листе, `statement.py:94-139`), для продолжений без заголовка колонки значений находятся по числам в строках с кодами (`columns.py:229-238`), и присоединяются **строки** контракта, а не ячейки. Такие продолжения есть в фикстурах (манифест: «страница 2 без шапки», строки 13, 20, 26, 28, 34, 41). Дизайн дописывает сырые строки второго листа в конец сетки первого; сегмент с новой раскладкой открывается только строкой с признаком шапки. Если на листе 2 нет шапки (есть только «Форма 0710001 с.2» и строка нумерации), а столбец кодов/сумм стоит в другой физической колонке (другая ширина колонки A, объединённые ячейки), значения читаются по позициям первого листа → суммы попадают не в те колонки или в `null`, без предупреждения (число колонок больше не сверяется).
**Как закрыть.** Граница листа — всегда новая граница сегмента (сегмент = лист или участок между шапками); раскладка сегмента без шапки — по числам (как сегодня), колонки сопоставляются с первым сегментом по порядку, несовпадение числа — `error`. Это сохраняет сегодняшнее поведение и даёт то же для OCR.
### F-17 — important — Р-1 (копия общего кода скриптом): нет версии копии в результате, «`make check` проверяет свежесть» повторяет ошибку #33, импорты всё же меняются, тесты пакета у потребителей не запускаются
**Где:** Р-1, принцип 1, «Что уходит из ядра» (пересчёт тем же кодом).
**Дыра.**
1. Расхождение копий невидимо на данных: ядро на старой копии пересчитывает после PATCH другим словарём/правилом, чем сервис при загрузке → статус/подсветка меняются от одной правки ячейки без изменения сумм (например, парсер уже знает новое соотношение, ядро — нет). В результате нет поля «версия `arm_statements`», ядро не может ни проверить, ни записать, каким кодом посчитан `checks`.
2. «`make check` потребителя проверяет свежесть копии» — проверка, читающая соседний репозиторий или сеть, — это ровно урок arm-core #33 (`03-architecture.md:180-182`: «тест, читающий соседний репозиторий, ломал main»; пайплайн платформы вне репозиториев, доступа к Gitea из него нет). `sync-web.sh` работает иначе: внешний скрипт в docs, точка синхронизации по хэшу дерева (`sync-web.sh:72-87`), код 20 при прямых правках.
3. «Копия заменяется строкой зависимости без смены импортов» — копия лежит в `api/src/arm_statements/` и импортируется как `src.arm_statements…` (все три репозитория импортируют через `src.`), пакет — как `arm_statements…`; импорты сменятся.
4. Тесты пакета остаются в `arm-statements`; у потребителя копия проверяется только его ruff/mypy с другими настройками (разные `noqa`, строгость) → `make check` ломается в одном репозитории и проходит в другом; golden-файлы в fixtures тоже должны прогоняться где-то у потребителя.
5. Загрузчики словарей разные (`parser/rules.py` `Rules` с `extra=forbid` и нормализацией «е/ё», `normalizer/rules.py` `OcrRules`, `domain/statement_checks.load_check_rules`) — словари пакета должны лежать внутри него со своим загрузчиком, иначе три загрузчика на один yaml.
**Как закрыть.** В контракт — `service.statements_version` (коммит копии); ядро пишет её в `recognition_job` и при пересчёте после PATCH сравнивает со своей (расхождение — предупреждение `error`, не падение). Свежесть копии — скрипт `sync-statements --check` в docs (как `sync-web.sh --check`), не тест и не `make check`. Копия с тестами (`api/tests/arm_statements/`) и единым конфигом линтеров в пакете. Импорт через `arm_statements` с добавлением пути (`pyproject`/`sys.path`) сразу, чтобы замена на зависимость была без правок.
### F-18 — important — «Что уходит из ядра»: `RECOGNIZED ⇔ done ∧ ИНН ≠ MISMATCH` теряет `period_known` — документ `RECOGNIZED`, но не подтверждаемый (`PERIOD_REQUIRED`)
**Где:** «Что уходит из ядра» (`statement_status`), шаг 8 п. 1.
**Дыра.** Сегодня D-6 требует `period_known` (год и квартал: дата — конец квартала, `statement_mapping.py:220-240`), а подтверждение и автоподтверждение — тоже (`form_confirmable`, `services/statements.py:79-81`; `documents._check_statement` → 409 `PERIOD_REQUIRED`). Сервис про кварталы ядра не знает; дата отчёта 15.05.2025 (промежуточная форма) даст `done` → ядро ставит `RECOGNIZED` («ручная проверка не требуется») → `auto_confirm` молча пропускает → документ висит `RECOGNIZED` с кнопкой «Подтвердить», отвечающей 409.
**Как закрыть.** Оставить в ядре `period_known` (квартал) как условие `RECOGNIZED`, либо задать его в общем критерии `done` через параметр «период должен быть концом квартала».
### F-19 — important — принцип 5 и шаг 4 п. 1: замена букв-двойников распространяется на суммы и коды вопреки §11.1 п. 1 (решение № 22: не проверено на стенде) — изменение не зафиксировано
**Где:** принцип 5 («очистка OCR — первая стадия типовых разборов … код строки (шаг 3), сумма (шаг 4)»), шаг 4 п. 1 («один `parse_amount(text)`: двойники, …»).
**Дыра.** §11.1 п. 1: двойники применяются **только к датам**; «замена двойников внутри кодов и сумм на стенде не проверялась, поэтому не делается (решение № 22 спеки 3a)». Сегодняшний `parse_amount` (`amounts.py:44-84`) двойники не трогает. Дизайн меняет правило молча: нет ни в Р-1…Р-7, ни в «Decisions made without discussion», §11.1 не правится. Цена: «О» → 0, «З» → 3, «б» → 6, «S» → 5 в сумме дают правдоподобное число с `is_recognized = false`, которое Р-7 затем может «подтвердить» через §7 (вместе с F-2 — без подсветки).
**Как закрыть.** Либо записать как решение (с условием: двойники в сумме/коде всегда дают `is_recognized = false` и не покрываются Р-7), либо оставить § 11.1 п. 1 и применять `fix_lookalikes` только к датам.
---
## Minor
### F-20 — minor — шаг 4 п. 1, решение № 6: общий `parse_amount` теряет проверки парсера (диапазон `numeric(18,2)`, квантование, дата/логическое) — Excel-дата становится суммой, «догадка из цифр» может уронить маппинг
**Где:** шаг 4 п. 1, п. 3, решение № 6.
**Дыра.** `to_money`/`quantize_money` парсера отсекают значения вне 16 целых цифр и округляют half-up (`numbers.py:85-97`), дату и bool дают `null` с предупреждением; `parse_amount` шлюза этого не делает (`amounts.py:72-84`), а «лучшая догадка — цифры подряд» и склейка строк (`split_merged_row` при несовпадении частей не разлепляет → «917 775\n2 371 333» → 9177752371333) дают сколь угодно длинные числа; контракт `money` длину не ограничивает, БД — `numeric(18,2)` → `MappingFatal` → файл `ERROR`. Excel-ячейка-дата «31.12.2024» через `parse_amount` → 31122024 (точек > 1 → удалены), `is_recognized = false`.
**Как закрыть.** В общий `parse_amount` — проверку диапазона/квантования из парсера; `value` типа дата/bool → `amount = null`, `string_value = text`, `is_recognized = false`; «догадка» ограничена 16 цифрами, иначе `null`.
### F-21 — minor — шаг 3 п. 1 / шаг 2 п. 1: разбор кода должен убирать внутренние пробелы, иначе «1 100» (aep_3, уверенность 80, «Итого по разделу I») станет `unreadable`
**Где:** шаг 2 п. 1 («Код — 4–5 цифр (допускается `.0`)»), шаг 3.
**Дыра.** Парсер делает `text.replace(" ", "")` перед регуляркой (`columns.py:65`); `clean_value` шлюза пробел сохраняет (`text.py:57-62`), а `int("1 100")` падает. В дизайне правило не оговорено.
**Как закрыть.** Записать: код = текст без пробелов/переносов, 4–5 цифр, допускается `.0`; прочее — `unreadable` с сырым текстом.
### F-22 — minor — шаг 6 п. 1 и шаг 2 п. 1: правило большинства знает только 1xxx/2xxx, а словарь кодов включает 5xxx (DISCLOSURE) — у расшифровок появятся строки, хотя §12 п. 8 их не отдаёт
**Где:** шаг 2 п. 1 («диапазонах словаря кодов всех форм»), шаг 6 п. 1, решение № 2.
**Дыра.** Лист «Пояснения» с кодами 5xxx получит раскладку и `RawLine[]`; в шаге 6 большинство 5xxx не относится ни к `BALANCE`, ни к `INCOME` — поведение не определено; ядро для `DISCLOSURE` строки не хранит (`_map_statement_docs`, `result_mapping.py:360-362`).
**Как закрыть.** 5xxx → `DISCLOSURE` в правиле большинства; для `DISCLOSURE` шаги 2–7 не выполняются (как сейчас), `lines`/`columns` пустые.
### F-23 — minor — шаг 6 п. 3 и шаг 8 п. 3: `locator` `json:` противоречит §11 (`file:<имя json>;table:<i>`, решение № 1 спеки 3a) и golden-эталонам; код ошибки «документов нет» у сервисов разный, хотя написано «как сейчас»
**Где:** шаг 6 п. 3 (решение № 5), шаг 8 п. 3.
**Дыра.** Эталоны `fixtures/ocr/expected_normalize/*` и §11 фиксируют `file:…`; смена ключа — правка §11, golden и ожиданий ядра (`test_mapping_golden`). «Документов нет — `error` (как сейчас)»: у шлюза `RECOGNITION_FAILED`, у парсера `INVALID_DOCUMENT` — единого выхода нет.
**Как закрыть.** Либо оставить `file:`, либо явно перечислить, что меняется; код ошибки унифицировать или записать различие в таблицу шага 8.
### F-24 — minor — шаг 2 п. 1: «нечитаемые коды становятся строками `unreadable` (так шлюз сейчас)» — неточно
**Где:** шаг 2 п. 1, проход (1).
**Дыра.** Сегодня нечисловой код → `RowKind.HEADER` → строка **пропускается** со счётчиком (`rows.py:58-71`, `_count_skipped`), а любое целое принимается кодом: в golden aep_3 есть строка с кодом «420» (уверенность срезана до 99) — это и не `unreadable`, и не код формы. Формулировка «так шлюз сейчас» вводит в заблуждение при написании спеки.
**Как закрыть.** Переформулировать: «проход по заголовку без порога — как у шлюза; статусы строк — новое поведение».
### F-25 — minor — шаг 3 п. 2: правило разлепления при `unreadable` частях не определено; неразлепленная склейка даёт одну строку с многострочными значениями
**Где:** шаг 3 п. 2, §11.1 п. 4.
**Дыра.** `split_merged_row` требует `isdigit()` у каждой части кода (`rows.py:98`); «1500\n17O0» (двойник) не разлепится → по дизайну строка не отбрасывается, а становится `unreadable` с ячейками «917 775\n2 371 333» → шаг 4 «догадка из цифр» даст 9 177 752 371 333 с `is_recognized = false` (см. F-20).
**Как закрыть.** Если число частей кода ≥ 2 и части значений совпали по числу, разлеплять и при `unreadable` частях (статус наследуется частью); многострочное значение в неразлепленной строке — `amount = null`, `string_value = text`.
---
## Что проверил и признал консистентным
- Р-2: ИС отдаёт ячейку на каждую позицию каждой строки — подтверждено по всем 8 таблицам 4 успешных json (0 недостающих позиций из 2224); `column_index` с пропусками (0,1,2,4,5) — у всех таблиц одинаково внутри таблицы.
- Р-5 про дискретность уверенности сумм: в ячейках сумм промежуток 90–99 встретился один раз («2100 I 150416», 92,3 — это мусор); в ячейках наименований и кодов значения 90–99 обычны, но на статус по дизайну не влияют.
- Словарь §7: `tabular-parser/api/rules/statement_checks.yaml` и `arm-core/api/rules/statement_checks.yaml` сегодня совпадают по содержанию (различие только в комментариях) — утверждение «две копии» верно, расхождения пока нет. Словари строк шлюза (плоский, 66n, без порядка) и ядра (по формам, с порядком бланка) действительно не совпадают (разное число кодов, разные имена, например 1160) — утверждение шага 5 п. 3 верно.
- `ContractModel` во всех трёх репозиториях — `extra="ignore"`, `strict=True`: аддитивные поля (`needs_review`, `line_status`, `document.status`, `line_code_text`, `ref`) старыми потребителями проглатываются без ошибки; ломают только смена типов (F-7).
- Шаг 4 п. 5 ↔ Р-2 ↔ правило `needs_review` шага 8 (ячейка с уверенностью 0 и пустым текстом подсвечивается общим правилом) — согласованы.
- Шаг 6 п. 1 ↔ шаг 8 п. 1 ↔ решение № 2 (`DISCLOSURE` всегда `done`, документ без строк) — согласованы с текущим `_is_ok` и `_document` парсера.
- Шаг 9 «`checks` обязателен у форм со строками, отсутствует у документа без строк» ↔ решение № 7 — согласованы; у ядра `checks: OptionalNotNull` принимает оба случая.
- Решение № 5 (грамматика `locator`) соответствует текущим форматам обоих сервисов (кроме ключа `file:`/`json:`, F-23).
- «Достройка шаблонных строк: код, у которого есть строка из файла, не достраивается» — совпадает с текущим `statement_out` (`schemas/statements.py:205-208`) и `_resolve_cells` (`LINE_EXISTS`).
- Шаг 0 и шаг 9 (доставка, S3, NATS, `Nats-Msg-Id`) — не меняются, описание совпадает с `03-architecture.md`.
- Порог запасного прохода (решение № 3: ≥ 3 кодов, доля ≥ 0,6) не теряет ни одну таблицу OCR-фикстур (минимальная доля 0,78).
- `statement_period` ядра (дата отчёта, иначе max дат колонок) совпадает с формулировкой шага 2 п. 6 — расходится только шаг 8 п. 1 (F-6).
- Р-4 ↔ шаг 3 п. 3 ↔ шаг 5 ↔ шаг 7 (все строки `ok`, сумма по коду) — внутри дизайна согласованы; неучтённые следствия — в F-9, F-15.
- Р-6 ↔ шаг 2 п. 3/п. 6 ↔ решение № 1 (`error` у колонки без даты) — согласованы; неучтённое следствие — F-10.
## Файлы и символы, которые смотрел
scan-gateway: `api/src/normalizer/ocr/layout.py` (`build_layout`, `infer_layout`, `cells_by_index`), `rows.py` (`classify_row`, `_data_rows`, `split_merged_row`, `parse_table_rows`, `table_type_by_codes`, `_count_skipped`), `amounts.py` (`parse_amount`, `is_blank`), `dates.py` (`parse_date`, `_numeric_date`, `is_date_sized`), `text.py` (`fix_lookalikes`, `clean_value`, `is_code_header`, `is_value_header`), `header.py` (`parse_file_header`), `document.py` (`build_document`, `_resolve_type`, `_columns`, `_choose_edition`, `_value`, `_Build.warn`), `__init__.py` (`OcrNormalizer.normalize`, `_normalize_file`), `rules.py` (`OcrRules`), `contract/common.py`, `contract/result.py`, `api/rules/statement_labels.yaml`, `statement_lines.yaml`.
tabular-parser: `api/src/parser/statements/columns.py` (`_CODE`, `line_code`, `find_code_column`, `_find_header_row`, `_is_period_label`, `_period_columns`, `_joined_label`, `_value_columns`, `is_numbering_row`, `find_layout`), `lines.py` (`collect_lines`, `_value`, `_line_name`, `statement_money`), `merge.py` (`Draft`, `join_blocker`, `join`), `statement.py` (`parse_sheets`, `_block_draft`, `_continuation`, `_weak_blocker`, `_fill`, `_document`, `_is_ok`), `checks.py` (`run_statement_checks`, `_amounts`, `_filled`, `_holds`, `_subline_rules`), `detect.py` (`find_forms`, `continuation_form`, `is_weak_continuation`, `classify_statement_sheet`), `header.py` (`parse_header`, `_report_date`), `book.py` (`parse`, `_parse_book`), `grid.py`, `numbers.py` (`to_money`, `quantize_money`), `rules.py` (`StatementHeaderRules`, `StatementPeriodRules.period_end`), `contract/common.py`, `contract/result.py`, `api/rules/statement_checks.yaml`, `statement_periods.yaml`, `statement_headers.yaml`, `api/tests/statements_manifest.yaml`, `tests/unit/parser/statements/test_lines.py` (ожидание «строк без кода с суммами»).
arm-core: `api/src/domain/statement_mapping.py` (`build_statement`, `statement_status`, `values_ok`, `statement_period`, `edition_for`), `domain/statement_checks.py` (`run_checks`, `_holds`, `subline_parents`, `_subline_rules`, `load_check_rules`, `cross_form_check`, `CODE_RANGES`), `domain/statement_lines.py`, `services/result_mapping.py` (`map_file`, `_map_statement_docs`, `_insert_statements`, `MAPPING_WARNINGS_KEY`), `services/statements.py` (`statement_hard_failures`, `form_confirmable`, `checks_report`, `build_out`, `recompute_status`, `_apply_cells`, `_resolve_cells`, `_apply_header`, `patch`, `accept_checks`), `services/auto_confirm.py`, `services/documents.py` (`document_out`, `_check_statement`), `repositories/statements.py` (`insert_statement`, `statement_amounts`, `value_flags`, `load_form`, `_line_amount`, `_pair_document`, `add_line`, `add_value`, `form_lines`), `schemas/statements.py` (`CellPatch`, `line_origin`, `_lines_out`, `statement_out`), `models/statements.py` (`FinancialStatementLine`), `contract/common.py`, `contract/result.py`, `api/rules/statement_checks.yaml`, `statement_lines.yaml`, `tests/test_api_statements.py` (`test_patch_duplicate_line_code_rejected`); фронт `web/src/widgets/DocumentReview/statementGrid.ts`, `StatementTable.tsx`, `ChecksPanel.tsx`, `formLayout.ts`, `web/src/types/api.ts`.
docs: `openspec/changes/unified-statement-normalization/design.md` (целиком), `context/05-domain-rules.md` §7, §11, §11.1, §12, `contracts/recognition-result.schema.json`, `model/data-model-v5.18.dbml` (`document`, `recognition_job`, `financial_statement_*`, `document_status`), `context/03-architecture.md` (строки 95–110, 172–190), `scripts/sync-web.sh`.
fixtures: `ocr/aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`, `aep_8_form4.json`, `БО 1 кв 2025.json`, `Бухгалтерская отчетность 1 кв.25.json`, `ocr/expected_normalize/*` (скриптами `python3 -I`).