250 lines
43 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), без права правки и без доступа к первым двум отчётам; получил обновлённый
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}`.