docs(#116): второй раунд ревью — проверка закрытия F-1…F-25 (V-1…V-13) и ревью вслепую (G-1…G-30)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-17 20:36:20 +04:00
parent 63f134b342
commit ba0bdeb744
2 changed files with 397 additions and 0 deletions

View File

@ -0,0 +1,249 @@
# Независимое ревью design.md вслепую — второй раунд (17.10.2026)
Ревьюер: отдельный агент (модель Fable), без права правки и без доступа к первым двум отчётам; получил обновлённый
design.md (после коммита 63f134b), правила, контракт, DBML, фикстуры и код. Маркеры находок — G-n.
Сверка перед передачей владельцу (сессия): подтверждены по коду G-1 (`statement_lines.py:20` — только четырёхзначные
коды; 1105 в словаре ядра нет), G-2 (соотношение с отсутствующей целью пропускается: `statement_checks.py:205`,
`checks.py:122`), G-3 (`string_value` только при `amount = null`, `document.py:149-150`), G-4 (`dates.py` шлюза
разбирает квартал и полугодие, словарь периодов парсера — нет), G-7 (`job_done` входит в `statement_status`,
`statement_mapping.py:222-232`). Пересечения с отчётом о закрытии: G-1 = V-2, G-3 = V-3, G-5 = V-1, G-6 = V-6, G-8 ≈ V-4,
G-11 = V-5 + V-10, G-30 ≈ V-12.
Отчёт агента ниже без сокращений.
---
# Независимое ревью дизайна `unified-statement-normalization/design.md` (17.10.2026)
Маркеры находок — `G-n`. Серьёзность: `critical` — неверный результат или потеря данных; `important` — пробел, который придётся решать до спеки; `minor` — неточность. Все сценарии проверены на реальных фикстурах (`fixtures/ocr/*.json`, `fixtures/statements-xlsx/**`) и коде трёх репозиториев; ссылки на код — `файл:строка`.
---
## CRITICAL
### G-1 (critical) — Подтверждение кода структурой (Р-5, «Код и наименование строки») делает `done` недостижимым для всех фикстур OCR и ломает НПЗ (Excel)
**Место:** шаг 8 «Код и наименование строки», шаг 5 п. 2, Р-5.
**Дыра.** Правило: «код подтверждён, если он есть в перечне и его положение относительно соседних подтверждённых кодов совпадает с порядком бланка …; иначе `needs_review` у строки, документ — `needs_check`». Источник «упорядоченного перечня кодов» не назван; единственный существующий кандидат — справочник ядра `arm-core/api/rules/statement_lines.yaml` (из шаблонов банка). В нём:
- только четырёхзначные коды (`arm-core/api/src/domain/statement_lines.py:20` — `_CODE = re.compile(r"^\d{4}$")`, загрузчик отвергает остальные);
- нет `1105` (Гудвил) ни в одной редакции, нет `1530` и `2460` в `fsbu4`, нет `2420` в `66n`, нет `2930`.
**Сценарии по данным:**
- Все четыре скана с таблицами (`aep_3`, `aep_6`, `БО 1 кв 2025`, `Бухгалтерская отчетность 1 кв.25`) содержат строку `1105` (1С печатает её всегда); формы 2025 года содержат `1530` и `2460` → по правилу эти строки `needs_review` → документ `needs_check`. Цель Р-7/AURORA-1524 («чистый скан может получить `done` и автоподтвердиться») недостижима ни на одной фикстуре.
- НПЗ (`npz/2025-Q3/…xlsx`, манифест: `BALANCE done`, `INCOME done`) — колонка кодов содержит пятизначные подстроки `11101, 11102, 11103, 11501…11509` (проверено `openpyxl`). Их нет ни в каком перечне → каждая такая строка `needs_review` → регрессия `done → needs_check`, не признанная в документе. Шаг 8 п. 1 говорит «Excel выполняет условия уверенности автоматически», но правило Р-5 — структурное, не по уверенности, и Excel под него попадает.
- Определение циклично: «относительно соседних **подтверждённых** кодов» — базы рекурсии нет (уверенность «не важна», значит ничем не заякорить первый подтверждённый код).
- Упрощённые формы (КНД 0710096: порядок `1150, 1170, 1210, 1250, 1230, 1600`) нарушают порядок полного бланка (`1250` раньше `1230`) → их коды никогда не подтверждаются (см. также G-17).
**Как закрыть:** (1) назвать источник перечня и зафиксировать, что он = объединение кодов шаблонов банка **и** кодов печатных форм 1С по редакции (`1105`, `1530`, `2420/2460`, `2930`, упрощённый бланк отдельным перечнем); (2) пятизначные коды подтверждать через четырёхзначный префикс-родителя (как `subline_parents`, `arm-core/api/src/domain/statement_checks.py:141`), не искать их в перечне; (3) снять рекурсию: «положение относительно ближайших соседей, **которые есть в перечне**»; (4) явно: при `source = excel` (уверенность 100) структурная проверка не ставит `needs_review`, а даёт `info`-предупреждение ядра «код не из редакции».
### G-2 (critical) — Р-8 (`detail`) + не оговорённое правило «цель присутствует» + ячейки с уверенностью 100 ⇒ `done` и автоподтверждение документа без итоговых строк
**Место:** шаг 3 (`detail`, п. 4), шаг 7 (правила счёта), шаг 8 п. 1, «Что уходит из ядра» (`RECOGNIZED`).
**Дыра.** Шаг 7 не говорит, проверяется ли соотношение, у которого отсутствует цель. Сегодня оба производителя и ядро соотношение с отсутствующей целью **пропускают** (`tabular-parser/api/src/parser/statements/checks.py:122`, `arm-core/api/src/domain/statement_checks.py:206`). F-2 («посчитано, если хотя бы один член непуст») про цель молчит. Если правило пропуска сохраняется, строка итога, потерявшая код, уходит в скрытый `detail`, её соотношение не считается, остальные сходятся, ячейки с уверенностью 100 не требуют покрытия → `done` → `RECOGNIZED` → автоподтверждение; агрегат по коду итога пуст, в КФО/ЕХ итога нет.
**Сценарий по данным (aep_3):**
- table 0, row 17: `БАЛАНС`, код пуст, суммы `5553383 / 5399730 / 5920582` → по Р-8 это `detail` с родителем `1200`, скрыта по умолчанию; `1600` в документе нет.
- table 0, row 29: `Итого по разделу III` (1300) → `detail` под `1370`.
- table 1, row 2: `Валовая прибыль (убыток)` (2100; код уехал в ячейку значения «2100 I 150416») → `detail` под `2120`.
В aep_3 документ всё равно `needs_check` (есть `unreadable`), но тот же скан с чистыми остальными ячейками проходит в `done`: `1600 = 1100 + 1200`, `1600 = 1700`, `1300 = Σ`, `2100 = 2110 − 2120` — пропущены (цель отсутствует), остальное сошлось.
**Как закрыть:** (1) явно зафиксировать в шаге 7: жёсткое соотношение, у которого цель отсутствует или `null`, а хотя бы один член непуст, — **нарушение**, не пропуск (это одна строка в правилах и закрывает сценарий); (2) дополнительно: `detail` недопустим под строкой-итогом (`xx00`) — «БАЛАНС» под `1200` становится `missing`; (3) см. G-10 про ручное подтверждение.
### G-3 (critical) — Сырой текст значения не хранится; пересчёт `needs_review` ядром «тем же кодом» (знак по конвенции колонки, F-1) невозможен, подсказка «сырой текст остаётся в подсказке ячейки» без источника
**Место:** шаг 4 п. 2–3, шаг 8 «Подтверждение проверкой» (знак), «Что уходит из ядра» (DBML v5.19, пересчёт после PATCH), Decisions № 10.
**Дыра.** Конвенция знака сравнивает «форму записи знака у ячейки (скобки, минус, без знака)» — это сырой текст. В контракте `string_value` заполняется **только** при `amount = null` (`scan-gateway/api/src/normalizer/ocr/document.py:149`, решение № 16; DBML `financial_statement_value.string_value` — «как распознано, если не число»). Шаг 4 п. 3 одновременно требует `string_value = сырой текст` для «догадки» с непустым `amount` — третья семантика. В списке DBML v5.19 нового поля с сырым текстом нет. Значит после PATCH ядро не может повторить F-1: либо вернёт ложные подсветки (AURORA-1524), либо пропустит правило — результат сервиса и ядра разойдутся. Данные (текст `(7744536}`) теряются.
**Сценарий:** aep_3 ОФР, `2120` = «(7744536}» → `amount = −7744536`, `is_recognized = false`, `string_value = null`. Аналитик правит `2210` в той же колонке → пересчёт → для `2120` конвенцию знака не из чего считать.
**Как закрыть:** добавить в контракт v2.0 и в `financial_statement_value` (v5.19) поле `source_text` (сырой текст ячейки всегда), оставить `string_value` в прежнем смысле; либо хранить результат конвенции как отдельный флаг (`sign_confirmed`) и не пересчитывать его в ядре. Зафиксировать одну семантику `string_value`.
### G-4 (critical, по регрессии данных) — «Один словарь периодов» молча заменяет разборщик дат шлюза более слабым
**Место:** шаг 1 п. 3 («дата → `date` — общий код по `text`»), шаг 2 п. 3 («один словарь периодов `statement_periods.yaml`»), принцип 5.
**Дыра.** Сегодня два разных разборщика: `scan-gateway/api/src/normalizer/ocr/dates.py` (диапазон «с … по …», «I квартал 2026», «1 полугодие», «9 мес.», числовые «30/09/25», двузначный год, склейка 8 цифр «30092025», хвост «0:00:00», «2|0|2|4|» через разделитель клеток) и `tabular-parser/api/rules/statement_periods.yaml` (`on_date`, `numeric_date` только с точками и четырёхзначным годом, `for_months`, `for_range`, `for_year`). Квартал, полугодие, диапазон, слэши, двузначный год, склеенные цифры в yaml **отсутствуют**. Документ не говорит, какой разборщик становится общим и что теряется.
**Сценарий:** aep_3 `date.form_name = "2|0|2|4|"` → сегодня `2024-12-31` (golden `report_date: "2024-12-31"`); по yaml → `null` → у aep_3 нет дат колонок (нет шапки) → «период не определён» (`error`) и редакция не выбрана. Также любой скан с подписью «за I квартал 2025 г.» или «с 01.01.2025 по 31.03.2025» теряет дату колонки → `error` «колонка без даты» (Р-6) → `needs_check`.
**Как закрыть:** общий разборщик = yaml-словарь, **дополненный** шаблонами `dates.py` (квартал/полугодие/диапазон/слэши/двузначный год/склейка цифр) как данными; перечислить в спеке тестовые строки `test_dates.py::test_parses_real_forms` как обязательные для словаря.
---
## IMPORTANT
### G-5 (important) — Защита F-5 «под подписью есть код» пропускает титульный блок 1С с ОКОПФ по ОК 028-2012
**Место:** шаг 2 п. 1 (а), Decisions № 3.
**Дыра.** Коды ОКОПФ современного классификатора пятизначные с префиксом в диапазоне ББ: `12300` (ООО → 1230), `12267` (АО → 1226), `12247` (ПАО → 1224), `12165`… 1С печатает их в столбце титульного блока «Коды». НПЗ: `12300` стоит в колонке 23 — той же, что подпись «Коды» (row 26, col 23; проверено `openpyxl`), выше неё; у Межениновской подпись «КОДЫ» стоит над блоком (row 4, col 18), ниже — ОКУД, ОКПО, ИНН, ОКВЭД, `65`, `384`. Форма с подписью над блоком и ОКОПФ `12300` под ней (стандартная раскладка 66н) проходит защиту «хотя бы один код из диапазона» → (а) выбирает титульный столбец, (б) не вызывается → документ без строк.
**Как закрыть:** в (а) требовать под подписью ≥ `min_codes` кодов (3), а при нескольких подписях брать столбец с наибольшим числом кодов; пятизначный код считать кодом только при наличии в том же столбце его четырёхзначного префикса.
### G-6 (important) — «ИНН не распознан» = `error` противоречит критерию `done` «(организация или ИНН)» и регрессирует ОФР НПЗ
**Место:** шаг 8 п. 1 и п. 2; Decisions № 1.
**Дыра.** П. 1: `done` ⇐ «(организация **или** ИНН)». П. 2: «ИНН не распознан» — `error` → `needs_check`. Условие «или» мёртвое. Сегодня это предупреждение выдаёт только шлюз (уровень файла); у парсера `inn = null` статуса не меняет (`statement.py:239` — `organization_name or inn`).
**Сценарий:** `npz/2025-Q3` ОФР — на листе нет метки «ИНН» (§12 п. 6, манифест `inn_optional_for: [INCOME]`, статус `done`) → под единым правилом `needs_check`. Регрессия не признана.
**Как закрыть:** «ИНН не распознан» → `info`, если организация найдена (сверку контрагента ядро делает по имени); `error` — только когда нет ни того ни другого; согласовать п. 1 и п. 2.
### G-7 (important) — Решение № 28 (ошибочный json набора понижает статус) сохраняется только в конверте, который ядро больше не читает
**Место:** шаг 8 п. 2–3; «Что уходит из ядра» (`RECOGNIZED ⇔ document.status = done ∧ …; остальные признаки D-6 не пересчитываются`).
**Дыра.** Сегодня `job_done` входит в `statement_status` (`arm-core/api/src/domain/statement_mapping.py:231`). Дизайн убирает его. Ошибка уровня файла (`aep_3_ofr.json: INVALID_DOCUMENT`) делает конверт `needs_check`, но `document.status` у BALANCE может быть `done` → `RECOGNIZED` → автоподтверждение, хотя ОФР потерян — ровно то, чего № 28 избегал.
**Как закрыть:** либо оставить `job_done` (статус конверта) в условии `RECOGNIZED`, либо класть предупреждения файла уровня `error` в `document_issue` каждого документа файла.
### G-8 (important) — Потеряна проверка диапазона кодов для слабого продолжения (N-1): «Справка» с кодом баланса приклеивается к ОФР
**Место:** шаг 1 п. 6 («`join_blocker` остаётся только по типу»), шаг 2 п. 1 («`relaxed` … не нужен»).
**Дыра.** Сегодня лист с 1–2 кодами без маркера присоединяется только если коды из диапазона открытой формы (`tabular-parser/api/src/parser/statements/statement.py:143-159` `_weak_blocker`, тесты `test_statement.py:394` «N-1»). В дизайне продолжение клеится «по позиции листа» и сверяется лишь тип из маркера (которого у такого листа нет). Лист «Справка» с одной строкой `1370` после ОФР попадает в сетку ОФР; большинство кодов — `2xxx`, тип не меняется, ошибки нет; строка `1370` становится `ok`-строкой ОФР. Поймает только предупреждение ядра «код не из редакции формы» к строке — оно не `error`, документ может быть `done`.
**Как закрыть:** для сегмента без шапки и без маркера с числом кодов < `min_codes` сохранить проверку «коды из диапазона `type_hint`», иначе — отдельный кандидат + `error`.
### G-9 (important) — `ref` не уникален: разлепленные строки и многолистовые формы → `line_ref` предупреждений и `document_issue.line_id` неразрешимы
**Место:** шаг 1 (`Cell.ref`), шаг 3 (`RawLine.ref`), шаг 8 п. 5, DBML `source_ref`.
**Сценарии:** `БО 1 кв 2025` row 73 «1500\n1700» → две строки с одним OCR `row_index` → одинаковый `ref`. НПЗ ББ — 5 листов в одной сетке; `ref` «адрес A1» без имени листа: `B12` есть на каждом листе.
**Как закрыть:** грамматика `ref`: OCR — `row:<row_index>[#<k>]` (k — часть разлепления), Excel — `<лист>!<A1>`; требование уникальности в документе.
### G-10 (important) — Ручное подтверждение документа со строками `missing`/`unreadable`, несущими суммы: их значения не входят в агрегат, КФО и ЕХ-снимок
**Место:** шаг 3 п. 4, «Что уходит из ядра» (агрегат по коду), §9.5 подтверждение.
**Дыра.** Агрегат берёт только строки `ok`. Условия подтверждения (`arm-core/api/src/services/documents.py:207-237`, `statements.py:70-81`) — период и жёсткие нарушения (акцептуемые с комментарием). Документ с `missing` «Выручка» (aep_3 ОФР row 0, `2110`, 8096738) подтверждается после акцепта → в КФО выручки нет, в `ex_snapshot_item.payload` её нет. То же для `detail`, принятых по Р-8 (цена признана), но для `missing`/`unreadable` решения нет.
**Как закрыть:** `form_confirmable`/`_check_statement` → 409 `LINES_UNRESOLVED`, пока есть `missing`/`unreadable` строки с непустым `amount`; аналитик назначает код или объявляет `detail` (F-14). Зафиксировать, что `detail` в ЕХ-снимок не попадают.
### G-11 (important) — `needs_review` строки есть в контракте и на экране, но его нет в DBML v5.19; `statements_version` негде хранить
**Место:** шаг 8 (единый выход: «`needs_review` … у каждой строки»), «Что уходит из ядра» (DBML v5.19; «версия … пишется в `recognition_job`»).
**Дыра.** Список полей v5.19 содержит `financial_statement_value.needs_review`, но не `financial_statement_line.needs_review` (результат Р-5). Принцип 3 запрещает считать при чтении. У `recognition_job` нового столбца нет.
**Как закрыть:** добавить `financial_statement_line.needs_review boolean`; `statements_version` — столбец `recognition_job.statements_version` или ключ в `metrics`.
### G-12 (important) — Снять `needs_review` без изменения значения нельзя; пустые ячейки с уверенностью 0 (Р-2) неправимы
**Место:** шаг 4 п. 5, шаг 8 «Подтверждение проверкой» («правка снимает `needs_review`»), «Что уходит из ядра» (PATCH).
**Дыра.** PATCH с тем же значением — не изменение (`arm-core/api/src/services/statements.py:205` `if value.amount == cell.amount: continue`; для несуществующей ячейки `amount = null` — пропуск, строка 197). Ячейка с уверенностью 71 и верным значением, не покрытая соотношением (25xx/29xx, колонки с мягкими правилами), или пустая ячейка с уверенностью 0 (aep_3 table 1 — две такие) остаётся `needs_review` навсегда; документ не выходит из `NEEDS_CHECK` иначе как через подтверждение целиком.
**Как закрыть:** в PATCH `values[].confirmed: true` (или явная запись того же значения считается подтверждением) → `needs_review = false`, `is_modified` не ставится, аудит `RECOGNIZED_DATA_EDIT`.
### G-13 (important) — Колонка без даты (Р-6): нет API поставить дату; `period_date` остаётся `null`, межформенная проверка и гудвилл «на отчётную дату» молча не считаются
**Место:** шаг 2 п. 6, Р-6 («аналитик подтверждает после сверки с источником»), «Что уходит из ядра» (правка даты отчёта).
**Дыра.** `HeaderPatch` правит только `inn/report_date/units` (`arm-core/api/src/schemas/statements.py:318`). «Ядру колонка без даты не мешает» верно лишь для `statement_period`; `_line_amount(period_date=…)` (`repositories/statements.py:306-327`), `pair_amounts`, сравнение версий и ЕХ используют `financial_statement_column.period_date`.
**Как закрыть:** PATCH `columns: [{column_id, period_date}]` с аудитом и пересчётом `is_current`/относительных колонок; `error` «колонка без даты» снимается после правки.
### G-14 (important) — Р-1: границы пакета `arm_statements` не определены (типы выхода, golden-тесты, словари)
**Место:** Р-1, шаг 9, принцип 1.
**Дыра.** (а) Шаги 2–8 отдают `StatementDocument` «как в контракте», но pydantic-модели контракта живут отдельной копией в каждом репозитории (`scan-gateway/api/src/contract/result.py`, `tabular-parser/api/src/contract/result.py`, `arm-core/api/src/contract/result.py:150-196`); во время перехода у ядра две модели (1.x и 2.0). Если пакет импортирует `src.contract`, он зависит от хоста и копия в ядре работает «по совпадению». (б) Р-1: «golden-файлы в fixtures» — соседний репозиторий; F-17 и `03-architecture.md` прямо запрещают тесты, читающие соседние репозитории (урок arm-core #33). Сегодня golden шлюза так и устроен: `scan-gateway/api/tests/golden_update.py:18` `REAL_FIXTURES = ../../fixtures/ocr`. Если тесты пакета копируются в три репозитория, они упадут в пайплайне платформы. (в) Свежесть копии проверяет только ручной `sync-statements --check`; расхождение всплывает у пользователя как `error` при PATCH (F-17).
**Как закрыть:** пакет владеет своими dataclass-ами выхода; хост конвертирует в свою модель контракта; golden пакета — внутри пакета (анонимизированные json), фикстурные тесты остаются только в scan-gateway/tabular-parser; `sync-statements --check` в `make check-full` (он запускается перед PR локально, не в пайплайне).
### G-15 (important) — Порядок строк на экране не определён: «как в файле, по `row_index`» против действующей раскладки «как на бумаге»
**Место:** «Что уходит из ядра» (экран: «строки как в файле, по `row_index`»; «строки missing/unreadable … на своём месте по row_index»).
**Дыра.** Сегодня фронт строит раскладку разделами и сортирует по коду (`arm-core/web/src/widgets/DocumentReview/formLayout.ts:1-4`, просьба владельца 09.10.2026), `load_form` сортирует по `line_code` (`repositories/statements.py:280`). Шаблонные строки `row_index` не имеют; `detail` должны стоять под родителем по `parent_row_index`, не по коду; при двух раскладках одновременно `unreadable` строка «на своём месте» и шаблонная `1600` оказываются в разных местах.
**Как закрыть:** одно правило: строки источника — по `row_index`; шаблонные — вставляются по порядку бланка между ближайшими соседями из источника; `detail` — под родителем; разделы — заголовками по первому коду сотни. Или явно оставить раскладку по коду, а `row_index` — только для `detail`/`unreadable`.
### G-16 (important) — Текстовые ячейки Excel в OCR-`parse_amount`: смена семантики «4,200», потеря знака «−» (U+2212), тонкие пробелы
**Место:** шаг 4 п. 1 («Excel-ячейки „число текстом“ идут тем же путём»), п. 3.
**Дыра.** Сегодня парсер: `"4,200"` → `4.20` (`numbers.py:101` запятая → точка), `"−1 234"` → `−1234` (`_MINUS_SIGN`), пробелы `[\s ]`. OCR `parse_amount` (`amounts.py:60`): `"4,200"` → `4200` (тысячи); `"−"` не в `_ALLOWED_RE` → выбрасывается → `+1234`, `is_recognized = false`; U+202F не убирается. Для членов `abs_terms` знак подхватит конвенция колонки, для прочих (напр. `1370` отрицательная нераспределённая прибыль текстом) — только если соотношение не сойдётся.
**Как закрыть:** в общем `parse_amount` добавить `−` в знаки и класс пробелов парсера; правило «запятая — тысячи» для `source = excel` применять только при ≥ 2 группах (`1,234,567`) или не применять.
### G-17 (important) — Упрощённые формы (КНД 0710096) не рассмотрены: жёсткие соотношения и перечень кодов им не подходят
**Место:** шаг 7, «Код и наименование строки», словарь `statement_checks.yaml`.
**Дыра.** В упрощённом балансе нет `1100/1200/1300`-подытогов части строк: `1600 = 1100 + 1200` с присутствующей целью и отсутствующими членами (с правилом G-2 — нарушение; сегодня — тоже нарушение в парсере и ядре, т. е. каждая упрощённая форма всегда `needs_check` с акцептом). Для OCR ни одна ячейка не покрывается (жёсткие соотношения не сходятся), все `< 100` подсвечены; порядок кодов (`1250` перед `1230`) не совпадает с полным бланком → строки не подтверждаются (G-1).
**Как закрыть:** отдельный набор соотношений и перечень кодов «упрощённая» в словарях, выбираемый по ОКУД `0710096` или по отсутствию `1100/1200` при наличии `1600`.
### G-18 (important) — Р-11 на ИНН портит ИНН aep_3; порядок «двойники → убрать пробелы» в разборе кода теряет «| 140»
**Место:** принцип 5 («ИНН и дата шапки (шаг 1)»), шаг 3 п. 1 («двойники, пробелы и переносы убираются, затем 4–5 цифр»).
**Сценарии:** aep_3 `inn.form_name = "3|1 21 2| о! О' 0 0; 3: 51"` → сегодня `digits_only` → `3121200351` (10 цифр, golden). С двойниками `|`→`1` вплотную к цифре («3|1», «2|») → 12 цифр → другой ИНН той же «валидной» длины → сверка с ЮЛ даёт `MISMATCH` вместо совпадения. Эвристика «`|` — разделитель клеток» (`text.py:22,29`) не срабатывает из-за букв в строке. Коды: aep_3 row 3 «| 140» (это 1140) — в заданном порядке `|` не вплотную к цифре → остаётся → после удаления пробелов «|140» → `unreadable`; в обратном порядке → `1140` → `ok`.
**Как закрыть:** ИНН — только цифры без двойников (или двойники-буквы `О/З/б/S/Ч`, но не `|/I/l`); для кодов зафиксировать порядок «убрать пробелы и переносы → двойники → 4–5 цифр» и ограничить `|`→`1` случаем, когда результат попадает в диапазон кодов.
---
## MINOR
### G-19 (minor) — Шаг 6 п. 1: `DISCLOSURE` «шаги 2–7 не выполняются», но тип по большинству кодов определяется в шаге 6 после шагов 2–5; коды 3xxx/4xxx вне диапазонов шага 2 п. 1
Таблица ОИК/ОДДС с нечитаемым `label` → `type_hint = null` → коды `3xxx/4xxx` не считаются кодами → «колонка кодов не найдена» → «не документ, `info`», а не «прочие → DISCLOSURE». Зафиксировать: `DISCLOSURE` по `type_hint` — шаги 2–7 пропускаются; по кодам — шаги 2–5 выполняются, строки отбрасываются.
### G-20 (minor) — Уровни: «в шапке не найдена организация» (парсер `NO_ORGANIZATION_WARNING`) без уровня; «формат файла не совпал с `file_type`» становится `error` (сегодня — предупреждение, `done` возможен) — регрессия не признана.
### G-21 (minor) — F-25 множит мусор: aep_6 row 6 «44 30\nI |» и row 90 «4 400\nI #4У» (значения пустые) → по две `unreadable` строки из одной. Сегодня разлепление требует, чтобы все части были цифрами (`rows.py:98`). Требовать, чтобы хотя бы одна часть разобралась как код.
### G-22 (minor) — Правило кода «текст без пробелов и переносов, 4–5 цифр» оставляет `1220 ,` (aep_3 row 11, НДС, суммы 157/4547/5746) в `unreadable`; сегодня строка тоже теряется (`classify_row` делает `int()` по сырому тексту, `rows.py:63`), так что не регрессия, но обрезка краевой пунктуации (как `clean_value`) даёт `ok` — это и есть цель 1462.
### G-23 (minor) — `HeaderPatch.report_date: null` очищает дату; относительные колонки (Р-6/F-10) сохранят `period_end`, посчитанный от старой даты, и `statement_period` возьмёт его как «абсолютную» — определить обнуление.
### G-24 (minor) — Р-10: две формы одного типа и периода в одном файле автоподтверждаются одним `now`; привязка берёт «самый поздний `confirmed_at`» с разрешением ничьей по `uuid` (`consolidation_documents.py:115-118`) — к версии привязывается случайный дубль. Решить: вторую форму того же типа и периода не автоподтверждать (`NEEDS_CHECK`) или выбирать по порядковому номеру формы.
### G-25 (minor) — `document_issue.line_id?` одиночный, а нарушение «подсвечивает все строки кода» (Р-4: `1220` ×2 в `БО`); нужен список или строка на каждую `line_id`. Не сказано: `_clear_file` чистит `document_issue`; `core`-источник заменяется при каждом пересчёте (иначе «версия копии расходится» останется после обновления ядра).
### G-26 (minor) — Принцип 3 («ядро при загрузке ничего не пересчитывает») против «проверки по словарю строк ядро делает само при загрузке» — уточнить формулировку.
### G-27 (minor) — Факт: «у ИС поле `date` в трёх из четырёх фикстур не дата» — с текущим разборщиком не дата у двух (aep_6, БО); у aep_3 «2|0|2|4|» разбирается, у «Бухгалтерская отчетность» — «за январь - март 2025 г.». Три из четырёх — только с yaml-разборщиком (G-4).
### G-28 (minor) — Шаг 2 п. 1 перечисляет подпись «Код строки», но `code_caption: ^код(ы)?$` парсера её не матчит (OCR `is_code_header` — `startswith("код")` матчит); расширить словарь.
### G-29 (minor) — Столбец наименования и исключение «Пояснения» (`_name_column`, `note_cols`, `_line_name` с поиском левее кодов) в целевых шагах 2/5 не описаны; у OCR — своё правило («наименование», иначе слева от кода). Задать одно.
### G-30 (minor) — PATCH назначения кода: в `before/after` аудита должны попадать `line_status` и `parent_row_index` (операция «объявить `detail`»); после назначения `needs_review` строки → `false` (аналог `is_modified`) — не сказано.
---
## Что проверил и признал консистентным
- **Р-4 (сумма по коду)** на реальных данных: «Бухгалтерская отчетность 1 кв.25» — `1220` ×2 (19 062 + 9 273), `1520` ×2 (771 605 + 89 221); `1200 = Σ` и `1500 = Σ` сходятся только с суммой; сегодняшний golden отбрасывает вторые строки и соотношения в ядре не сходятся. Решение подтверждается данными.
- **Р-2 (пустые ячейки):** в четырёх json ни одной пустой ячейки с `0 < confidence < 100`; `confidence = 0` при пустом тексте — только aep_3 table 1 (2 шт.). Основание Р-2 верно.
- **Р-8 (`detail`):** правило «ближайшая строка с кодом выше» на всех «в том числе» aep_6/БО/Бухгалтерская даёт правильных родителей (`1210`, `1230`, `1250`, `1410`, `1520`, `2110`, `2120`, `2340`, `2350`).
- **F-25 / OQ-156:** «1500\n1700» с тремя парными суммами в БО и Бухгалтерской разлепляется по правилу.
- **Сегменты:** повторные шапки «Код» на страницах 2 (aep_6 row 72, БО row 42, Бухгалтерская row 50) укладываются в правило шага 2 п. 2.
- **Словарь §7:** `arm-core/api/rules/statement_checks.yaml` и `tabular-parser/api/rules/statement_checks.yaml` побайтно совпадают (без комментариев) — тезис «две копии» верен, расхождения сейчас нет; `abs_terms` в дизайне (`2120, 2210, 2220, 2330, 2350, 2410`) = словарю; допуск ±1 и округление 0,001 % описаны как в коде (`checks.py:127-140`, `statement_checks.py:214-222`).
- **Эффективная дата** = `statement_period` ядра (`statement_mapping.py:73-80`), редакция по границе 2024-12-31 (`edition_for`, `rules.py:88-93` шлюза) — согласовано.
- **Р-9 / F-7:** `StatementLine.line_code: str` в ядре (`contract/result.py:160`) — `null` действительно уронит валидацию; порядок «сначала ядро» обоснован.
- **Р-10 и привязка:** ключ привязки `(ЮЛ, тип, счёт, квартал, нарастающий)` (`consolidation_documents.py:104-111`) не использует уникальность `document`, снятие индекса привязке не мешает (кроме G-24).
- **Грамматика `locator`** и golden-эталоны `file:<json>;table:<i>` — не меняются (F-23) — верно.
- **Таблица уровней** шага 8 п. 2 и Decisions № 1 согласованы между собой (кроме G-6, G-20).
- Decisions № 2, 3, 5, 6, 7, 11, 12, 13 согласованы с соответствующими шагами.
## Что смотрел
**Документы:** `design.md` (целиком), `05-domain-rules.md` §7, §11, §11.1, §12, `03-architecture.md` (контракт, копии словарей, цикл маппинга), `recognition-result.schema.json` v1.2 (`$comment`, `statementDocument`, `failedRelation`, `money`, `metrics`, `service`), DBML v5.18 (`document`, `financial_statement_meta/column/line/value`, `recognition_job`, `audit_event`, enum `audit_action`, `document_status`).
**Фикстуры (скриптом `python3 -I`):** `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`; `expected_normalize/{README.md, aep_3, aep_6, БО 1 кв 2025}.json`; `statements-xlsx`: `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`, `npz/2025-Q3/…xlsx` (openpyxl), `tabular-parser/api/tests/statements_manifest.yaml`.
**scan-gateway:** `api/src/normalizer/ocr/{layout.py, rows.py, amounts.py, dates.py, text.py, header.py, document.py, __init__.py}`, `api/src/normalizer/rules.py`, `api/rules/{statement_labels, ocr_lookalikes, statement_lines}.yaml`, `api/tests/golden_update.py`.
**tabular-parser:** `api/src/parser/statements/{detect, columns, lines, merge, statement, checks, number_format(display_amount), header(parse_header, _report_date)}.py`, `api/src/parser/{book.py (parse), grid.py, numbers.py, detect.py (classify_sheet), rules.py (StatementPeriodRules, line_codes)}`, `api/rules/{statement_checks, statement_periods, statement_headers}.yaml`, тесты `test_statement.py` (N-1, O-9), `test_statement_synthetic_files.py` (SKIPPED_COLUMN_WARNING).
**arm-core:** `api/src/domain/{statement_mapping, statement_checks, statement_lines}.py`, `api/src/services/{result_mapping (_map_statement_docs, _insert_statements, map_file), statements, auto_confirm, documents (_check_statement), consolidation_documents (_key, fits, _latest_first)}.py`, `api/src/repositories/statements.py` (insert_statement, statement_amounts, value_flags, form_lines, load_form, _line_amount), `api/src/schemas/statements.py`, `api/src/contract/result.py`, `api/rules/{statement_checks, statement_lines}.yaml`, `api/src/api/endpoints/files/statements.py`, `web/src/widgets/DocumentReview/{statementGrid.ts, formLayout.ts, StatementTable.tsx, ChecksPanel.tsx}`.

View File

@ -0,0 +1,148 @@
# Повторное ревью design.md — проверка закрытия F-1…F-25 (17.10.2026, второй раунд)
Ревьюер: отдельный агент (модель Fable), без права правки; получил обновлённый design.md (после коммита 63f134b) и
`review-2026-10-17.md`. Задание: по каждой находке F-1…F-25 найти место закрытия, прогнать сценарий через новые
правила, оценить статус; отдельно — согласованность правок между собой. Маркеры новых проблем — V-n.
Отчёт агента ниже без сокращений.
---
# Повторное ревью design.md (unified-statement-normalization) — закрытие F-1…F-25
Ревьюер: независимый агент, без права правки. Проверено: `design.md` целиком (763 строки), `review-2026-10-17.md`, §7/§11/§11.1/§12 `05-domain-rules.md`, контракт v1.2, DBML v5.18, код трёх сервисов (через codegraph + Read), фикстуры OCR (4 успешных json, golden-эталоны) и Excel (НПЗ, Унагранде ×2, Межениновская, синтетика) скриптами `python3 -I` / venv парсера. Решения Р-8…Р-11 не пересматривались.
## 1. Таблица по находкам
| F-n | Где закрыто | Статус | Что осталось / новая проблема |
|---|---|---|---|
| F-1 | Шаг 7 (abs_terms «подтверждает только величину»), «Подтверждение проверкой» (знак по конвенции колонки), шаг 4 п. 1 (`}`/`]` — закрывающая скобка), Р-7, решение № 10 | closed, new issue | Правило верно на данных (aep_3 ОФР: все члены abs в скобках, `(7744536}` подтверждается, `{38378)` — подсвечивается). Но «форма записи знака» не доезжает ни до контракта, ни до БД — ядро не может пересчитать конвенцию после PATCH (**V-3**); область применения правила не ограничена сомнительными ячейками (**V-13**) |
| F-2 | Шаг 7 («посчитано, если хотя бы один член непуст»), «Подтверждение проверкой» (покрыто ⇒ `amount` не `null`), Р-7 | closed | Сценарий «колонка залита полосой» даёт `needs_review = true` у всех мусорных ячеек; сценарий «1600 мусор, 1100/1200 читаемы» — нарушение, все три подсвечены |
| F-3 | Шаг 3 (статус `detail`, родитель — ближайшая строка с кодом выше в сегменте), Р-8, решение № 9, «Что уходит из ядра» (экран скрывает `detail`) | closed, new issue | По фикстурам все 90 строк без кода с суммами в 8 OCR-таблицах — `detail`, единственная `missing` — «Выручка» aep_3 (код 2110 в колонке значений, верно). У Excel НПЗ/Унагранде `missing` нет. Но роль строк титульного блока над заголовком не определена — у Межениновской «Дата 30 \| 9 \| 2025» станет `missing` (**V-8**) |
| F-4 | Принцип 2, шаг 3 (`row_index`, `ref`), шаг 8 п. 5, DBML v5.19 (`row_index`, `source_ref`, `parent_row_index`), «Экран показывает строки как в файле» | closed | — |
| F-5 | Шаг 2 п. 1 (а) «подпись принимается только с кодом под ней, последняя над первой строкой с кодом», п. 4 (обе ветки парсера сохранены), решение № 3 | partial | Защита «хотя бы один код под подписью» пробивается ОКОПФ **12267** (АО) в титульном блоке 1С: Унагранде ББ Лист 1 и Унагранде Групп ОФР 3кв.25 — под «Коды» стоит 12267 (1226 ∈ [1100, 1799]); правила выбора между двумя столбцами-кандидатами нет (**V-1**, critical). Ветка «заголовок по периодам» перенесена — верно |
| F-6 | Шаг 2 п. 3 (эффективная дата), шаг 8 п. 1 («дата отчёта **или** самая поздняя абсолютная дата колонок»), п. 2 (`info` «дата отчёта не разобрана при наличии дат колонок»), решение № 12 | closed | Совпадает с `statement_period` ядра (`statement_mapping.py:73-80`) |
| F-7 | Шаг 9 (v2.0, порядок «сначала ядро», два маппера), Р-9 | closed | Паттерн `^1\.\d+$` в `contract/common.py:33` поднимается до `^(1\|2)\.\d+$`; запрос `recognition-request` остаётся 1.x (шаг 0) — не затронут. В спеке нужно задать, чем заполняются новые поля при приёме 1.x (`line_status = ok`, `row_index` по порядку, `needs_review` — старое правило) — не противоречие, а пробел спеки |
| F-8 | Шаг 8 п. 1 (одна функция `done`), «Подтверждение проверкой» (is_modified ⇒ false, акцепт, `error` сохраняются), DBML v5.19 (`line_status`, `line_code_text`, `row_index`, `value.needs_review`, `document_issue`), решение № 11 | partial | Пп. 2–4 закрыты. П. 1 закрыт для значений, но **`needs_review` строки** (F-9) в DBML v5.19 не добавлен, и правило «назначение кода снимает `needs_review` строки» не записано (**V-5**). Пересчёт конвенции знака после PATCH невозможен без формы записи (**V-3**) |
| F-9 | «Код и наименование строки» (упорядоченный перечень по редакциям, порядок бланка, редакция по эффективной дате / обе, равные соседние допустимы), шаг 5 п. 2, Р-5 | closed, new issue | Итоги после слагаемых — верно. Но пятизначные подстрочные коды (11101, 12101, 23201 — все выгрузки 1С: НПЗ, Унагранде) ни в какой перечень бланка не входят → `needs_review` у каждой такой строки → НПЗ ББ/ОФР и Унагранде ОФР из `done` уходят в `needs_check` (**V-2**, critical). Алгоритм «положение относительно соседних подтверждённых» циклически определён. Порядок/состав редакций теперь в двух местах (**V-11**) |
| F-10 | Шаг 2 п. 3 (`period_kind = relative:<вид>`, `period_end = null`), «Что уходит из ядра» (PATCH даты → ядро досчитывает общим кодом), Р-6, DBML `column.period_kind` | closed | Согласовано с F-6: эффективная дата из колонок даёт относительным датам опору и без правки |
| F-11 | «Что уходит из ядра» (снять уникальность `document`, порядковый номер формы), Р-10 | closed, new issue | Потеря устранена, но не сказано, что делать с двумя документами одного типа и периода у одного ЮЛ (чек-лист, консолидация, `_pair_document`), и как быть с половинками формы (OCR: «одна таблица = одна форма, склейки нет») — склейки внутри файла нет (**V-7**) |
| F-12 | Шаг 8 п. 2 (таблица уровней), решение № 1 | closed, new issue | Все блокираторы из ревью перечислены (json набора, `BACKEND_PARSING_ERROR`, лист без кодов, сегменты, колонка без даты, заголовок, тип), xlsb/дробные решены. Но «ИНН не распознан» → `error` противоречит критерию `done` «(организация **или** ИНН)» и ломает НПЗ ОФР без ИНН (`inn_optional_for: [INCOME]`, сегодня `done`); «формат не совпал с `file_type`» → `error` противоречит собственному принципу решения № 1 (**V-6**) |
| F-13 | Шаг 7 («не сошлось» = вне допуска ±1 **и** вне округления 0,001 %; округление и мягкие → `soft`, `info`), «Подтверждение проверкой» (покрыто в пределах допуска или округления; мягкие подсветки не дают), шаг 8 п. 1 | closed | НПЗ (1600 = 1700 на 1,94 при ~17 млрд) и Унагранде (мягкие 2400/2410) остаются `done` по этому пункту |
| F-14 | «Назначение кода строке» (`PATCH …/statement lines: [{line_id, line_code \| null, line_status}]`, аудит, пересчёт, `missing → detail`, `detail → ok`), экран: поле кода у `unreadable`/`missing`/`detail` | closed | Мелкие пробелы спеки: переход `ok → detail`, недопустимая пара `line_code: null` + `line_status: ok`, `parent_row_index` при смене статуса (**V-12**, minor) |
| F-15 | «Что уходит из ядра» (одна функция «агрегат формы по коду», перечислены `_line_amount`, `statement_amounts`, `subline_parents`/`parent_code`, `formLayout`, `_resolve_cells`, `metrics.fields_*`), Р-4 | closed | Все точки из ревью названы. Уточнить формулировку: сумма — по строкам `ok` (шаг 7 говорит это, «Что уходит из ядра» — «сумма значений по колонке» без оговорки) |
| F-16 | Шаг 1 (`segment_breaks`, «граница листа — всегда граница сегмента»), шаг 2 п. 2 (своя раскладка, по числам без шапки, сопоставление по порядку, число не совпало → `error`), п. 6 шага 1 | closed, new issue | Сегмент-продолжение **без шапки и с 1–2 кодами** (НПЗ ОФР лист `_2` — единственная строка 2400, синтетика st07) по правилу п. 1 (б) «кодов ≥ 3» столбца кодов не получит; решение № 3 снимает `relaxed`, ссылаясь на склейку в шаге 1, но склейка не помогает — раскладка у сегмента своя (**V-4**) |
| F-17 | Р-1 (пакет `arm_statements` с тестами и конфигом линтеров, импорт по имени пакета, `service.statements_version`, `sync-statements --check` в docs, словари со своим загрузчиком), шаг 9, «Что уходит из ядра» (версия в `recognition_job`, расхождение — `error`) | closed | Все 5 пунктов закрыты. `recognition_job` не имеет колонки под `statements_version` (есть только `service_version`), в список DBML v5.19 не внесена (**V-10**, minor) |
| F-18 | «Что уходит из ядра» (`RECOGNIZED ⇔ done ∧ ИНН ≠ MISMATCH ∧ период — конец квартала`), шаг 8 п. 1 («квартал — знание ядра») | closed | — |
| F-19 | Принцип 5, Р-11 (двойники в суммах и кодах ⇒ `is_recognized = false`, далее §7 с оговорками F-1/F-2), шаг 4 п. 2 («замена двойника — исправление») | closed | Решение владельца, зафиксировано; §11.1 п. 1 помечен к отмене. Данные: в 4 json двойников в суммах/кодах нет (`(Ю4)` в aep_6 — «Ю» не в списке двойников, остаётся грязной) |
| F-20 | Шаг 4 п. 1 (квантование half-up, 16 целых цифр, дата/логическое → `null` + `string_value`, догадка > 16 цифр → `null`), решения № 6, № 13 | closed | — |
| F-21 | Шаг 2 п. 1 («код — текст без пробелов и переносов»), шаг 3 п. 1 («1 100» → 1100) | closed | Порядок с разлеплением (F-25) не оговорён: «переносы убираются» в п. 1 против «N частей через перевод строки» в п. 2 (**V-9**, minor) |
| F-22 | Шаг 6 п. 1 (5xxx и прочие → `DISCLOSURE`; без кодов — `type_hint`; `DISCLOSURE` без шагов 2–7), решение № 2 | closed | Форма 3/4 (3xxx/4xxx не в диапазонах, label не в словаре) → «не документ, `info`» — как сегодня (golden aep_8_form4 = `error` по ответу ИС); согласовано |
| F-23 | Шаг 1 (`locator file:…`), шаг 6 п. 3, шаг 8 п. 3 (`RECOGNITION_FAILED` / `INVALID_DOCUMENT` — различие записано), решение № 5 | closed | — |
| F-24 | Шаг 2 п. 1 («новое поведение для обоих: шлюз сегодня пропускает со счётчиком, кодом считает любое целое») | closed | — |
| F-25 | Шаг 3 п. 2 (разлепление и при нечитаемой части → `unreadable`; число частей не совпало → одна строка, многострочное значение → `null` + `string_value`) | closed | См. V-9 о порядке операций |
Итого: closed — 15, partial — 2 (F-5, F-8), closed с новой проблемой — 8 (F-1, F-3, F-9, F-11, F-12, F-16 + мелкие у F-14, F-17, F-21).
## 2. Разбор незакрытых и новых проблем
### V-1 — critical — F-5: защита «под подписью есть код» пробивается ОКОПФ в титульном блоке 1С; тай-брейка между столбцами-кандидатами нет
**Где:** шаг 2 п. 1 (а), решение № 3.
**Что проверил.** Скриптом по xls/xlsx прошёл все ячейки `^код(ы| строки)?$` и содержимое столбца под ними:
- `unagrande-co/2024-FY/Б.Б Лист 1.xls`: «Коды» r6c34 → под ней `0710001, 31, 47888105, 3249004697, 10.51.3, **12267**, 384, 7716021332, 1037739271701`; «Код» r28c22 → `1110, 11101, 11102, …`.
- `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`: «Коды» r3c18 → `0710002, 30, 69583357, 7728757141, **12267**, 384`, а дальше в том же столбце **значения таблицы** (`За Январь - Сентябрь 2024 г.`, `287017990.57`, `-302669265.62`…) — титульный столбец совпал с колонкой сумм.
`12267` (ОКОПФ «АО») по правилу дизайна — код: 4–5 цифр, первые четыре `1226 ∈ [1100, 1799]` (`statement_headers.yaml:86`; `columns.py:63-71` `line_code`). Для ООО (ОКОПФ 12300) то же. У НПЗ и Межениновской ОКОПФ в этом столбце нет — там защита работает.
**Сценарий.** Два столбца удовлетворяют (а). Правило «последняя такая подпись над первой строкой с кодом»: если «первая строка с кодом» — глобально по сетке, это r13 (ОКОПФ) → единственная подпись над ней «Коды» r6 → столбец c34 → каждая строка формы становится `unreadable`/`missing`, у Унагранде Групп ОФР столбец сумм объявляется кодами. Если «первая строка с кодом» — по столбцу, кандидатов два, а правила выбора в дизайне нет; реализация «первый найденный сверху/слева» выберет титульный блок. Сегодня парсер защищён порядком: сначала столбец по подсчёту (`find_code_column`, `columns.py:74-95`: кодов ≥ 3, доля ≥ 0,6), потом подпись только в этом столбце (`_find_header_row`, `:127-146`).
**Как закрыть.** Требовать под подписью не «хотя бы один код», а долю кодов среди непустых ≥ `min_share` (под «Коды» r6c34 — 1 из 9 = 0,11, под «Код» r28c22 — ~1,0; «документ из двух строк» сохраняется: 2 из 2); при нескольких кандидатах — столбец с наибольшим числом кодов. Либо вернуть порядок парсера (подсчёт → подпись в найденном столбце → заголовок по периодам), а исключение для малых таблиц делать явной веткой. Любой вариант зафиксировать в решении № 3.
### V-2 — critical — F-9 / Р-5: пятизначные подстроки не входят ни в один перечень бланка → каждая выгрузка 1С с детализацией теряет `done`
**Где:** «Код и наименование строки», шаг 5 п. 2, Р-5.
**Что проверил.** Пятизначные коды в фикстурах: НПЗ ББ — `11101…11103, 11501…11509, 11901, 12101…12106, 12201…, 12501…, 13401…, 15201…`, НПЗ ОФР — `23201…23203, 23301, 23401…, 25101, 25102`; Унагранде ББ — `11101…11507`. Это подстроки «в том числе» 1С (§12 п. 5: «пятизначные подстроки отдаются как обычные строки»; §7 «родитель = Σ подстрок», `statement_checks.py:141-184` `subline_parents`/`_subline_rules`). В словаре ядра `statement_lines.yaml` (132 строки, редакции 66n/fsbu4) пятизначных кодов нет — и в «упорядоченном перечне кодов по редакциям» их быть не может: они свои у каждой организации.
**Сценарий.** Правило дизайна: «Код подтверждён, если он **есть в перечне** и его положение … совпадает с порядком бланка; … иначе `needs_review` у строки, документ — `needs_check`». Уверенность не спасает: «Подтверждён — уверенность не важна; иначе `needs_review`» — оговорки для `confidence = 100` нет. НПЗ ББ (43 кода на листе 2, большинство пятизначные), НПЗ ОФР, Унагранде ОФР 3кв.25 — сегодня `done` (манифест строки 18, 39, 47–48) — по дизайну `needs_check`; Excel-путь теряет автоподтверждение целиком, у шлюза 1С-подобных сканов тоже.
Второе: «положение относительно соседних **подтверждённых** кодов» — определение циклическое, алгоритм не задан.
**Как закрыть.** (а) Пятизначный код подтверждён, если его четырёхзначный родитель по правилу `subline_parents` (ближайшая строка выше с теми же тремя цифрами, иначе код из первых четырёх цифр) подтверждён и подстрока стоит после него; (б) алгоритм — наибольшая подпоследовательность кодов, согласованная с порядком бланка хотя бы в одной из проверяемых редакций; коды вне её — `needs_review`; (в) явно спросить владельца: подтверждать ли структурой коды с уверенностью 100 (Excel) — по тексту шага 8 п. 1 «Excel выполняет условия уверенности автоматически» это, видимо, не задумывалось.
### V-3 — important — F-1 ↔ F-8: «форма записи знака» не хранится — ядро не может пересчитать конвенцию после PATCH; «сырой текст в подсказке» для распознанных ячеек пуст
**Где:** «Подтверждение проверкой» (знак), «Что уходит из ядра» (пересчёт `needs_review` общим кодом после PATCH), шаг 4 п. 1–3, шаг 9.
**Что проверил.** Контракт v1.2: `values[].string_value` — `["string","null"]` (схема l.199), DBML `financial_statement_value.string_value` — «как распознано, если не число»; шлюз пишет его только при `amount = null` (`document.py:149-151`). Дизайн (шаг 4 п. 3) расширяет это до «догадки» (`is_recognized = false`), но у ячейки, разобравшейся чисто, `string_value` остаётся `null`. Форма записи (скобки / минус / без знака) из `amount = −7744536` не восстанавливается.
**Сценарий.** aep_3 ОФР, колонка c2: 2120 `(7744536}` 88,9 → покрыто по величине, знак по конвенции (2210 `(693052)` 100, 2220 `(278598)` 100 — скобки) → `needs_review = false`. Аналитик правит любую ячейку документа → PATCH → ядро «пересчитывает `checks` и `needs_review` общим кодом»; входа «форма записи» у ядра нет → либо ветка знака пропускается (подсветка знака исчезает молча — то, против чего F-1), либо «сравнивать не с чем» → `needs_review = true` у всех членов `abs_terms` → `done` недостижим правкой. Второе следствие: 2210 `(546441)` с уверенностью 62,5 в c4 (чистый разбор, `is_recognized = true`) — «сырой текст и уверенность остаются в подсказке ячейки», но сырого текста в результате нет.
**Как закрыть.** В контракт v2.0 — `values[].text` (сырой текст источника всегда, как `Cell.text` шага 1), в DBML v5.19 — `financial_statement_value.source_text`; конвенция и подсказка считаются от него. Альтернатива — `sign_form: brackets | minus | none` + `text` только у `is_recognized = false`, но это два поля вместо одного.
### V-4 — important — F-16 / решение № 3: сегмент-продолжение без шапки с 1–2 кодами не получает столбца кодов — строка 2400 НПЗ теряется
**Где:** шаг 2 п. 1 (б) («кодов ≥ 3»), п. 2 («без шапки — столбец кодов по правилу п. 1»), решение № 3 («`relaxed` … снят, потому что продолжения склеиваются в шаге 1»).
**Что проверил.** НПЗ, лист «Отчет о финансовых результата_2»: подписи нет, кодов в столбце — **один** (2400); манифест строка 48: «лист с единственной строкой 2400 присоединён (O-9)», документ `done` по 3 листам. Сегодня: `_continuation` → `find_layout(relaxed=True)` (`statement.py:94-105`, `min_codes_continuation: 1`, `statement_headers.yaml:92`), затем `_weak_blocker` (`:145-160`). Синтетика `st07_single_code_continuation.xlsx` — тот же случай.
**Сценарий.** По дизайну лист дописывается в сетку в шаге 1, граница листа открывает сегмент, «у сегмента своя раскладка: … без шапки — столбец кодов по правилу п. 1» → (а) подписи нет, (б) кодов 1 < 3 → столбца нет. Что происходит с сегментом без столбца кодов, не сказано; при любом чтении строка 2400 либо теряется (принцип 2 нарушен), либо весь документ получает `error` «лист без колонки кодов» → `needs_check`. Склейка в шаге 1 этому не помогает — раскладка всё равно своя.
**Как закрыть.** Для сегмента не первого: подписи нет → столбец кодов наследуется от предыдущего сегмента, если в нём хотя бы один код (это и есть сегодняшний `relaxed` + `_weak_blocker` «коды из диапазона формы»); иначе — правило (б); не найден → `error` сегмента. Поправить решение № 3.
### V-5 — important — F-8 / F-9: `needs_review` строки негде хранить; снятие его назначением кода не описано
**Где:** «Код и наименование строки» (`StatementLine.needs_review`), шаг 8 п. 1 («нет значений **и строк** `needs_review`»), DBML v5.19 (только `financial_statement_value.needs_review`), «Назначение кода строке».
**Сценарий.** Результат с `needs_review = true` у строки 1230 (код с уверенностью 50 не на месте) → ядро «при загрузке ничего не пересчитывает, хранит как пришло» — колонки нет → признак теряется; после PATCH функция `done` получает вход «статусы строк, `needs_review`» неполным. Аналитик назначает код `unreadable`-строке (F-14) — `line_code_modified = true`, но правила «`line_code_modified` ⇒ `needs_review` строки = false» нет (для значений аналогичное `is_modified` ⇒ false записано) → строка подсвечена навсегда.
**Как закрыть.** DBML v5.19: `financial_statement_line.needs_review` (как пришло, пересчёт после PATCH); правило `line_code_modified = true` ⇒ `needs_review = false`; либо явно: признак строки хранится записью `document_issue(kind = warning, line_id)` и критерий `done` читает его оттуда — но тогда так и написать.
### V-6 — important — F-12: уровень `error` для «ИНН не распознан» и «формат не совпал с `file_type`» противоречит критерию `done` и принципу решения № 1; регрессия НПЗ ОФР
**Где:** шаг 8 п. 1 («(организация или ИНН)»), п. 2 (`error`: «ИНН не распознан», «формат файла не совпал с `file_type`»), решение № 1 («`error` — то, что меняет данные или их полноту»).
**Сценарий.** НПЗ ОФР: в шапке нет ИНН (манифест строка 45 `inn_optional_for: [INCOME]`), организация есть; сегодня `done`. По дизайну критерий 1 проходит (организация есть), но предупреждение «ИНН не распознан» уровня `error` даёт `needs_check` — два правила одного документа спорят, побеждает `error`. Формулировка «не распознан» — из шлюза; что сервис выдаёт у Excel при отсутствующем ИНН, не сказано (сегодня парсер об ИНН не предупреждает). «Формат не совпал с `file_type`»: файл прочитан верно, данные и полнота не изменились — по собственному принципу решения № 1 это `info`; сегодня — предупреждение без влияния на статус.
**Как закрыть.** «ИНН не найден/не распознан» → `error` только если и организации нет (= критерий «организация или ИНН»); при организации — `info`, сверку с ЮЛ делает ядро (`MISMATCH`). «Формат не совпал» → `info`. Таблицу уровней выровнять с принципом решения № 1.
### V-7 — important — Р-10 / F-11: два документа одного типа и периода у одного ЮЛ — правила выбора нет; половинки формы внутри файла не склеиваются
**Где:** «Что уходит из ядра» (DBML `document`, «привязка к версии консолидации — по периоду, как у ОСВ»), Р-10, шаг 1 п. 6 (OCR: «одна таблица = одна форма, склейки нет — без изменений»).
**Сценарий 1.** Лист-продолжение с маркером другого типа → отдельный кандидат того же периода; или ИС вернула ББ двумя таблицами (актив/пассив) — у ОСВ второй документ различим по счёту, у ББ — нет. Чек-лист `required_document_rule` (индекс `(legal_entity_id, document_type, osv_account_code, period_year, period_quarter, status)`), привязка к консолидации «по периоду», межформенная проверка `_pair_document` (`repositories/statements.py:330-346`: подтверждённый, затем последний по `created_at` — в одном файле `created_at` одинаков) — все берут «один документ на тип и период». Два подтверждённых ББ одного периода → неопределённость в КФО.
**Сценарий 2.** Половинки (актив `needs_check` по `1600 = 1700`, пассив `needs_check`) теперь обе хранятся, но операции «склеить» нет (OQ-140 запрещает только между файлами, внутри файла не описано) — аналитик не может довести файл до `done` иначе как перезагрузкой.
**Как закрыть.** Записать: в чек-лист и консолидацию идёт подтверждённый документ; подтверждение второго документа того же типа и периода у ЮЛ — 409 с указанием первого (или автозамена, как между файлами, §12 «Автозамена», `05-domain-rules.md:689`); склейка половинок не предоставляется — зафиксировать как известное ограничение или вопрос в `07-open-questions.md`.
### V-8 — minor — F-3 / шаг 2–3: роль строк над строкой заголовка не определена — титульный блок с числами становится `missing`
**Где:** выход шага 2 (`TableLayout`: роли `header | numbering | data`), шаг 3 («по строке на каждую строку с ролью `data`, у которой есть код или хотя бы одно число»).
**Что проверил.** Межениновская Форма1: над заголовком (r23) строки r6 «Дата (число, месяц, год) | 30 | 9 | 2025» и r15 «подлежит обязательному аудиту | 0 | ДА | 1» — кода нет, числа правее столбца кодов; Форма2 — r6. Сегодня они не данные: `find_layout` ставит `first_data_row = header_last_row + 1` (`columns.py:294-310`), `collect_lines` начинается с неё.
**Сценарий.** Если в `TableLayout` эти строки — `data` (третьей роли нет), по шагу 3 они без кода и с числами над первой строкой с кодом → `missing` → `done` недостижим у Межениновской (сегодня `done`, манифест строки 50–56).
**Как закрыть.** Роль `title` (или «не строка») для всего, что выше строки заголовка сегмента; при отсутствии заголовка — выше первой строки с кодом; `missing` — только между заголовком и первой строкой с кодом.
### V-9 — minor — F-21 ↔ F-25: порядок «разлепить по переводу строки» и «убрать переносы в коде» не задан
**Где:** шаг 2 п. 1 и шаг 3 п. 1 («пробелы и переносы убираются») против шага 3 п. 2 («ячейка кода содержит N частей через перевод строки → N строк»).
**Сценарий.** «1500\n1700» по п. 1 → «15001700» → не код → `unreadable`; по п. 2 → две строки `ok`. Записать явно: сначала п. 2 (разлепление), затем п. 1; для поиска столбца (шаг 2) многострочная ячейка считается кодом, если **каждая** часть — код.
### V-10 — minor — F-17: `recognition_job.statements_version` не в списке DBML v5.19
`recognition_job` имеет `service_name`, `service_version`, `result_schema_version`, `metrics jsonb` — отдельной колонки под версию общего кода нет, а «Что уходит из ядра» требует писать её и сравнивать. Добавить `statements_version varchar` в ту же миграцию (или оговорить хранение в `metrics`).
### V-11 — minor — F-9 ↔ Р-3: состав и порядок кодов по редакциям живут в двух местах
Общий пакет получает «упорядоченный перечень кодов по редакциям», ядро оставляет `statement_lines.yaml` с теми же редакциями и порядком бланка (`editions: 66n` l.9, `fsbu4` l.72) ради наименований и проверки «код не из редакции». Это та же пара копий, которую дизайн устраняет для §7. Предложение: словарь ядра — только `код → наименование` (по редакциям), порядок и состав редакций ядро берёт из своей копии пакета `arm_statements`; `sync-statements --check` сверяет, что все коды словаря ядра есть в перечне пакета.
### V-12 — minor — F-14: пробелы в операции назначения кода
Не описаны: переход `ok → detail` (ложный код, который аналитик хочет вывести из агрегата, не удаляя строку), недопустимость пары `line_code: null` + `line_status: ok`, пересчёт `parent_row_index` при переходе `missing → detail` (родитель — ближайшая строка `ok` выше), что происходит с `line_code_confidence` после назначения (100?).
### V-13 — minor — F-1: область правила знака не ограничена сомнительными ячейками; `null`-члены не исключены из большинства
Текст: «не совпадает или сравнивать не с чем — `needs_review = true` независимо от соотношения». При буквальном чтении правило применяется и к чистым ячейкам (`confidence = 100`, `is_recognized = true`): Excel-ОФР, где 2120 в формате `(#,##0)`, а 2410 — `-#,##0`, получит подсветку расходов; OCR-ОФР малой компании, где из членов `abs_terms` заполнена одна 2120 с уверенностью 100 — «сравнивать не с чем» → `needs_review`. Решение № 10 про «вперемешку скобками и минусом — это и нужно» относится к сомнительным ячейкам. Записать: знак проверяется только у ячеек, которым нужно покрытие (`confidence < 100` или `is_recognized = false`); в большинство входят члены с непустым `amount`.
## 3. Согласованность правок между собой
- **F-2 ↔ F-1 ↔ F-13 ↔ F-3/Р-8 ↔ Р-4/F-15.** Цепочка «агрегат по коду = Σ строк `ok` → соотношение посчитано, если хотя бы один член непуст → сошлось в пределах допуска или округления → покрывает только ячейки с `amount ≠ null` → для `abs_terms` только величину» внутренне непротиворечива; `detail`/`unreadable`/`missing` исключены из агрегата, подстроки 5xxxx — `ok` и проверяются по AURORA-1527 (мягко, подсветки не дают). Сбоит только хранение формы знака (V-3) и область правила (V-13).
- **F-6 ↔ F-10 ↔ F-9.** Одна «эффективная дата» (решение № 12) питает относительные подписи, редакцию перечня кодов и критерий `done`; пересчёт после PATCH даты описан. Согласовано. Не задан `period_kind` у колонки без подписи (шаг 2 п. 4) — предположительно `none`, стоит записать.
- **Статус `done` ↔ пересчёт после PATCH (F-8).** Входы функции названы; `is_modified`, акцепт, сохранение `error` — есть. Пробелы: `needs_review` строки (V-5), форма знака (V-3).
- **F-9 ↔ Р-3.** Перечень без наименований Р-3 не нарушает; но дублирует редакции/порядок словаря ядра (V-11) и не покрывает подстроки (V-2).
- **Контракт v2.0 (F-7/Р-9) ↔ DBML v5.19 (F-4/F-8/F-11/Р-10).** Соответствие полей: `line_status`, `line_code_text`, `row_index` → `row_index`, `ref` → `source_ref`, `parent_ref` → `parent_row_index`, `values[].needs_review` → `value.needs_review`, `columns[].period_kind` → `column.period_kind`, `document.status` → `document.status`, `document.warnings[]`/`checks` → `document_issue`, `document` без уникальности + порядковый номер, `crm_id`. `line_code` уже nullable в DBML v5.18, `period_date` nullable — миграция по ним не нужна. Не хватает: `financial_statement_line.needs_review` (V-5), `recognition_job.statements_version` (V-10), сырого текста значения (V-3). `service.statements_version` ложится в существующий объект `service {name, version}` контракта — аддитивно.
- **Р-11 ↔ F-1/F-2.** «Двойник ⇒ `is_recognized = false` ⇒ нужно покрытие ⇒ покрытие требует посчитанного соотношения и `amount ≠ null`» — согласовано; `}`/`]` как закрывающая скобка: не сказано, считается ли это «исправлением символа» (влияет только на то, нужно ли покрытие ячейке с уверенностью 100 — у `(7744536}` уверенность 88,9, вопрос не возникает).
- **Решение № 1 ↔ шаг 8 п. 2.** Списки `error` совпадают, кроме `BACKEND_PARSING_ERROR` и «формат не совпал» (есть только в п. 2); принцип решения № 1 нарушен двумя пунктами (V-6).
- **Р-8 ↔ §12 п. 5 / §11.1 п. 4 / строка 688.** Дизайн отменяет OQ-36/OQ-141; OQ-149 («восстановление строк по наименованию») не упомянут и остаётся открытым — противоречия нет, но при правке §12 его надо сохранить.
## 4. Что проверил и признал согласованным
- Р-2: ИС отдаёт ячейку на каждую позицию (по 8 таблицам повторно подтверждено, позиций не хватает 0).
- Статусы строк без кода по OCR: aep_3 ББ `detail` 2 / `unreadable` 6 / `missing` 0; aep_3 ОФР 1 / 1 / 1 («Выручка»); aep_6 ББ 19 / 2 / 0, ОФР 2 / 3 / 0; «БО 1 кв 2025» ББ 20 / 3 / 0, ОФР 2 / 2 / 0; «Русский бисквит 1 кв.25» ББ 40 / 3 / 0, ОФР 12 / 2 / 0 — цель Р-7 («чистый скан может стать `done`») по строкам достижима.
- Конвенция знака на данных: aep_3 ОФР c2 — 2120 `(7744536}` 88,9; 2210 `(693052)` 100; 2220 `(278598)` 100; 2330 `{38378)` 71,4; 2350 `(241654)` 75; 2410 `(108450)` 87,5 → конвенция «скобки», 2120 подтверждается, 2330 подсвечивается — как и описано в дизайне. aep_6 c4: 2220 `(124)` 100, 2350 `(Ю4)` 75 — «Ю» не двойник, остаётся грязной и подсвечивается.
- Диапазоны кодов словаря (BALANCE 1100–1799, INCOME 2100–2999, DISCLOSURE 5000–5999; `min_share 0.6`, `min_codes 3`, `min_codes_continuation 1`) — шаг 2 п. 1 (б) воспроизводит парсер.
- Шаг 6 п. 1 ↔ `statement_labels.yaml` шлюза (4 подписи: ББ, ОФР ×3) ↔ `statement_headers.yaml` парсера (ОКУД, «Пояснения» → DISCLOSURE) — слияние словарей не противоречит; форма 4 (aep_8_form4) сегодня `error` от ИС, регрессии нет.
- Шаг 9 / Р-9 ↔ `contract/common.py` (`SCHEMA_VERSION_PATTERN`, `ContractModel extra="ignore", strict=True`) — два маппера реализуемы; запрос остаётся 1.x.
- F-13 ↔ `statement_checks.yaml` (жёсткие 1100…1700, 2100/2200/2300; мягкие 2400 ×2, 2410, 2500; `abs_terms` 2120/2210/2220/2330/2350/2410) — формулировки дизайна совпадают со словарём.
- F-18 ↔ `form_confirmable` (`services/statements.py:71-81`: квартал обязателен) — `period_known` в ядре сохранён.
- F-6 ↔ `statement_period` (`statement_mapping.py:73-80`), F-9 ↔ `edition_for` (`:83-87`, граница 2024-12-31) — одна формула эффективной даты и редакции.
- F-15: `_pair_document` уже имеет порядок выбора (подтверждённый → последний), агрегат по коду его не ломает; `line_origin` (`schemas/statements.py:157-159`) по `line_code_original_value` при появлении `line_status` можно заменить, но это не противоречие.
- Уровень «xlsb без форматов», «дробные при тыс.», «подпись из словаря исключений» = `info` — совпадает с сегодняшним поведением парсера (документ `done`).
## Файлы и символы, которые смотрел
**docs** (worktree `.wt/normalization/docs`): `openspec/changes/unified-statement-normalization/design.md` (целиком), `review-2026-10-17.md` (целиком), `context/05-domain-rules.md` (строки 505–530 §7, 686–690, 990–999 §11.1 п. 4, 1105–1115 §12 п. 2, 1137–1155 §12 п. 5/7), `contracts/recognition-result.schema.json` (`$comment`, `service`, `lines[]`, `values[]`, `checks`, `string_value`), `model/data-model-v5.18.dbml` (`document`, `recognition_job`, `financial_statement_meta/column/line/value`).
**tabular-parser**: `api/src/parser/statements/columns.py` (`_CODE`, `line_code` 63–71, `find_code_column` 74–95, `_first_code_row`, `_find_header_row` 127–146, `_value_columns` 229–238, `find_layout` 294–310), `statement.py` (`_block_draft` хвост, `_continuation` 94–135, `_sheet_codes`, `_weak_blocker` 145–160, `_fill`), `detect.py` (`is_weak_continuation` 131–134, `classify_statement_sheet`, `_Candidate`), `api/rules/statement_headers.yaml` (19–34, 78–96), `statement_checks.yaml` (6–28), `api/tests/statements_manifest.yaml` (1–75).
**scan-gateway**: `api/src/normalizer/ocr/amounts.py` (`parse_amount` 44–84, `is_blank`), `rows.py` (`classify_row` 58–71, `split_merged_row` 92–118, `_data_rows`), `document.py` (`_value` 134–159), `api/rules/statement_labels.yaml`, `api/src/gateway/merge.py` (контекст склейки).
**arm-core**: `api/src/domain/statement_checks.py` (`subline_parents` 141–162, `_subline_rules` 165–184, `run_checks` 187–197), `statement_mapping.py` (`statement_period` 73–80, `edition_for` 83–87), `contract/common.py` (30–70), `services/statements.py` (`form_confirmable` 71–81), `repositories/statements.py` (`_pair_document` 330–346), `schemas/statements.py` (`line_origin` 157–159), `api/rules/statement_lines.yaml` (структура редакций, отсутствие пятизначных кодов), `services/result_mapping.py` (точки `MappingFatal`).
**fixtures** (скриптами `python3 -I` и venv парсера с `-I`): `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/*.json` + README; `statements-xlsx/unagrande-co/2024-FY/Б.Б Лист 1.xls`, `Б.Б. Лист 2.xls`, `ОФР. Лист 1.xls`, `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`, `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`, `npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (…).xlsx` (все 9 листов), `synthetic/st07_single_code_continuation.xlsx`.
Не смотрел: arm-web (не индексирован, по заданию), `services/documents.py`, `auto_confirm.py`, чек-лист (`repositories/consolidations.py`) — следствия Р-10 для них описаны по индексу DBML, а не по коду.