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