213 lines
64 KiB
Markdown
213 lines
64 KiB
Markdown
# Независимое ревью 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 только про индекс).
|
||
**Дыра.**
|
||
1. В 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 не добавляет.
|
||
2. Критерий `done` включает «нет предупреждений уровня `error`». После PATCH `recompute_status` передаёт `warnings=()` (`services/statements.py:152-168`, задокументировано как ограничение) — ядро не знает, были ли у документа `error`-предупреждения (колонка без даты, сегменты не совпали) → правка одной ячейки может перевести документ в `RECOGNIZED` при сохраняющейся структурной проблеме.
|
||
3. Общий код считает `needs_review` из `confidence`/`is_recognized`/§7, но ничего не говорит про `is_modified`. Сегодня правка аналитика снимает сомнение (`values_ok`: `modified or …`, `statement_mapping.py:211-218`). Без этого исправленная ячейка строки, не входящей ни в одно соотношение (2460, 2900, 29xx, шапка, расшифровки), останется `needs_review` навсегда → `done` недостижим правкой.
|
||
4. Акцепт нарушений (`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, «Что уходит из ядра» (пересчёт тем же кодом).
|
||
**Дыра.**
|
||
1. Расхождение копий невидимо на данных: ядро на старой копии пересчитывает после PATCH другим словарём/правилом, чем сервис при загрузке → статус/подсветка меняются от одной правки ячейки без изменения сумм (например, парсер уже знает новое соотношение, ядро — нет). В результате нет поля «версия `arm_statements`», ядро не может ни проверить, ни записать, каким кодом посчитан `checks`.
|
||
2. «`make check` потребителя проверяет свежесть копии» — проверка, читающая соседний репозиторий или сеть, — это ровно урок arm-core #33 (`03-architecture.md:180-182`: «тест, читающий соседний репозиторий, ломал main»; пайплайн платформы вне репозиториев, доступа к Gitea из него нет). `sync-web.sh` работает иначе: внешний скрипт в docs, точка синхронизации по хэшу дерева (`sync-web.sh:72-87`), код 20 при прямых правках.
|
||
3. «Копия заменяется строкой зависимости без смены импортов» — копия лежит в `api/src/arm_statements/` и импортируется как `src.arm_statements…` (все три репозитория импортируют через `src.`), пакет — как `arm_statements…`; импорты сменятся.
|
||
4. Тесты пакета остаются в `arm-statements`; у потребителя копия проверяется только его ruff/mypy с другими настройками (разные `noqa`, строгость) → `make check` ломается в одном репозитории и проходит в другом; golden-файлы в fixtures тоже должны прогоняться где-то у потребителя.
|
||
5. Загрузчики словарей разные (`parser/rules.py` `Rules` с `extra=forbid` и нормализацией «е/ё», `normalizer/rules.py` `OcrRules`, `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`).
|