250 lines
43 KiB
Markdown
250 lines
43 KiB
Markdown
# Независимое ревью 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}`.
|