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