Compare commits
10 Commits
fbc1de5910
...
43b4b808ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43b4b808ae | ||
|
|
f55f05efa1 | ||
|
|
41c328f2ab | ||
|
|
e6400bf52f | ||
|
|
ba0bdeb744 | ||
|
|
63f134b342 | ||
|
|
3c31e0ecf5 | ||
|
|
2fa60ef924 | ||
|
|
d2572d2a6b | ||
|
|
bcb6b0b527 |
@ -1,7 +1,10 @@
|
||||
# Единый алгоритм нормализации ББ/ОФР (scan-gateway и tabular-parser) — дизайн
|
||||
|
||||
Issue: arm-ka-docs#116. Статус: черновик; текущая цепочка описана, целевая картина по шагам 1–9 — предложение, ждёт
|
||||
решений владельца Р-6, Р-7; Р-1…Р-5 решены (brainstorming 17.10.2026).
|
||||
Issue: arm-ka-docs#116. Статус: **финальная версия** — целевая картина по шагам 1–9 согласована с владельцем
|
||||
(решения Р-1…Р-19, 17.10.2026) и прошла три раунда независимого ревью (`review-2026-10-17.md`,
|
||||
`review-2026-10-17-closure.md`, `review-2026-10-17-blind.md`, `review-2026-10-17-closure-2.md`,
|
||||
`review-2026-10-17-blind-2.md`); следующий шаг — спека (дельты OpenSpec, контракт v2.0, §11/§12 в
|
||||
`05-domain-rules.md`, DBML v5.19).
|
||||
|
||||
## Цель
|
||||
|
||||
@ -134,7 +137,7 @@ json: ИНН — только цифры, организация, дата (дв
|
||||
|
||||
| № | | scan-gateway | tabular-parser |
|
||||
|---|---|---|---|
|
||||
| 1 | Что считается кодом при поиске столбца | любое целое число (`int(text)`) | 4–5 цифр в диапазоне формы (словарь) |
|
||||
| 1 | Что считается кодом при поиске столбца | столбец — по справочнику кодов (`infer_layout`), строка — любое целое (`int(text)`) | 4–5 цифр в диапазоне формы (словарь) |
|
||||
| 2 | Повторная шапка внутри таблицы | перезаписывает раскладку | не ищется (одна раскладка на блок) |
|
||||
| 3 | Дата колонки | «размером с дату» + двойники + `parse_date` | словарь периодов `period_end` |
|
||||
| 4 | Без шапки | синтез шапки, колонки без дат, предупреждение | колонки по числам, подпись пустая |
|
||||
@ -315,61 +318,75 @@ sheet:<A>,<B>`. `DISCLOSURE` и форма без колонки кодов —
|
||||
|
||||
## Целевая картина
|
||||
|
||||
Каждый шаг ниже: **вход = выход предыдущего шага**, единый выход для обоих сервисов, таблица «различие → к чему
|
||||
приводим → что меняется в scan-gateway → что меняется в tabular-parser». Решения владельца помечены датой; остальное —
|
||||
предложение (17.10.2026), решения, которые нужны от владельца, собраны в конце (Р-1…Р-7). Имена полей — рабочие,
|
||||
уточняются при написании спеки.
|
||||
Каждый шаг ниже: **вход = выход предыдущего шага**, единый выход для обоих сервисов, список «различие → к чему
|
||||
приводим → что меняется в scan-gateway → что меняется в tabular-parser». Решения владельца собраны в конце
|
||||
(Р-1…Р-19, все приняты 17.10.2026). Текст прошёл три раунда независимого ревью (`review-2026-10-17*.md` рядом с этим
|
||||
файлом); находки внесены. Имена полей — рабочие, уточняются при написании спеки.
|
||||
|
||||
Принципы, из которых выведены решения по строкам:
|
||||
|
||||
1. **Один выход на шаг.** Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист
|
||||
Excel → кандидат»). Шаги 2–8 — общий код с общими словарями (форма общего кода — Р-1).
|
||||
Excel → кандидат»). Шаги 2–8 — общий код с общими словарями (форма общего кода — Р-1). Единственное место, где
|
||||
общий код смотрит на источник, — разбор запятой и точки в сумме (шаг 4).
|
||||
2. **Ничего не выбрасывать молча.** Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со
|
||||
статусом, а не превращается в счётчик; пользователь её видит и исправляет.
|
||||
статусом, а не превращается в счётчик; пользователь её видит и исправляет. Положение в источнике (`ref`), порядок
|
||||
строк (`row_index`) и сырой текст ячейки (`text`) доезжают до базы и экрана. Подтвердить документ, не разобрав
|
||||
строки с суммами без кода, нельзя (Р-13).
|
||||
3. **Качество — структурно и с привязкой к месту:** уверенность ячейки (от источника), `is_recognized` (разбор числа),
|
||||
статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Уверенность, `is_recognized` и §7
|
||||
сервис сводит в один признак ячейки `needs_review` (шаг 8) и из него, статусов строк и предупреждений выводит
|
||||
статус документа; ядро при загрузке ничего не пересчитывает, после правок аналитика считает тем же общим кодом.
|
||||
4. **Один словарь — одно место:** проверки §7, периоды, заголовки форм, диапазоны кодов — в общем коде сервисов;
|
||||
соответствие «код → наименование» (словарь строк с редакциями) — только в ядре, сервисы его не знают и отдают
|
||||
распознанный текст (решение владельца 17.10.2026, Р-3).
|
||||
сервис сводит в один признак `needs_review` у значения, строки и поля шапки (шаг 8) и из них выводит статус
|
||||
документа. Ядро при загрузке не пересчитывает ни проверки, ни статус, ни `needs_review`: хранит их как пришли и
|
||||
добавляет только предупреждения `info` по своему словарю имён. После правок аналитика ядро считает тем же общим
|
||||
кодом, добавляя своё знание: правка или подтверждение снимает `needs_review`, акцепт снимает жёсткие нарушения,
|
||||
квартал периода, сверка ИНН и наименований с справочником — условия `RECOGNIZED`.
|
||||
4. **Один словарь — одно место:** проверки §7 (полный и упрощённый наборы), периоды, заголовки форм, упорядоченные
|
||||
перечни кодов форм по редакциям и вариантам (без наименований), диапазоны кодов — в общем коде сервисов;
|
||||
соответствие «код → наименование» — только в ядре, сервисы его не знают и отдают распознанный текст (Р-3). Порядок и
|
||||
состав редакций ядро берёт из своей копии общего кода, словарь ядра хранит только имена; `sync-statements --check`
|
||||
сверяет, что все коды словаря ядра есть в перечне.
|
||||
5. **Очистка OCR (§11.1) — не отдельный шаг, а первая стадия типовых разборов текста** в общем коде, там, где уже
|
||||
известен смысл ячейки: ИНН и дата шапки (шаг 1), подписи колонок (шаг 2), код строки и разлепление склеенных
|
||||
строк (шаг 3), сумма (шаг 4). Разбор вызывается для любой ячейки без `value`; у Excel он срабатывает только на
|
||||
текстовых ячейках и почти всегда ничего не меняет. Факт исправления фиксируется там же: `is_recognized = false`
|
||||
у значения, `unreadable` у кода, предупреждение у даты. Из `normalizer/ocr/` шлюза `text`, `dates`, `amounts`
|
||||
становятся общими разборами, `layout`, `rows`, `header`, `document` — шагами 2–6 общего кода.
|
||||
известен смысл ячейки: дата шапки (шаг 1), подписи колонок (шаг 2), код строки и разлепление склеенных строк
|
||||
(шаг 3), сумма (шаг 4). Буквы-двойники («О» → 0, «З» → 3, «б» → 6, «S» → 5, «I», «l», «|» → 1) заменяются в датах,
|
||||
подписях, кодах и суммах (Р-11, отменяет §11.1 п. 1 и решение № 22 среза 3a «только даты»): сумма с заменой
|
||||
получает `is_recognized = false`, код с заменой считается неуверенным (эффективная уверенность ниже 100), а дальше
|
||||
их судьбу решают контрольные соотношения §7 и структурная проверка кода. В суммах крайние «|» (след границы
|
||||
таблицы: «| : 122477») убираются, а не заменяются. ИНН двойники не трогают: только цифры, как сейчас — иначе
|
||||
«3|1 21 2|» из aep_3 даёт лишние единицы и `MISMATCH` с ЮЛ. Разбор вызывается для любой ячейки без `value`; у Excel
|
||||
он срабатывает только на текстовых ячейках и почти всегда ничего не меняет. Из `normalizer/ocr/` шлюза `text`,
|
||||
`dates`, `amounts` становятся общими разборами, `layout`, `rows`, `header`, `document` — шагами 2–6 общего кода.
|
||||
|
||||
### Шаг 1 — Подготовка данных
|
||||
|
||||
**Решения владельца (17.10.2026):** 1) кто размечает — различие остаётся; 2) на выходе одинаковый контракт;
|
||||
3) шапка своя у каждой таблицы; 4) тип формы — единый подход; 5) уверенность есть и у парсера, всегда 100;
|
||||
6) парсер склеивает продолжения в шаге 1; 7) ячейка — один формат.
|
||||
**Решения владельца:** 1) кто размечает — различие остаётся; 2) на выходе одинаковый контракт; 3) шапка своя у
|
||||
каждой таблицы; 4) тип формы — единый подход; 5) уверенность есть и у парсера, всегда 100; 6) парсер склеивает
|
||||
продолжения в шаге 1; 7) ячейка — один формат.
|
||||
|
||||
**Вход:** байты файла (шаг 0).
|
||||
|
||||
**Единый выход:** список кандидатов в форму на файл, `FormCandidate`:
|
||||
|
||||
- `source` — `ocr` | `excel`;
|
||||
- `locator` — откуда взят (имя json и номер таблицы / имена листов);
|
||||
- `locator` — откуда взят: `file:<имя json>;table:<i>` (как в §11 и golden-эталонах) / `sheet:<A>[,<B>]`;
|
||||
- `type_hint` — `BALANCE` | `INCOME` | `DISCLOSURE` | `null`, по единому правилу (п. 4);
|
||||
- `evidence` — ОКУД и заголовок как ячейки (для сверки с кодами в шаге 6);
|
||||
- `header` — поля `header` контракта (`organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`,
|
||||
`okfs`, `address`), каждое — ячейка или `null`;
|
||||
- `rows` — плотная сетка строк × ячеек; все страницы и листы формы уже в одной сетке; пустая позиция — `null`;
|
||||
- `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто): граница листа — всегда
|
||||
граница сегмента раскладки (шаг 2);
|
||||
- `warnings` — предупреждения шага (формат — шаг 8).
|
||||
|
||||
Ячейка `Cell` одна для шапки и таблицы: `text` (как показывает источник), `value` (число или дата, если источник их
|
||||
знает; у OCR — `null`), `confidence` 0–100 (у Excel — 100), `ref` (позиция в источнике для сообщений: `row_index` /
|
||||
`column_index` у OCR, адрес A1 у Excel).
|
||||
знает; у OCR — `null`), `confidence` 0–100 (у Excel — 100), `ref` — позиция в источнике: OCR — `row:<row_index>`,
|
||||
Excel — `<лист>!<A1>`. Уникальность в документе требуется от `ref` строки (шаг 3), не ячейки.
|
||||
|
||||
- **1. Кто размечает** — остаётся, фиксируется полем `source`
|
||||
- scan-gateway: без изменений
|
||||
- tabular-parser: без изменений
|
||||
- **2. Единица разметки** — файл → список кандидатов; шаги 2–8 работают только с кандидатом (Р-1 — форма общего кода)
|
||||
- **2. Единица разметки** — файл → список кандидатов; у OCR каждая таблица ИС — кандидат, у Excel — каждый блок формы на листе; шаги 2–8 работают только с кандидатом (Р-1 — форма общего кода)
|
||||
- scan-gateway: адаптер «таблица json → кандидат»
|
||||
- tabular-parser: адаптер «блок формы на листе → кандидат»
|
||||
- **3. Шапка** — у каждого кандидата своя; поля = поля `header` контракта, каждое — `Cell`; разбор текста поля (ИНН → цифры, дата → `date`, единицы → словарь) — общий код по `text`
|
||||
- **3. Шапка** — у каждого кандидата своя; поля = поля `header` контракта, каждое — `Cell`; разбор текста поля — общий код по `text`: ИНН → только цифры, без двойников; дата → словарь периодов (шаг 2 п. 3), который включает все шаблоны сегодняшнего `dates.py` шлюза; единицы → словарь. Уверенность и сырой текст каждого поля доезжают до контракта (`*_confidence`, `*_text`)
|
||||
- scan-gateway: шапку json копирует в каждую свою таблицу
|
||||
- tabular-parser: добавляет сырой текст и уверенность 100
|
||||
- **4. Тип формы** — одно правило: ОКУД → заголовок по одному словарю → `null`; окончательно — по кодам строк в шаге 6, у обоих
|
||||
@ -378,105 +395,136 @@ sheet:<A>,<B>`. `DISCLOSURE` и форма без колонки кодов —
|
||||
- **5. Уверенность** — поле `Cell.confidence`; Excel всегда 100
|
||||
- scan-gateway: без изменений
|
||||
- tabular-parser: добавляет поле
|
||||
- **6. Многостраничная форма** — одна форма = одна сетка на выходе шага 1; строки продолжения дописываются в конец сетки по маркеру «Форма … с.2» или по позиции листа; проверка «число колонок сумм совпало» из склейки уходит (повторная шапка внутри сетки — правило шага 2, общее для обоих: у OCR она и так внутри таблицы); тип из маркера сверяется с `type_hint`, расхождение — отдельный кандидат + предупреждение
|
||||
- **6. Многостраничная форма** — одна форма = одна сетка на выходе шага 1; строки продолжения дописываются в конец сетки по маркеру «Форма … с.2» или по позиции листа, граница листа запоминается в `segment_breaks`; тип из маркера сверяется с `type_hint`, расхождение — отдельный кандидат + предупреждение `error`. Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы (как `_weak_blocker` сейчас) и, при трёх и более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат с предупреждением `error`. Раскладка у каждого сегмента своя (шаг 2 п. 2), поэтому разная ширина колонок или объединённые ячейки на листе-продолжении без шапки не сдвигают значения
|
||||
- scan-gateway: без изменений
|
||||
- tabular-parser: склейка переезжает до раскладки; `join_blocker` остаётся только по типу
|
||||
- tabular-parser: склейка переезжает до раскладки; `join_blocker` сохраняет проверки по типу, диапазону кодов и числу колонок
|
||||
- **7. Ячейка** — один формат `Cell`; Excel: `text` — как показывает числовой формат, `value` — число по формату (tp#42; формат не разобран — сырое значение + предупреждение); OCR: `text` сырой, `value` `null`, `confidence` от ИС
|
||||
- scan-gateway: обёртка над `value` + `confidence` ИС
|
||||
- tabular-parser: отображаемый текст и значение считаются здесь, а не в шаге 4
|
||||
|
||||
### Шаг 2 — Раскладка таблицы
|
||||
|
||||
**Вход:** `FormCandidate` (сетка, `type_hint`).
|
||||
**Вход:** `FormCandidate` (сетка, `segment_breaks`, `type_hint`).
|
||||
|
||||
**Единый выход:** `TableLayout` — столбец кода, столбец наименования, колонки значений `[{col, label, period_end}]`,
|
||||
роль каждой строки сетки (`header` | `numbering` | `data`), сегменты (участки сетки между повторными шапками, у
|
||||
каждого своя раскладка колонок).
|
||||
**Единый выход:** `TableLayout` — сегменты сетки (участки между повторными шапками и границами листов), у каждого:
|
||||
столбец кода, столбец наименования, колонки значений `[{col, label, label_confidence, period_end, period_kind}]`,
|
||||
сопоставленные с первым сегментом по порядку; роль каждой строки сетки (`title` | `header` | `numbering` | `data`).
|
||||
|
||||
- **1. Что считается кодом при поиске столбца** — одно правило: 4–5 цифр (допускается `.0`), первые четыре — в диапазонах словаря кодов всех форм (`type_hint` неизвестен — объединение диапазонов); столбец с наибольшим числом кодов, кодов ≥ 3, доля среди непустых ≥ порога; нет — «колонка кодов не найдена»
|
||||
- scan-gateway: `int(text)` → правило словаря (закрывает AURORA-1517 вместе с п. 5)
|
||||
- tabular-parser: диапазон формы → объединение; `relaxed` для одиноких продолжений не нужен (они склеены в шаге 1)
|
||||
- **2. Повторная шапка внутри таблицы** — одно правило: строка внутри сетки с признаком шапки («Код» / та же сигнатура) открывает новый сегмент; у сегмента своя раскладка (позиции колонок могут сдвинуться); колонки значений сегментов сопоставляются по порядку; число не совпало — предупреждение, значения по позиции
|
||||
**Диапазоны кодов** (словарь, границы уточняются в спеке): ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях
|
||||
капитала 3100–3699, отчёт о движении денежных средств 4100–4499, отчёт о целевом использовании 6100–6499,
|
||||
пояснения 5000–5999. «Кодовые диапазоны» для поиска столбца — объединение этих отрезков (годы 2000–2099 и
|
||||
четырёхзначные суммы на 1–6 вне отрезков кодами не считаются). «Диапазон столбца» — отрезок, в который попадает
|
||||
большинство кодов найденного столбца; им проверяются разлепление строк (шаг 3), слабые продолжения и наследование
|
||||
столбца в сегментах.
|
||||
|
||||
- **1. Столбец кодов** — одно правило: код — текст без пробелов, переносов и краевой пунктуации («1220 ,» → 1220), после замены двойников (порядок: убрать пробелы и переносы → двойники → проверка; «|» → 1 только если результат попадает в кодовый диапазон: «| 140» → 1140), 4–5 цифр (допускается `.0`), первые четыре в кодовых диапазонах. Многострочная ячейка — код, если каждая её часть код. (а) Столбец, над данными которого стоит подпись «Код» / «Коды» / «Код строки» (словарь подписей) **и под которой доля кодов среди непустых ячеек ≥ 0,6 или кодов ≥ 3**; несколько кандидатов — столбец с наибольшим числом кодов; берётся последняя такая подпись над первой строкой с кодом. Защита обязательна: в выгрузках 1С под подписью «Коды» титульного блока стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ (12267 у АО, 12300 у ООО — по первым четырём цифрам это «коды баланса»), ОКЕИ: один код из девяти, доля 0,11. Документ из двух строк остаётся документом: два из двух, доля 1,0. (б) Подписи нет → столбец с наибольшим числом ячеек-кодов, кодов ≥ 3, доля ≥ 0,6 (так парсер сейчас); нет — «колонка кодов не найдена». Статусы строк с нечитаемым или пустым кодом — новое поведение для обоих: шлюз сегодня такие строки пропускает со счётчиком
|
||||
- scan-gateway: столбец по подписи получает защиту по доле; запасной подсчёт — по кодовым диапазонам, как сейчас; классификация строк `int(text)` → статусы
|
||||
- tabular-parser: добавляет проход по подписи до подсчёта; диапазон формы → кодовые диапазоны
|
||||
- **2. Повторная шапка и границы листов** — одно правило: новый сегмент открывает строка с признаком шапки («Код» / та же сигнатура) **или** граница листа из `segment_breaks`; у сегмента своя раскладка: по его шапке, а без шапки — столбец кодов по правилу п. 1, и если сегмент не первый и кодов в нём меньше трёх, ослабленный поиск: столбец с наибольшим числом кодов из диапазона столбца первого сегмента, минимум один (так `relaxed` + `_weak_blocker` парсера сейчас: лист «_2» НПЗ с единственной строкой 2400, у которого столбец кодов в другой колонке, чем на листе 1); колонки значений без шапки — по числам в строках с кодами, сопоставление с первым сегментом по порядку; число не совпало — предупреждение `error`, значения по позиции; столбец кодов не найден ни так, ни так — предупреждение `error` сегмента, строки сегмента не теряются (роль `data`, статусы по шагу 3)
|
||||
- scan-gateway: как сейчас, через общее правило
|
||||
- tabular-parser: добавляет: бывший «лист-продолжение со своей раскладкой» становится сегментом
|
||||
- **3. Дата колонки** — один словарь периодов (`statement_periods.yaml`) → `period_end`; перед разбором — очистка двойников OCR (общий шаг, у Excel не срабатывает)
|
||||
- scan-gateway: `parse_date` «размером с дату» → словарь
|
||||
- tabular-parser: добавляет очистку двойников (no-op)
|
||||
- **4. Без шапки** — одно правило: строка заголовка не найдена → колонки значений = столбцы правее кода, где в строках с кодами стоят числа; `label` пустая, `period_end` `null`; одно предупреждение «строка заголовка не найдена»
|
||||
- scan-gateway: синтез шапки уходит (он эквивалентен)
|
||||
- tabular-parser: добавляет предупреждение
|
||||
- tabular-parser: бывший «лист-продолжение со своей раскладкой» становится сегментом; бывшая проверка числа колонок в `join_blocker` — предупреждение сегмента
|
||||
- **3. Дата колонки** — один словарь периодов (`statement_periods.yaml`), **объединяющий** сегодняшний yaml парсера и шаблоны `dates.py` шлюза: «На 31 марта 2025 г.», «За январь – март 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «за I квартал 2025 г.», «1 полугодие 2026», «с 01.01.2025 по 31.03.2025», «2024», двузначный год, хвост «0:00:00», клетки «2|0|2|4|»; строки `test_dates.py::test_parses_real_forms` шлюза — обязательные тесты словаря. Результат — `period_end` и `period_kind`: абсолютные подписи → `absolute`; относительные («На отчётную дату», «На 31 декабря предыдущего года», «На 31 декабря года, предшествующего предыдущему», «За отчётный период», «За аналогичный период прошлого года») → `relative:<вид>`, дата считается от **эффективной даты документа**: дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок; нет ни той ни другой → `period_end = null`, `period_kind` остаётся относительным, ядро досчитает после правки даты отчёта; подписи нет → `period_kind = none`. Подпись OCR с уверенностью ниже 100, разобранная в дату, подтверждается, если дата равна одной из ожидаемых (эффективная дата, минус год, минус два года) — иначе `error` «дата колонки неуверенна». Перед разбором — очистка двойников OCR. Относительные подписи — Р-6: это бланки по приказу 66н без впечатанных дат, у шлюза сейчас «колонка 0 без даты: На отчетную дату»
|
||||
- scan-gateway: `parse_date` → словарь, его шаблоны переезжают в словарь
|
||||
- tabular-parser: добавляет очистку двойников (no-op), относительные подписи и шаблоны шлюза
|
||||
- **4. Строка заголовка и её отсутствие** — одно правило (обе ветки парсера сохраняются): строка заголовка — последняя над первой строкой с кодом, где в столбце кодов стоит подпись «Код»; иначе последняя над первой строкой с кодом с двумя и более подписями-периодами правее кодов (или одной, не голым годом); двухстрочный заголовок склеивается. Роль `title` — только строки выше найденной строки заголовка (титульный блок «Дата 30 | 9 | 2025» Межениновской); в сегменте без заголовка роли `title` нет, строки выше первого кода с содержимым идут в данные (шаг 3). Заголовка нет ни у одного сегмента документа → колонки значений первого сегмента = столбцы правее кода, где в строках с кодами стоят числа; `label` пустая, `period_end` `null`, `period_kind = none`; одно предупреждение `error` «строка заголовка не найдена». Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки (п. 2) без предупреждения: листы 2–5 ББ НПЗ без шапки остаются `done`
|
||||
- scan-gateway: синтез шапки уходит (он эквивалентен); добавляет ветку «заголовок по периодам»
|
||||
- tabular-parser: добавляет предупреждение для документа без единого заголовка
|
||||
- **5. Строка нумерации «1 2 3 4 5»** — одно правило `is_numbering_row` → роль `numbering`, в данные не попадает
|
||||
- scan-gateway: добавляет (AURORA-1517)
|
||||
- tabular-parser: как сейчас
|
||||
- **6. Подпись без периода** — одно правило: подпись — период по словарю → колонка с датой; подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля») → пропуск с предупреждением info; иначе, если в столбце есть числа в строках с кодами — колонка без даты + предупреждение (Р-6); иначе пропуск
|
||||
- scan-gateway: добавляет словарь исключений
|
||||
- tabular-parser: перестаёт молча пропускать неизвестные числовые колонки
|
||||
- **6. Подпись без периода** — Р-6: подпись — период по словарю (абсолютный или относительный, п. 3) → колонка с датой; подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск с предупреждением `info`; иначе, если в столбце есть числа в строках с кодами (OCR-мусор вроде «| | г» с уверенностью 17 в aep_3, обрезанная или самодельная подпись) — колонка остаётся с подписью как есть, `period_end = null`, `period_kind = none`, предупреждение `error` с привязкой к колонке, документ `needs_check`; аналитик ставит дату колонке через PATCH или подтверждает после сверки с источником; иначе пропуск. Ядру колонка без даты не мешает для периода формы (эффективная дата, п. 3), но межформенная проверка и гудвилл «на отчётную дату» по такой колонке не считаются, пока дата не проставлена. Пропуск числовой колонки с нераспознанной подписью отвергнут: для OCR это тихая потеря периода с возможным автоподтверждением
|
||||
- scan-gateway: добавляет словарь исключений и относительные периоды
|
||||
- tabular-parser: перестаёт молча пропускать неизвестные числовые колонки; добавляет относительные периоды
|
||||
- **7. Столбец наименования** — одно правило: подпись «Наименование (показателя)» в строке заголовка → этот столбец; иначе столбец левее кодов с наибольшим числом текстов, кроме столбца «Пояснения»; наименование строки — ячейка этого столбца, пусто → ближайшая текстовая ячейка левее кода, кроме «Пояснения»
|
||||
- scan-gateway: «наименование», иначе слева от кода → общее правило
|
||||
- tabular-parser: `_name_column` / `_line_name` → общее правило
|
||||
|
||||
### Шаг 3 — Отбор строк данных
|
||||
|
||||
**Вход:** `FormCandidate` + `TableLayout`.
|
||||
|
||||
**Единый выход:** `RawLine[]` — по строке на каждую строку сетки с ролью `data`, у которой есть код или хотя бы одно
|
||||
число: `ref`, ячейка кода, `code_status` (`ok` | `unreadable` — код есть, но не разобран | `missing` — кода нет, суммы
|
||||
есть), ячейка наименования, ячейки значений по колонкам раскладки. Строка без кода и без чисел (заголовок раздела
|
||||
«АКТИВ», «в том числе:») — не строка. Счётчиков отброшенных нет — есть статусы. Повтор кода — не статус: по правилам
|
||||
отчётности один код может стоять у нескольких строк с разными наименованиями, все они `ok`.
|
||||
**Единый выход:** `RawLine[]` — по строке на каждую строку сетки с ролью `data`, у которой есть содержимое: код,
|
||||
число или непустой текст в колонке значений с уверенностью ниже 100 (признак потерянной суммы: aep_3 row 8 «[190 :»
|
||||
с «Ё», «" :»). У строки: `row_index` (порядковый номер в документе), `ref` (уникален в документе; у разлепленных строк
|
||||
`row:<i>#<k>`), ячейка кода, `code_status`, ячейка наименования, ячейки значений по колонкам раскладки сегмента,
|
||||
`parent_ref` (для `detail`). Статусы (`ok` | `unreadable` | `missing` | `detail`):
|
||||
|
||||
- **1. Код-мусор** — статус `unreadable`, строка сохраняется с сырым текстом кода (AURORA-1462: «ИЧ,250» видна, аналитик назначает 1250)
|
||||
- `ok` — код разобран;
|
||||
- `unreadable` — ячейка кода содержит хотя бы одну цифру, но кодом не разобралась («ИЧ,250», «изо :», «[190 :»);
|
||||
ячейка кода без единой цифры («}», «:») считается пустой;
|
||||
- `detail` — Р-8: строка без кода с содержимым, стоящая под строкой с кодом (в том же сегменте или в предыдущих
|
||||
сегментах документа — родитель продолжается через разрыв страницы), — расшифровка («в том числе: …», «из них …»,
|
||||
«Оборудование к установке», «Товар (41.01)»); родитель — ближайшая строка с кодом выше. Сервисы её отдают, ядро
|
||||
хранит, в проверки §7, агрегаты и критерий `done` она не входит, экран по умолчанию не показывает. Отменяет OQ-36 /
|
||||
OQ-141 («строки без кода не отдаются»); OQ-149 («восстановление строк по наименованию») остаётся открытым;
|
||||
- `missing` — строка без кода с содержимым, над которой во всём документе нет строки с кодом (кода не было или он
|
||||
потерян в самом начале таблицы: «Выручка» aep_3, где 2110 уехал в ячейку значения); строки роли `title` сюда не
|
||||
попадают.
|
||||
|
||||
Строка без кода и без содержимого (заголовок раздела «АКТИВ», «в том числе:» без сумм) — не строка. Счётчиков
|
||||
отброшенных нет — есть статусы. Повтор кода — не статус: по правилам отчётности один код может стоять у нескольких
|
||||
строк с разными наименованиями, все они `ok`. Строка формы с пустой ячейкой кода под другой строкой с кодом попадёт в
|
||||
`detail` и будет скрыта; её значения не войдут в сумму раздела, нарушение §7 подсветит родителя (цель соотношения
|
||||
отсутствует при непустых членах — нарушение, шаг 7), аналитик раскроет расшифровки и назначит код (Р-13) — осознанная
|
||||
цена Р-8.
|
||||
|
||||
- **1. Код-мусор** — статус `unreadable`, строка сохраняется с сырым текстом кода (AURORA-1462: «ИЧ,250» видна, аналитик назначает 1250). Разбор кода — по правилу шага 2 п. 1
|
||||
- scan-gateway: счётчик → статус
|
||||
- tabular-parser: отличает «нет кода» от «не разобран»
|
||||
- **2. Склейка двух строк в одну** — одно правило: ячейка кода содержит N кодов через перевод строки и ячейки значений — N частей → N строк (OQ-156); у Excel не срабатывает
|
||||
- scan-gateway: как сейчас, через общее правило
|
||||
- **2. Склейка двух строк в одну** — одно правило, выполняется **до** разбора кода: ячейка кода содержит N частей через перевод строки и ячейки значений — N частей → N строк (OQ-156) с `ref` `row:<i>#<k>`; разлепление делается, если хотя бы одна часть кода разобралась как код диапазона столбца («44 30\nI |» aep_6 не множится: 4430 не в диапазоне ББ), нечитаемая часть становится строкой `unreadable`; число частей не совпало или ни одна часть не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, `is_recognized = false`. Значения разлепленных строк получают `is_recognized = false` и нуждаются в покрытии проверками §7 (Р-17: сошлись — подсветки нет, иначе `needs_review`); у Excel не срабатывает
|
||||
- scan-gateway: как сейчас, через общее правило; разлепление при нечитаемой части — новое; статус документа после разлепления — по общему правилу, а не безусловный `needs_check`
|
||||
- tabular-parser: правило общее, no-op
|
||||
- **3. Повтор кода** — **решение владельца (17.10.2026):** повтор кода — законный случай (один код, разные наименования), не ошибка и не предупреждение. Все строки сохраняются как есть со статусом `ok`; текстовое предупреждение и `checks.duplicate_line_codes` уходят. Для проверок §7, подтверждения и всего, что идёт после, значения одного кода идут суммой (см. «Что уходит из ядра»; договорённость ДБД в AURORA-1431; закрывает AURORA-1406). Неверно прочитанный код, случайно совпавший с существующим, ловит §7 через сумму
|
||||
- **3. Повтор кода** — Р-4: повтор кода — законный случай (один код, разные наименования), не ошибка и не предупреждение. Все строки сохраняются как есть со статусом `ok`; текстовое предупреждение и `checks.duplicate_line_codes` уходят. Для проверок §7, подтверждения и всего, что идёт после, значения одного кода идут суммой (см. «Что уходит из ядра»; договорённость ДБД в AURORA-1431; закрывает AURORA-1406). Неверно прочитанный код, случайно совпавший с существующим, ловит структурная проверка кода: повтор, у которого хотя бы одна копия с уверенностью ниже 100, — `needs_review` у этой строки (Р-16)
|
||||
- scan-gateway: «оставлена первая» → все строки
|
||||
- tabular-parser: `duplicates` уходит
|
||||
- **4. Отброшенная строка** — строки не отбрасываются: `unreadable` / `missing` идут дальше со статусом (AURORA-1634, 1462); ядро не достраивает вместо них пустую шаблонную строку молча
|
||||
- **4. Отброшенная строка** — строки не отбрасываются: `unreadable` / `missing` / `detail` идут дальше со статусом, `row_index` и `ref` (AURORA-1634, 1462); ядро не достраивает вместо них пустую шаблонную строку молча; подтвердить документ, пока у `unreadable` / `missing` есть суммы, нельзя (Р-13). Отмена OQ-36 / OQ-141 — Р-8
|
||||
- scan-gateway: счётчики → статусы
|
||||
- tabular-parser: счётчики → статусы
|
||||
- tabular-parser: счётчики → статусы; строки без кода с суммами начинают отдаваться
|
||||
|
||||
### Шаг 4 — Значения ячеек
|
||||
|
||||
**Вход:** ячейки значений `RawLine` по колонкам раскладки (`Cell` или `null`).
|
||||
|
||||
**Единый выход:** `StatementValue {column_index, amount, string_value, confidence, is_recognized}` — как в контракте,
|
||||
с одним смыслом полей у обоих.
|
||||
**Единый выход:** `StatementValue {column_index, amount, string_value, text, confidence, is_recognized}`: `text` —
|
||||
сырой текст источника всегда, как `Cell.text`; `string_value` — как сейчас, только когда сумма не разобрана
|
||||
(`amount = null`, непустой текст); `needs_review` досчитывается в шаге 8.
|
||||
|
||||
- **1. Предобработка текста** — у ячейки после шага 1 либо есть `value`, либо только `text`. `value` — число → `amount = value` (2 знака), `is_recognized = true`. `value` нет или не число (дата, логическое) → один `parse_amount(text)` для обоих: двойники, разделители тысяч, скобки → минус, прочерк → `null`; Excel-ячейки «число текстом» идут тем же путём
|
||||
- scan-gateway: `parse_amount` переезжает в общий код
|
||||
- tabular-parser: `to_money` заменяется общим `parse_amount`
|
||||
- **2. Смысл `is_recognized`** — один: текст разобран как число без выброшенных и исправленных символов; `confidence` — отдельно, от источника; оба признака сервис сводит в `needs_review` (шаг 8, «Подтверждение проверкой»), экран подсвечивает по нему
|
||||
- **1. Предобработка текста** — у ячейки после шага 1 либо есть `value`, либо только `text`. `value` — число → `amount = value`, квантование до 2 знаков half-up, не больше 16 целых цифр (`numeric(18,2)`), иначе `amount = null`, `string_value` = текст, `is_recognized = false` и предупреждение; `value` — дата или логическое → `amount = null`, `string_value` = текст, `is_recognized = false`; `value` нет → один `parse_amount(text, source)` для обоих: двойники (Р-11), пробелы и тонкие пробелы, минус «−» (U+2212) как знак, скобки → минус, причём `}` и `]` принимаются как закрывающая скобка (OQ-147), прочерк → `null`; разделители: у `ocr` запятая — тысячи при группе из трёх цифр (AURORA-1405, sg#35) и точка — тысячи при группах из трёх цифр («837.994», sg#37), текст с тремя и более знаками после разделителя — `amount = null`, `string_value` = текст (решение № 30 среза 3a сохраняется); у `excel` запятая и точка — десятичный разделитель («4,200» = 4,20, «1.500» = 1,5, как `to_money` сейчас); «лучшая догадка» из цифр ограничена 16 цифрами, иначе `null`; многострочное значение в строке с однострочным кодом → `amount = null`, `string_value` = текст, `is_recognized = false`; Excel-ячейки «число текстом» идут тем же путём. Разбор ОСВ (`to_money` в `classify.py`) общим `parse_amount` не заменяется — он остаётся за пределами этой работы
|
||||
- scan-gateway: `parse_amount` переезжает в общий код и получает квантование, предел длины, `}` / `]`, «−», тонкие пробелы
|
||||
- tabular-parser: `to_money` для форм заменяется общим `parse_amount` с `source = excel`
|
||||
- **2. Смысл `is_recognized`** — один: текст разобран как число без выброшенных и исправленных символов (замена двойника — исправление; `}` / `]` вместо скобки — тоже); `confidence` — отдельно, от источника; оба признака сервис сводит в `needs_review` (шаг 8, «Подтверждение проверкой»), экран подсвечивает по нему
|
||||
- scan-gateway: отвязывает от `confidence`
|
||||
- tabular-parser: как сейчас
|
||||
- **3. «42831 1»** — в `parse_amount` пробел допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — лучшая догадка (цифры подряд), `is_recognized = false`, `string_value` — сырой текст (AURORA-1511, 1519)
|
||||
- **3. «42831 1»** — в `parse_amount` пробел допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — лучшая догадка (цифры подряд), `is_recognized = false`, сырой текст — в `text` (AURORA-1511, 1519)
|
||||
- scan-gateway: меняется через общий код
|
||||
- tabular-parser: то же правило, у Excel почти не срабатывает
|
||||
- **4. Единицы** — одно правило: единицы «тыс. руб.» и дробных ≥ 10 % → предупреждение документа
|
||||
- **4. Единицы** — одно правило: единицы «тыс. руб.» и дробных ≥ 10 % → предупреждение документа `info` (как у парсера сейчас, документ остаётся `done`); то же сочетание у книги xlsb без числовых форматов — `error`: вероятен масштаб ×1000 (рубли с копейками, которые формат переводил в тысячи), а §7 сходится при любом масштабе
|
||||
- scan-gateway: добавляет
|
||||
- tabular-parser: как сейчас
|
||||
- **5. Пустая ячейка** — **решение владельца (17.10.2026, Р-2):** одно правило: позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` ячейки (нет ячейки — 100), собственной подсветки нет. По `fixtures/ocr` ИС отдаёт ячейку на каждую позицию каждой строки: пустые — пустой текст с уверенностью 100, непрочитанные — с уверенностью 0, и такие идут в `needs_review` по общему правилу шага 8; позиция отсутствует только при несовпадении колонок сегмента, что покрыто предупреждением `error` шага 2 п. 2
|
||||
- tabular-parser: как сейчас; xlsb-сочетание — новое
|
||||
- **5. Пустая ячейка** — Р-2: позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` ячейки (нет ячейки — 100), собственной подсветки нет. По `fixtures/ocr` ИС отдаёт ячейку на каждую позицию каждой строки: пустые — пустой текст с уверенностью 100; непрочитанные — пустой текст с уверенностью 0, и такие нуждаются в покрытии по общему правилу шага 8 (снимается покрытием или подтверждением ячейки аналитиком); позиция отсутствует только при несовпадении колонок сегмента, что покрыто предупреждением `error` шага 2 п. 2
|
||||
- scan-gateway: «ячейки нет → 0, не распознано» уходит
|
||||
- tabular-parser: как сейчас
|
||||
|
||||
### Шаг 5 — Код, наименование, редакция
|
||||
|
||||
**Вход:** `RawLine` (статус, ячейки кода и наименования) + `StatementValue[]`.
|
||||
**Вход:** `RawLine` (статус, `row_index`, `ref`, ячейки кода и наименования) + `StatementValue[]`.
|
||||
|
||||
**Единый выход:** `StatementLine {line_status, line_code, line_code_text, line_code_confidence, line_name,
|
||||
line_name_confidence, values}`: `line_code` — разобранный код (4–5 цифр) или `null` при `unreadable` / `missing`;
|
||||
`line_code_text` — сырой текст ячейки кода; `line_status` = `code_status` шага 3 (`ok` | `unreadable` | `missing`).
|
||||
**Единый выход:** `StatementLine {row_index, ref, line_status, line_code, line_code_text, line_code_confidence,
|
||||
line_name, line_name_confidence, parent_ref, values}`: `line_code` — разобранный код (4–5 цифр) или `null` при
|
||||
`unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от источника,
|
||||
а у кода с заменой двойника — ниже 100 (неуверенный); `line_status` = `code_status` шага 3; `parent_ref` — у `detail`.
|
||||
Несколько строк с одним кодом — обычные строки `ok`, порядок как в файле.
|
||||
|
||||
- **1. Источник наименования** — **решение владельца (17.10.2026):** сервисы отдают распознанный текст наименования как есть, с уверенностью источника; соответствие «код → наименование» — только в ядре и только когда нужно (показ справочного имени, достройка шаблонных строк). Экран показывает справочное имя, файловое — рядом (Р-3; AURORA-1406, 1522)
|
||||
- **1. Источник наименования** — Р-3: сервисы отдают распознанный текст наименования как есть, с уверенностью источника; соответствие «код → наименование» — только в ядре и только когда нужно (показ справочного имени, достройка шаблонных строк, сверка для неуверенных кодов — Р-16). Экран показывает справочное имя, файловое — рядом (AURORA-1406, 1522)
|
||||
- scan-gateway: имя из `statement_lines.yaml` → имя из OCR
|
||||
- tabular-parser: как сейчас
|
||||
- **2. Редакция (66н / ФСБУ 4)** — в сервисах не определяется; редакция — свойство словаря строк ядра (по дате отчёта), ядро использует её при достройке шаблонных строк, показе справочного имени и проверке «код не из редакции формы»
|
||||
- scan-gateway: `_choose_edition` уходит
|
||||
- tabular-parser: без изменений
|
||||
- **2. Редакция (66н / ФСБУ 4)** — в сервисах редакция нужна только для упорядоченного перечня кодов (подтверждение кода структурой, шаг 8) и выбирается по эффективной дате документа (шаг 2 п. 3), без даты — проверяются обе редакции. Наименования и достройка шаблонных строк — ядро по его словарю имён; проверка «код не из редакции формы» — ядро по своей копии перечня общего кода
|
||||
- scan-gateway: `_choose_edition` → выбор редакции перечня кодов в общем коде
|
||||
- tabular-parser: добавляет выбор редакции перечня кодов
|
||||
- **3. Справочник строк** — только в ядре; в общий код сервисов не входит; копия в шлюзе удаляется
|
||||
- scan-gateway: удаляет `api/rules/statement_lines.yaml`
|
||||
- tabular-parser: без изменений
|
||||
- **4. Уверенность кода** — от источника (ИС / 100), без срезания до 99 по справочнику; «код не в справочнике» — проверка ядра по его словарю, не сервисов
|
||||
- **4. Уверенность кода** — от источника (ИС / 100), без срезания до 99 по справочнику; «код не в справочнике» — проверка ядра, предупреждение `info` к строке, не сервисов
|
||||
- scan-gateway: убирает `min(conf, 99)`
|
||||
- tabular-parser: как сейчас
|
||||
|
||||
@ -484,31 +532,52 @@ line_name_confidence, values}`: `line_code` — разобранный код (4
|
||||
|
||||
**Вход:** `FormCandidate` (`type_hint`, `evidence`, `header`) + `TableLayout` + `StatementLine[]`.
|
||||
|
||||
**Единый выход:** `StatementDocument {document_type, locator, header, columns, lines}` без `checks`.
|
||||
**Единый выход:** `StatementDocument {document_type, form_variant, locator, header, columns, lines}` без `checks`.
|
||||
|
||||
- **1. Тип формы** — одно правило: большинство кодов 1xxx → `BALANCE`, 2xxx → `INCOME`; совпало с `type_hint` или `type_hint` `null` — тип по кодам; противоречит — тип по кодам + предупреждение; кодов нет — `type_hint`; без колонки кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check`), `type_hint` нет → не документ, предупреждение info
|
||||
- scan-gateway: как сейчас, через общее правило
|
||||
- tabular-parser: добавляет сверку с кодами
|
||||
- **1. Тип формы** — одно правило: большинство кодов в диапазоне ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`; совпало с `type_hint` или `type_hint` `null` — тип по кодам; противоречит — тип по кодам + предупреждение `error`; кодов нет — `type_hint`. Без колонки кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с предупреждением `error`); `type_hint` нет: у `ocr` каждая таблица ИС с содержимым — потерянный документ, предупреждение `error` файла (сегодня шлюз: «нет шапки и столбца кода» → `needs_check`), у `excel` лист «прочее» — `info`. `DISCLOSURE` по `type_hint` — шаги 2–7 не выполняются; `DISCLOSURE` по кодам — шаги 2–5 выполнены, строки и колонки отбрасываются (ядро строки пояснений не хранит), проверки не считаются, документ `done`. **Вариант формы** `form_variant` — `full` | `simplified`, только по структуре кодов (ОКУД для этого не годится: 0710096 — код КНД комплекта упрощённой отчётности, на бланках стоят 0710001 / 0710002): ББ — есть 1600 и нет ни 1100, ни 1200; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300; шаг 7 и перечень кодов берут упрощённый набор. Образец упрощённой формы — в fixtures при первой возможности
|
||||
- scan-gateway: как сейчас, через общее правило; добавляет упрощённый вариант и `error` за потерянную таблицу
|
||||
- tabular-parser: добавляет сверку с кодами и упрощённый вариант
|
||||
- **2. Склейка страниц** — решена в шаге 1, различия нет
|
||||
- scan-gateway: без изменений
|
||||
- tabular-parser: без изменений
|
||||
- **3. `locator`** — одна грамматика «пары `ключ:значение` через `;`», набор ключей по источнику: `json:<имя>;table:<i>` / `sheet:<A>[,<B>]`
|
||||
- **3. `locator`** — одна грамматика «пары `ключ:значение` через `;`», набор ключей по источнику: `file:<имя json>;table:<i>` / `sheet:<A>[,<B>]` — ключи как сейчас, golden-эталоны и §11 не меняются
|
||||
- scan-gateway: без изменений
|
||||
- tabular-parser: без изменений
|
||||
|
||||
Шапка документа — поля `header` кандидата как есть (`text` → `*_text`, `value` → поле, `confidence` → `*_confidence`).
|
||||
Шапка документа — поля `header` кандидата как есть (`text` → `*_text`, `value` → поле, `confidence` → `*_confidence`
|
||||
у всех полей).
|
||||
|
||||
### Шаг 7 — Самопроверки
|
||||
|
||||
**Вход:** `StatementDocument`.
|
||||
|
||||
**Единый выход:** `StatementChecks` у обоих: `relations_failed[]`, `soft_relations_failed[]` (каждое нарушение — с
|
||||
кодами слагаемых и `column_index`, чтобы экран подсветил ячейки). Проверки по словарю строк (код не в словаре, код не
|
||||
из редакции формы по дате отчёта) — в ядре, сервисы словаря строк не знают (Р-3). `duplicate_line_codes` уходит:
|
||||
соотношения считаются по сумме значений одного кода в
|
||||
колонке (повтор кода — законный случай, шаг 3 п. 3); нарушение подсвечивает все строки этого кода.
|
||||
кодами слагаемых и `column_index`, чтобы экран подсветил ячейки). Правила счёта:
|
||||
|
||||
- **1. Проверки только у парсера; словарь §7 в двух копиях; ядро результат не читает** — проверки — общий код с одним словарём `statement_checks.yaml` у обоих сервисов; для OCR нарушение §7 — главный признак неверно прочитанной цифры; ядро читает `checks` из результата и пересчитывает тем же кодом только после правок аналитика (PATCH)
|
||||
- значение кода в колонке — сумма по всем строкам `ok` с этим кодом (повтор кода — законный случай, шаг 3 п. 3); строки
|
||||
`detail`, `unreadable`, `missing` в соотношения не входят; `duplicate_line_codes` уходит; нарушение подсвечивает все
|
||||
строки кода;
|
||||
- соотношение считается **посчитанным**, если непуста цель или хотя бы один член; пропускается, только когда пусто
|
||||
всё (ни строки `ok` с кодом цели, ни одного непустого члена). Пустая цель при непустых членах — жёсткое нарушение
|
||||
(«БАЛАНС» без кода, ушедший в `detail`, делает 1600 невидимым, и это должно подсветить родителя); непустая цель при
|
||||
пустых членах — тоже нарушение (лист только с активом: «1600 = 1700» при отсутствующем 1700 — `needs_check`, как
|
||||
сегодня у трёх файлов манифеста парсера). Сегодня оба сервиса и ядро соотношение с отсутствующей целью пропускают
|
||||
— поведение меняется; посчитанное соотношение из всех `null` ничего не подтверждает (то же правило, что для
|
||||
«родитель = Σ подстрок» по AURORA-1527);
|
||||
- «сошлось» / «не сошлось»: жёсткое соотношение не сошлось, если расхождение больше допуска ±1 **и** больше
|
||||
округления 0,001 % — только тогда оно в `relations_failed`; расхождение в пределах округления и мягкие соотношения —
|
||||
в `soft_relations_failed`, это `info`, на статус и подсветку не влияют (OQ-138 сохраняется);
|
||||
- соотношение с членами по модулю (`abs_terms`: 2120, 2210, 2220, 2330, 2350, 2410) сходится при любом знаке такого
|
||||
члена и потому **подтверждает только величину, не знак** — см. «Подтверждение проверкой»;
|
||||
- набор соотношений выбирается по `form_variant`: полный или упрощённый (нет подытогов 1100 / 1200 / 1300 у ББ,
|
||||
2100 / 2200 / 2300 у ОФР, свои равенства), оба — в одном словаре `statement_checks.yaml`;
|
||||
- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код
|
||||
из первых четырёх цифр (`subline_parents`, §12 п. 7: 12310 под 1230).
|
||||
|
||||
Проверки по словарю имён (код не в словаре) и по перечню кодов (код не из редакции формы по эффективной дате) — в
|
||||
ядре, как предупреждения `info` к строке; сервисы словаря имён не знают (Р-3, Р-12).
|
||||
|
||||
- **1. Проверки только у парсера; словарь §7 в двух копиях; ядро результат не читает** — проверки — общий код с одним словарём `statement_checks.yaml` у обоих сервисов; для OCR нарушение §7 — главный признак неверно прочитанной цифры; ядро читает `checks` из результата, хранит их (`document_issue`, см. «Что уходит из ядра») и пересчитывает тем же кодом только после правок аналитика (PATCH)
|
||||
- scan-gateway: добавляет шаг
|
||||
- tabular-parser: `run_statement_checks` → общий код
|
||||
|
||||
@ -516,107 +585,338 @@ line_name_confidence, values}`: `line_code` — разобранный код (4
|
||||
|
||||
**Вход:** `StatementDocument` + `StatementChecks` + предупреждения шагов 1–7.
|
||||
|
||||
**Единый выход:** у каждого документа `status` (`done` | `needs_check`) и `warnings[]`; у файла `metrics.warnings[]`;
|
||||
статус конверта — агрегат. Предупреждение — структура `{code, severity: error | info, message, line_ref?,
|
||||
column_index?}`, а не строка.
|
||||
**Единый выход:** у каждого документа `status` (`done` | `needs_check`), `warnings[]`, `needs_review` у каждого
|
||||
значения, каждой строки и каждого поля шапки; у файла `metrics.warnings[]`; статус конверта — агрегат.
|
||||
Предупреждение — структура `{code, severity: error | info, message, line_refs?: [], column_index?, field?}`, а не
|
||||
строка; `line_refs` — список, потому что нарушение подсвечивает все строки кода.
|
||||
|
||||
- **1. Критерий `done`** — один критерий из самого результата: раскладка найдена ∧ (организация или ИНН) ∧ дата отчёта ∧ все строки `ok` ∧ нет значений и строк `needs_review` (см. «Подтверждение проверкой» ниже) ∧ жёсткие проверки §7 сошлись ∧ нет предупреждений уровня `error`. `DISCLOSURE` — `done` без условий о раскладке, строках и проверках (как сейчас). Excel выполняет условия уверенности автоматически
|
||||
- **1. Критерий `done`** — одна функция общего кода с входами «раскладка, шапка, статусы строк, `needs_review`, нарушения, предупреждения, подтверждения и акцепт аналитика»: раскладка найдена ∧ (организация или ИНН) ∧ период определён (дата отчёта **или** самая поздняя абсолютная дата колонок — `statement_period` ядра; у ИС поле `date` часто мусор: у aep_6 и «БО 1 кв 2025» там название организации) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей шапки с `needs_review` (см. «Подтверждение проверкой» ниже) ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет предупреждений уровня `error`. `DISCLOSURE` — `done` без условий о раскладке, строках и проверках (как сейчас). Excel выполняет условия уверенности автоматически; структурная проверка кодов к Excel не применяется (Р-12). Квартал периода, статус конверта, сверка ИНН и наименований — знание ядра, в `done` не входят
|
||||
- scan-gateway: «ничего не выброшено и уверенность 100» → общий критерий
|
||||
- tabular-parser: `_is_ok` → общий критерий; добавляются строки и значения
|
||||
- **2. Влияние предупреждений** — по уровню: `error` → `needs_check`, `info` — не влияет; у обоих одинаково; уровень каждого предупреждения задаётся в спеке (например, «строка заголовка не найдена», «колонка без даты», «тип по кодам противоречит заголовку» — `error`; «подпись из словаря исключений пропущена», «дробные при тыс. руб.» — `info`)
|
||||
- **2. Влияние предупреждений** — по уровню: `error` → `needs_check`, `info` — не влияет; у обоих одинаково. Таблица уровней, полная версия — в спеке. `error`: ошибочный json набора при успешном соседе (уровень файла; решение № 28 среза 3a), `BACKEND_PARSING_ERROR` таблицы, таблица ИС с содержимым без колонки кодов и без типа (уровень файла), сегмент с содержимым без колонки кодов (в том числе потерянный лист-продолжение), лист с тремя и более кодами и другим числом колонок, не присоединившийся к форме, колонки сегмента не совпали, колонка без даты, дата колонки неуверенна, заголовка нет ни у одного сегмента, тип по кодам противоречит заголовку, тип маркера продолжения противоречит форме, ни организации, ни ИНН в шапке, период не определён (нет ни даты отчёта, ни дат колонок), дата отчёта неуверенна и не совпала с датами колонок, xlsb без форматов при «тыс. руб.» и дробных. `info`: ИНН не найден или неуверен при найденной организации (сверку с ЮЛ делает ядро — `MISMATCH`; НПЗ ОФР без ИНН остаётся `done`), организация не найдена или неуверенна при найденном ИНН, дата отчёта не разобрана при наличии дат колонок, единицы не найдены (аналитик правит в шапке), формат файла не совпал с `file_type` (файл прочитан верно), подпись из словаря исключений пропущена, дробные при «тыс. руб.» (кроме xlsb без форматов), xlsb без числовых форматов само по себе (tp#42, документ остаётся `done`), лист Excel «прочее» без `type_hint` и кодов, разлеплена склеенная строка, код не в словаре имён или не из редакции (ядро). Принцип: `error` — только то, что меняет данные или их полноту
|
||||
- scan-gateway: «любое → `needs_check`» уходит
|
||||
- tabular-parser: предупреждения начинают влиять
|
||||
- **3. Статус документа** — в контракте: `document.status`; конверт `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` (как сейчас)
|
||||
- **3. Статус документа** — в контракте: `document.status` у каждого документа, включая ОСВ (у ОСВ `done`, когда ведомость разобрана, как сейчас; её предупреждения — `info`); конверт `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` с кодом по источнику: `RECOGNITION_FAILED` у шлюза, `INVALID_DOCUMENT` у парсера — это различие остаётся, коды описывают разные причины. Статус конверта в условие `RECOGNIZED` ядра не входит, туда входят предупреждения `error` уровня файла (Р-19)
|
||||
- scan-gateway: добавляет
|
||||
- tabular-parser: внутренний `ok` → в контракт
|
||||
- **4. Формат предупреждений** — структура (см. выше) вместо строк `<json>: table:<i>: …` / `sheet=<имя> …`; место — в `locator` документа, а не в тексте
|
||||
- **4. Формат предупреждений** — структура (см. выше) вместо строк `<json>: table:<i>: …` / `sheet=<имя> …` у всех документов результата, включая ОСВ; место — в `locator` документа, а не в тексте
|
||||
- scan-gateway: меняет
|
||||
- tabular-parser: меняет
|
||||
- **5. Привязка** — к документу, строке (`line_ref`) или ячейке (`line_ref` + `column_index`); экран показывает у места
|
||||
- tabular-parser: меняет, включая путь ОСВ
|
||||
- **5. Привязка** — к документу, строкам (`line_refs`), ячейкам (`line_refs` + `column_index`) или полю шапки (`field`); `ref` строки уникален в документе и хранится в ядре вместе с `row_index`, поэтому экран показывает предупреждение у места и после сохранения
|
||||
- scan-gateway: меняет
|
||||
- tabular-parser: меняет
|
||||
|
||||
**Подтверждение проверкой (вопрос владельца 17.10.2026).** Проверка §7 — арбитр, уверенность — подсказка там, где
|
||||
проверки нет. По каждому значению сервис считает `needs_review`:
|
||||
**Подтверждение проверкой.** Проверка §7 — арбитр, уверенность — подсказка там, где проверки нет. По каждому
|
||||
значению сервис считает `needs_review`:
|
||||
|
||||
- `true`, если `confidence` ниже порога (Р-5: 100) **и** значение не покрыто сошедшимся жёстким соотношением §7 в своей
|
||||
колонке (не входит ни в одно, соотношение не сошлось или оно мягкое); для `is_recognized = false` — то же правило,
|
||||
если Р-7 = да, иначе всегда `true`;
|
||||
- `true` у всех ячеек соотношения в колонке, где оно не сошлось, независимо от уверенности;
|
||||
- иначе `false`: значение подтверждено проверкой, не подсвечивается и статус не трогает; сырой текст и уверенность
|
||||
остаются в подсказке ячейки.
|
||||
- значение **нуждается в покрытии**, если `confidence` ниже порога (Р-5: 100) или `is_recognized = false` (число
|
||||
собрано из грязного текста, с заменой двойников или из разлепленной строки — Р-7, Р-11, Р-17); чистые значения
|
||||
(уверенность 100 и `is_recognized = true`) в покрытии не нуждаются: правило покрытия и правило знака к ним не
|
||||
применяются;
|
||||
- значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось
|
||||
(в пределах допуска или округления), и `amount` самого значения не `null` (мусор, давший `null`, покрытым не
|
||||
бывает);
|
||||
- **знак**: для члена соотношения с `abs_terms` покрытие подтверждает только величину. Знак подтверждён, если форма
|
||||
записи знака у ячейки (скобки, минус, без знака — по `text`) совпадает с конвенцией колонки — формой записи у
|
||||
большинства членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без правки аналитика
|
||||
(правленые ячейки в голосование не входят); не совпадает или сравнивать не с чем — `needs_review = true` независимо
|
||||
от соотношения. Так «(7744536}» с `}` как закрывающей скобкой даёт −7 744 536 и подтверждается, если 2210 и 2220 в
|
||||
той же колонке тоже в скобках; «{38378)» при остальных в скобках — подсвечивается;
|
||||
- `needs_review = true`, если значение нуждается в покрытии и не покрыто (или не подтверждён знак);
|
||||
- `true` у всех ячеек жёсткого соотношения в колонке, где оно не сошлось вне допуска и округления, независимо от
|
||||
уверенности и чистоты; мягкие соотношения и расхождения в пределах округления подсветки не дают;
|
||||
- иначе `false`: значение подтверждено проверкой, не подсвечивается и статус не трогает; сырой текст (`text`) и
|
||||
уверенность остаются в подсказке ячейки.
|
||||
|
||||
Экран подсвечивает ячейки только по `needs_review`; после PATCH ядро пересчитывает `checks` и `needs_review` общим
|
||||
кодом. Не покрыто проверками и остаётся на уверенности: поля шапки, справочные строки ОФР 25xx / 29xx, детализация
|
||||
без проверки родителя, `DISCLOSURE`, колонки, где есть только мягкие соотношения. Риск — две взаимно компенсирующие
|
||||
ошибки в одном соотношении; принимается (перекрёстные соотношения баланса его снижают). Закрывает AURORA-1524 без
|
||||
ущерба для 1519. Распространение на `is_recognized = false` — Р-7.
|
||||
Экран подсвечивает ячейки только по `needs_review`. Ядро хранит `needs_review` и `text` как пришли и пересчитывает
|
||||
`needs_review` вместе с `checks` общим кодом после PATCH (конвенция знака считается от сохранённого `text`), добавляя
|
||||
своё: правленое значение (`is_modified`) или подтверждённое аналитиком без изменения (`is_confirmed`, хранится) —
|
||||
`needs_review = false`; акцепт нарушений — жёсткие нарушения не считаются; предупреждения `error` документа
|
||||
сохраняются и после правки, кроме снимаемых соответствующей правкой (таблица — в «Что уходит из ядра»). Не покрыто
|
||||
проверками и остаётся на уверенности: справочные строки ОФР 25xx / 29xx, детализация без проверки родителя,
|
||||
`DISCLOSURE`, колонки, где есть только мягкие соотношения. Риск — две взаимно компенсирующие ошибки в одном
|
||||
соотношении; принимается (перекрёстные соотношения баланса его снижают). Закрывает AURORA-1524 без ущерба для 1519.
|
||||
Сошедшееся посчитанное равенство подтверждает и догадку из грязного текста (Р-7): случайное совпадение требует второй,
|
||||
компенсирующей ошибки в том же равенстве.
|
||||
|
||||
**Код и наименование строки (Р-5, решение владельца 17.10.2026).** Уверенность кода подтверждается не §7 (перепутанные
|
||||
соседи 1220 и 1230 дают ту же сумму раздела), а структурой: код есть в перечне кодов формы и стоит по возрастанию
|
||||
внутри раздела. Подтверждён — уверенность не важна; иначе `needs_review` у строки (`StatementLine.needs_review`),
|
||||
документ — `needs_check`. По `fixtures/ocr` коды часто приходят с уверенностью 50 и 25, поэтому требовать 100, как
|
||||
шлюз сейчас, нельзя. Перечень кодов форм без наименований — часть общего кода (нужен и для поиска столбца кодов), не
|
||||
словарь строк. Уверенность наименования на статус и подсветку в сервисах не влияет: сервисы отдают текст как есть;
|
||||
сопоставление файлового наименования со справочным по коду — вопрос ядра, открыт (см. «Что уходит из ядра»).
|
||||
**Поля шапки.** У каждого поля шапки свой `needs_review`: дата отчёта с уверенностью ниже 100 подтверждена, если
|
||||
совпала с самой поздней абсолютной датой колонок, иначе `needs_review = true` и предупреждение `error` «дата отчёта
|
||||
неуверенна» — иначе неверно прочитанный год давал бы неверный период и автоподтверждение; ИНН и организация с
|
||||
уверенностью ниже 100 — `info`, их сверяет ядро (ИНН — `MISMATCH` с ЮЛ, организация — по алиасам); единицы и прочие
|
||||
поля на статус не влияют. Сегодня шлюз требует уверенность 100 у ИНН и организации и распознанную дату — правило
|
||||
становится точнее, не слабее.
|
||||
|
||||
**Код строки (Р-5, Р-12, Р-16).** Уверенность кода подтверждается не §7 (перепутанные соседи 1220 и 1230 дают ту же
|
||||
сумму раздела), а структурой и наименованием; правило применяется **только к неуверенным кодам** — уверенность ниже
|
||||
100 или замена двойника (Р-12): у Excel и чистых OCR-кодов расхождение с перечнем даёт предупреждение `info` ядра «код
|
||||
не из редакции», а не `needs_check` — иначе пятизначные подстроки 1С (11101, 23201) и нестандартные строки клиента
|
||||
(1231) блокировали бы автоподтверждение. В общем коде лежит **упорядоченный перечень кодов** каждой формы по
|
||||
редакциям (66н, ФСБУ 4) и вариантам (полная, упрощённая) без наименований — порядок бланка, где итоги стоят после
|
||||
слагаемых (1110 … 1190, 1100; 2110, 2120, 2100), а не по возрастанию. Источник перечня — объединение кодов шаблонов
|
||||
банка (словарь ядра) и кодов печатных форм 1С по редакции: 1105, 1530, 2420, 2460, 2930 и другие, которых в словаре
|
||||
ядра нет. Алгоритм: четырёхзначные коды строк документа образуют последовательность; для каждой проверяемой
|
||||
редакции берётся наибольшая подпоследовательность, согласованная с порядком бланка; редакция — та, где она длиннее
|
||||
(при равенстве — по эффективной дате); если таких подпоследовательностей несколько, подтверждены только коды, общие
|
||||
для всех; коды с уверенностью 100 участвуют в последовательности как подтверждённые якоря. Пятизначный код
|
||||
подтверждён, если подтверждён его родитель (правило шага 7) и подстрока стоит после него. Равные соседние коды
|
||||
допустимы, но повтор кода, у которого хотя бы одна копия неуверенна, — `needs_review` у неуверенной строки (Р-16).
|
||||
Неподтверждённый неуверенный код — `needs_review` у строки (`StatementLine.needs_review`), документ — `needs_check`.
|
||||
Структура не ловит подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120, когда 1120 на
|
||||
бланке пропущен): это закрывает ядро — для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение
|
||||
распознанного наименования со справочным по коду (Р-16; порог сходства — в спеке), расхождение подсвечивает строку
|
||||
через `document_issue`. По `fixtures/ocr` коды часто приходят с уверенностью 50 и 25, поэтому требовать 100, как
|
||||
шлюз сейчас, нельзя. Перечень кодов — не словарь строк: наименований в нём нет, Р-3 не нарушается; ядро пользуется
|
||||
своей копией перечня для проверки «код не из редакции», словарь ядра хранит только имена. Уверенность наименования
|
||||
на статус и подсветку в сервисах не влияет: сервисы отдают текст как есть.
|
||||
|
||||
### Шаг 9 — Результат
|
||||
|
||||
Одинаково, как сейчас; контракт `recognition-result.schema.json` → **v1.3**: `line_status`, `line_code` nullable,
|
||||
`line_code_text`; `needs_review` у значения и у строки; `document.status`; `document.warnings[]` и `metrics.warnings[]`
|
||||
структурные (уровень, привязка); `checks` обязателен для `BALANCE` / `INCOME` со строками у обоих конвейеров (у
|
||||
документа без строк отсутствует), нарушения с кодами слагаемых, без `duplicate_line_codes`.
|
||||
Одинаково, как сейчас; контракт `recognition-result.schema.json` → **v2.0** (Р-9: по правилам контракта смена типа
|
||||
поля — MAJOR). Что меняется: `line_code` nullable, `line_status`, `line_code_text`, `row_index`, `ref`,
|
||||
`parent_ref`, `needs_review` у значения, строки и полей шапки, `values[].text`, `header.*_confidence` и `*_text` у
|
||||
всех полей; `document.status` у всех документов, включая ОСВ, `document.form_variant`; `document.warnings[]` и
|
||||
`metrics.warnings[]` — структуры с уровнем и привязкой у всех документов, включая ОСВ; `columns[].period_kind` и
|
||||
`label_confidence`; `checks` обязателен для `BALANCE` / `INCOME` со строками у обоих конвейеров (у документа без строк
|
||||
отсутствует), нарушения с кодами слагаемых, без `duplicate_line_codes`; `service.statements_version` — коммит копии
|
||||
общего кода, которым посчитан результат. Выкладка: ядро поднимает `SUPPORTED_MAJOR` и на время перехода принимает
|
||||
1.x и 2.0 (два маппера, ack тоже 1.x и 2.0; при приёме 1.x статус документа считается по прежнему правилу D-6 —
|
||||
`job_done ∧ проверки ∧ values_ok ∧ …`, новые поля заполняются по умолчанию: `line_status = ok`, `row_index` по
|
||||
порядку, `text = string_value`), сервисы переходят на 2.0 после ядра — порядок «сначала ядро» обязателен, иначе
|
||||
первый же результат с `null` в `line_code` даёт файлу `ERROR`. Запрос `recognition-request` остаётся 1.x. Путь ОСВ
|
||||
в парсере меняется только в части структуры предупреждений и `document.status`.
|
||||
|
||||
### Что уходит из ядра
|
||||
### Что уходит из ядра и что в нём меняется
|
||||
|
||||
- `build_statement`: повторная дедупликация колонок, кодов и значений (контракт гарантирует уникальность колонок;
|
||||
повторы кодов — законные строки, хранятся все как пришли).
|
||||
- Повторы кодов (решение владельца 17.10.2026): в `financial_statement_line` хранятся все строки; уникальный индекс
|
||||
`(meta_id, line_code)` из DBML v5.18 снимается (DBML v5.19, миграция). Для проверок §7, подтверждения и всего, что
|
||||
идёт после (КФО, выгрузки, сверки), документ читается через агрегат по коду — сумма значений, справочное имя —
|
||||
чистой функцией, без хранения (DECISION-5). Экран показывает строки как в файле.
|
||||
- `statement_status` (D-6): `RECOGNIZED` ⇔ `document.status = done` ∧ ИНН ≠ `MISMATCH` (единственное знание ядра);
|
||||
остальные признаки D-6 не пересчитываются.
|
||||
- §7 при загрузке: `checks` берутся из результата; пересчёт общим кодом — только после PATCH.
|
||||
- Словарь §7: единственный экземпляр в общем коде (Р-1), копия ядра удаляется. Словарь строк (код → наименование,
|
||||
редакции) остаётся только в ядре, копия шлюза удаляется; проверки по нему (код не в словаре, код не из редакции по
|
||||
дате отчёта) ядро делает само при загрузке и после PATCH, как предупреждения к строке.
|
||||
- Достройка шаблонных строк: по словарю ядра; строки `missing` / `unreadable` показываются как пришли, с
|
||||
подсветкой; код, у которого есть хотя бы одна строка из файла, шаблоном не достраивается.
|
||||
- Экран: подсветка ячеек только по `needs_review` (уверенность, `is_recognized` и нарушения §7 уже сведены сервисом);
|
||||
строк — по `line_status` и `needs_review` строки; предупреждения документа — с привязкой к месту.
|
||||
- Открытый вопрос ядра (не сервисов): сопоставлять ли файловое наименование строки со справочным по коду и что
|
||||
делать при расхождении (владелец 17.10.2026: «наверное, будем пытаться сопоставить»). В сервисах уверенность и
|
||||
текст наименования на статус не влияют.
|
||||
- **DBML v5.19 и одна миграция:** `financial_statement_line` — снять уникальность `(meta_id, line_code)`, добавить
|
||||
`row_index` (у строк из источника; у шаблонных и созданных правкой — `null`), `source_ref` (частичный уникальный
|
||||
индекс по `(meta_id, source_ref)` там, где не `null`), `line_status`, `line_code_text`, `parent_row_index` (родитель
|
||||
`detail`), `needs_review`, `is_confirmed` (подтверждение кода аналитиком); `financial_statement_value` —
|
||||
`source_text` (из `values[].text`), `needs_review` (как пришло от сервиса, пересчитывается ядром после PATCH;
|
||||
пересматривает решение v5.13 «считается, не хранится»), `is_confirmed` (подтверждено без изменения, хранится вместе
|
||||
с `confirmed_by / at`); `financial_statement_column.period_kind`, `label_confidence`; `financial_statement_meta.form_variant`,
|
||||
`needs_review` полей шапки; таблица `document_issue` (вид `warning` | `relation` | `soft`, код, уровень, сообщение,
|
||||
список `line_id`, `column_id?`, `field?`, источник `service` | `core`) — хранит предупреждения и нарушения как
|
||||
пришли; при перезагрузке файла очищается; при пересчёте после PATCH записи `relation` / `soft` заменяются целиком
|
||||
независимо от источника, записи `warning` источника `service` сохраняются, кроме снимаемых правкой;
|
||||
`recognition_job.statements_version`; `document` — снять уникальность `(stored_file_id, document_type,
|
||||
osv_account_code)` только для `BALANCE` / `INCOME` (частичный индекс: ОСВ по счёту и `DISCLOSURE` / `OTHER` остаются
|
||||
«один на тип на файл», §9.5 п. 2) и добавить порядковый номер формы в файле (Р-10); в ту же миграцию — `crm_id`
|
||||
ГСК / ГСЗ (AURORA-1619, arm-core#112). Заполнение при миграции: `row_index` существующих строк — по порядку кода,
|
||||
`line_status = ok`, `needs_review` значений — по прежнему `values_ok`, `source_text = string_value`.
|
||||
- Повторы кодов (Р-4): для проверок §7, подтверждения и всего, что идёт после (КФО, выгрузки, сверки), документ
|
||||
читается через **одну функцию «агрегат формы по коду»** — сумма значений строк `ok` по колонке, список `line_id`,
|
||||
справочное имя — чистой функцией, без хранения (DECISION-5). На неё переводятся все точки, где сегодня «одна
|
||||
строка на код»: `_line_amount` (межформенная проверка 1370 / 2400), `statement_amounts`, `subline_parents` /
|
||||
`parent_code`, `formLayout` (дети под родителем), `_resolve_cells`, `metrics.fields_*`.
|
||||
- `statement_status` (D-6) при приёме 2.0: `RECOGNIZED` ⇔ `document.status = done` ∧ нет предупреждений `error`
|
||||
уровня файла (потерянный json или таблица ИС, решение № 28 среза 3a; `needs_check` соседнего документа само по себе
|
||||
не блокирует — Р-19) ∧ ИНН ≠ `MISMATCH` ∧ период — конец квартала (`period_known`: иначе документ `RECOGNIZED`, но
|
||||
не подтверждаемый, 409 `PERIOD_REQUIRED`) ∧ у строк с неуверенным кодом распознанное наименование совпало со
|
||||
справочным (Р-16) ∧ в файле нет второй формы того же типа и периода (Р-18: при двух и более — ни одна не
|
||||
автоподтверждается). Остальные признаки D-6 не пересчитываются.
|
||||
- §7 при загрузке: `checks`, `needs_review` и предупреждения берутся из результата и хранятся (`document_issue`);
|
||||
ядро добавляет только `info` по словарю имён и перечню кодов и свою сверку наименований для неуверенных кодов.
|
||||
Пересчёт общим кодом — только после PATCH, с правилами `is_modified` или `is_confirmed` ⇒ `needs_review = false` у
|
||||
значения; `line_code_modified` или `is_confirmed` ⇒ `needs_review` строки = false; акцепт ⇒ жёсткие нарушения
|
||||
сняты. Версия общего кода из результата (`statements_version`) пишется в `recognition_job`; при пересчёте ядро
|
||||
сравнивает её со своей копией, расхождение — предупреждение `info` к документу (на окне выкладки «сначала ядро»
|
||||
расхождение штатно).
|
||||
- **Какая правка какое `error` снимает:** PATCH `header.report_date` — «период не определён», «дата отчёта не
|
||||
разобрана», «дата отчёта неуверенна»; PATCH `header.inn` или `organization_name` — «ни организации, ни ИНН»; PATCH
|
||||
`columns[].period_date` — «колонка без даты», «дата колонки неуверенна»; назначение кода или отметка `detail` —
|
||||
нарушения, пересчитываемые §7. Остальные `error` сервиса (колонки сегмента не совпали, заголовка нет, тип
|
||||
противоречит, потерянный json, лист или таблица, xlsb с масштабом) снимаются только перезагрузкой файла.
|
||||
- **PATCH `…/statement`** (Р-13): помимо `header` и `values` принимает `values[].confirmed` (подтвердить ячейку без
|
||||
изменения значения: `is_confirmed`, `needs_review = false`, `is_modified` не ставится, аудит), `columns:
|
||||
[{column_id, period_date}]` (дата колонке без подписи или с неуверенной подписью; пересчитывает `is_current`;
|
||||
`report_date: null` обнуляет `period_end` относительных колонок) и `lines`: «назначить код» (`line_id`, `line_code`),
|
||||
«отметить расшифровкой» (`line_id`, `line_status = detail`), «подтвердить код» (`line_id`, `confirmed`). Переходы:
|
||||
`unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` отметкой;
|
||||
`ok` → `ok` с другим кодом (смена кода, прежний в `line_code_original_value`); `ok` неуверенный → подтверждён.
|
||||
Правила: код 4–5 цифр, может совпадать с существующим (Р-4); незнакомый словарю — `info`; назначенный или
|
||||
подтверждённый код снимает `needs_review` строки, `line_code_confidence` становится 100, `line_code_modified`,
|
||||
`line_code_original_value`; у `detail` родитель пересчитывается как ближайшая строка `ok` выше по `row_index`;
|
||||
шаблонная строка с таким кодом, если она уже создана правкой аналитика с суммами, — 409 `LINE_EXISTS` с подсказкой
|
||||
(аналитик сначала очищает её суммы или выбирает другой код), без сумм — удаляется, её место занимает настоящая;
|
||||
пара «`line_status = ok` без кода» — 422; удаления строк нет (первичка не мутируется, `detail` — способ вывести
|
||||
строку из сумм). Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Аудит
|
||||
`RECOGNIZED_DATA_EDIT` с `before / after` по коду, статусу, родителю и подтверждению. Один PATCH — один пересчёт и
|
||||
одна запись.
|
||||
- **Подтверждение документа** (Р-13, Р-18): порядок проверок 409 по §9.5 п. 4: `PERIOD_REQUIRED` → `LINES_UNRESOLVED`
|
||||
(есть строки `unreadable` / `missing` с непустым `amount`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле уже
|
||||
подтверждена другая форма того же типа и периода; 409 со ссылкой на неё; между файлами работает автозамена §12 п. 3)
|
||||
→ жёсткие нарушения без акцепта. Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не
|
||||
допускает). `detail` в агрегат, КФО и снимок ЕХ не попадают — цена Р-8.
|
||||
- Правка даты отчёта (PATCH шапки): для колонок с `period_kind = relative:<вид>` ядро досчитывает `period_end` общим
|
||||
кодом от новой даты.
|
||||
- Словарь §7: единственный экземпляр в общем коде (Р-1), копия ядра удаляется. Словарь имён строк (код → наименование
|
||||
по редакциям) остаётся только в ядре, копия шлюза удаляется; порядок и состав редакций ядро берёт из своей копии
|
||||
перечня общего кода.
|
||||
- Достройка шаблонных строк: по словарю ядра; код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не
|
||||
достраивается.
|
||||
- **Экран** (Р-14): раскладка остаётся «как на бумаге» — разделы, внутри строки `ok` по возрастанию кода, несколько
|
||||
строк одного кода в порядке файла, шаблонные строки на своих местах, пятизначные подстроки под родителем. Строка
|
||||
`unreadable` / `missing` ставится под ближайшей предыдущей по `row_index` строкой, которая разместилась по коду, а
|
||||
без такой — в начало первого раздела; подсвечена целиком, в ячейке кода серым сырой текст с подсказкой. Строки
|
||||
`detail` скрыты под переключателем «Показать расшифровки (N)», как пустые строки детализации, показываются под
|
||||
родителем с отступом. Подсветка ячеек только по `needs_review` (уверенность, `is_recognized` и нарушения §7 уже
|
||||
сведены сервисом), в подсказке — `text` и уверенность; поля шапки с `needs_review` подсвечены; предупреждения и
|
||||
нарушения — из `document_issue`, с привязкой к месту. В режиме «Редактировать»: ячейка кода у `unreadable` /
|
||||
`missing` — поле ввода с подсказкой из справочника (код → справочное имя), меню строки «Это расшифровка»; у `detail`
|
||||
— «Назначить код»; у обычной строки — «Сделать расшифровкой», смена кода и «Подтвердить код» у неуверенного;
|
||||
подтверждение ячейки без правки; дата у колонки без подписи. Кнопка «Подтвердить данные» при неразобранных
|
||||
строках с суммами отключена с подсказкой «Разберите строки без кода: N» и переходом к первой; сервер отвечает тем
|
||||
же 409.
|
||||
- Открытый вопрос: склейка двух половинок формы из одного файла не предоставляется, файл перезагружают — в
|
||||
`07-open-questions.md` (Р-15). Вопрос о сопоставлении файлового наименования со справочным закрыт Р-16: для
|
||||
неуверенных кодов это условие `RECOGNIZED`, для остальных — только показ справочного имени.
|
||||
|
||||
### Решения, которые нужны от владельца
|
||||
### Решения владельца (Р-1…Р-19, 17.10.2026)
|
||||
|
||||
- **Р-1** (шаг 1, п. 2) — **решено 17.10.2026: копия по скрипту.** Платформа не собирает Python-пакеты и не кладёт их
|
||||
в свой Nexus, поэтому общий код — dataclass-ы шага 1 (сериализуемые в json, golden-файлы в fixtures), шаги 2–8 и
|
||||
словари §7, периодов, заголовков форм, диапазонов кодов (без словаря строк) — живёт в отдельном репозитории
|
||||
`arm-statements` со своими тестами и оформлен как пакет `arm_statements/` с `pyproject`. В tabular-parser,
|
||||
scan-gateway и arm-core он лежит копией в `api/src/arm_statements/` с маркером коммита источника; раскладывает
|
||||
скрипт `sync-statements` в docs по образцу `sync-web.sh`, `make check` потребителя проверяет свежесть копии, прямые
|
||||
правки копии запрещены. Когда платформа научится собирать пакеты, копия заменяется строкой зависимости без смены
|
||||
импортов.
|
||||
- **Р-2** (шаг 4, п. 5) — **решено владельцем 17.10.2026:** отсутствующая позиция под колонкой — чистый `null` без
|
||||
собственной подсветки. Основание: по fixtures ИС отдаёт ячейку на каждую позицию (пустая — уверенность 100,
|
||||
непрочитанная — 0, и её ловит общее правило `needs_review`); отсутствие позиции бывает только при несовпадении
|
||||
колонок сегмента и покрыто предупреждением шага 2 п. 2.
|
||||
- **Р-3** (шаг 5, п. 1) — **решено владельцем 17.10.2026:** сервисы отдают распознанный текст наименования;
|
||||
соответствие «код → наименование» — только в ядре, при необходимости (= решение № 2 по спринту 2, AURORA-1406 /
|
||||
1522; договорённость ДБД «из справочника» выполняется на экране).
|
||||
- **Р-4** (шаг 3, п. 3) — **решено владельцем 17.10.2026:** повторы кодов законны; все строки сохраняются и
|
||||
показываются, для проверок и всех операций после подтверждения или автоподтверждения — суммой по коду. Следствие:
|
||||
снять уникальность `(meta_id, line_code)` в DBML.
|
||||
- **Р-5** (шаг 8, «Подтверждение проверкой») — **решено владельцем 17.10.2026:** порог для значений — 100 (у ИС
|
||||
уверенность дискретная: 100, ~80, 71, 67, 60, 50, 25, 0, промежутка 90–99 нет; в группе 70–89 есть настоящие ошибки,
|
||||
например «11 30» с уверенностью 80); ложных подсветок становится меньше за счёт подтверждения проверкой §7, а не
|
||||
порога. Код строки подтверждается структурой (перечень кодов формы + порядок), не уверенностью. Уверенность
|
||||
наименования в сервисах не используется; сопоставление со справочным — открытый вопрос ядра (= решение № 7 по
|
||||
спринту 2, AURORA-1519 / 1524).
|
||||
- **Р-6** (шаг 2, п. 6) — неизвестная числовая колонка без периода: оставлять без даты с предупреждением (`needs_check`)
|
||||
или пропускать, как парсер сейчас.
|
||||
- **Р-7** (шаг 8, «Подтверждение проверкой») — снимать ли подсветку сошедшимся соотношением §7 и с `is_recognized =
|
||||
false` (число собрано из грязного текста), а не только с низкой уверенности; предложение — да: сошедшаяся проверка
|
||||
сильнее догадки о мусоре.
|
||||
- **Р-1** (шаг 1, п. 2) — **копия по скрипту.** Платформа не собирает Python-пакеты и не кладёт их в свой Nexus, поэтому
|
||||
общий код — dataclass-ы шага 1 (сериализуемые в json), шаги 2–8, словари §7, периодов, заголовков форм, перечни и
|
||||
диапазоны кодов (без словаря имён) со своим загрузчиком — живёт в отдельном репозитории `arm-statements` со своими
|
||||
тестами и оформлен как пакет `arm_statements/` с `pyproject`. Границы пакета: пакет владеет своими типами входа и
|
||||
выхода, хост конвертирует выход в свою модель контракта (у ядра на переходе их две); golden-тесты пакета — внутри
|
||||
пакета на синтетических и обезличенных данных, тесты на реальных фикстурах остаются в scan-gateway и tabular-parser
|
||||
и соседний репозиторий не читают. В tabular-parser, scan-gateway и arm-core пакет лежит копией вместе со своими
|
||||
тестами и конфигом линтеров (конфиг пакета подключается к `pytest` и `ruff` хоста как include, чтобы правила не
|
||||
конфликтовали), импортируется по имени пакета `arm_statements` (путь добавляется в `pyproject`), поэтому замена
|
||||
копии на зависимость не трогает импорты; маркер коммита источника попадает в результат как
|
||||
`service.statements_version`; раскладывает и проверяет свежесть скрипт `sync-statements` в docs по образцу
|
||||
`sync-web.sh` (`--check`, код 20 при прямых правках), вызывается из `make check-full` перед PR локально — **не** из
|
||||
`make check` и не из пайплайна: тест, читающий соседний репозиторий, уже ломал main (arm-core #33, пайплайн
|
||||
платформы вне репозиториев). Прямые правки копии запрещены. Срочное исправление общего кода: PR в `arm-statements`
|
||||
→ `sync-statements` → PR в каждом потребителе, порядок выкладки «сначала ядро».
|
||||
- **Р-2** (шаг 4, п. 5) — отсутствующая позиция под колонкой — чистый `null` без собственной подсветки. Основание: по
|
||||
fixtures ИС отдаёт ячейку на каждую позицию (пустая — уверенность 100, непрочитанная — 0, и её ловит общее правило
|
||||
`needs_review`); отсутствие позиции бывает только при несовпадении колонок сегмента и покрыто предупреждением
|
||||
шага 2 п. 2.
|
||||
- **Р-3** (шаг 5, п. 1) — сервисы отдают распознанный текст наименования; соответствие «код → наименование» — только в
|
||||
ядре, при необходимости (= решение № 2 по спринту 2, AURORA-1406 / 1522; договорённость ДБД «из справочника»
|
||||
выполняется на экране).
|
||||
- **Р-4** (шаг 3, п. 3) — повторы кодов законны; все строки сохраняются и показываются, для проверок и всех операций
|
||||
после подтверждения или автоподтверждения — суммой по коду. Следствие: снять уникальность `(meta_id, line_code)` в
|
||||
DBML; все чтения «одна строка на код» в ядре переводятся на агрегат.
|
||||
- **Р-5** (шаг 8, «Подтверждение проверкой») — порог для значений — 100 (у ИС уверенность дискретная: 100, ~80, 71,
|
||||
67, 60, 50, 25, 0, промежутка 90–99 нет; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80);
|
||||
ложных подсветок становится меньше за счёт подтверждения проверкой §7, а не порога. Код строки подтверждается
|
||||
структурой — упорядоченным перечнем кодов формы по редакции, не уверенностью; область применения — Р-12,
|
||||
дополнение — Р-16 (= решение № 7 по спринту 2, AURORA-1519 / 1524).
|
||||
- **Р-6** (шаг 2, п. 6) — числовая колонка с нераспознанной подписью остаётся в документе без даты с предупреждением
|
||||
`error` (`needs_check`, подтверждение руками); пропуск отвергнут как тихая потеря периода. Относительные подписи
|
||||
бланка 66н («На отчётную дату», «На 31 декабря предыдущего года», …) добавляются в словарь периодов и считаются от
|
||||
эффективной даты документа; после правки даты отчёта ядро досчитывает их; дату колонке можно поставить руками.
|
||||
- **Р-7** (шаг 8, «Подтверждение проверкой») — **да:** сошедшееся жёсткое соотношение §7 снимает подсветку и с
|
||||
`is_recognized = false`; такой документ может получить `done` и автоподтвердиться, сырой текст остаётся в подсказке
|
||||
ячейки. Оговорки по ревью: соотношение должно быть посчитанным, а не «0 = 0»; для членов по модулю знак
|
||||
подтверждается конвенцией колонки, а не соотношением; правило знака — только для сомнительных ячеек. Варианты
|
||||
«всегда подсвечивать» и «снимать подсветку, но не автоподтверждать» отвергнуты: первый возвращает ложные подсветки
|
||||
AURORA-1524, второй даёт статус «требует проверки» без подсветок.
|
||||
- **Р-8** (шаг 3) — расшифровки без кода («в том числе …») отдаются сервисами со статусом `detail` и хранятся ядром; в
|
||||
проверки, агрегаты и `done` не входят; экран по умолчанию их не показывает (переключатель, как у пустых строк
|
||||
детализации). Отменяет OQ-36 / OQ-141.
|
||||
- **Р-9** (шаг 9) — контракт **v2.0**, а не аддитивный 1.3; ядро на время перехода принимает 1.x и 2.0; порядок
|
||||
выкладки — сначала ядро.
|
||||
- **Р-10** (ядро) — снять уникальность `document` по `(stored_file_id, document_type, osv_account_code)` для форм
|
||||
ББ / ОФР, вторая форма того же типа в файле хранится; в ту же миграцию DBML v5.19 — `crm_id` ГСК / ГСЗ из AURORA-1619
|
||||
(arm-core#112).
|
||||
- **Р-11** (принцип 5) — буквы-двойники заменяются и в суммах, и в кодах; результат — `is_recognized = false` у суммы и
|
||||
неуверенный код, дальше их проверяют контрольные соотношения §7 и структура, и если всё сошлось, подсвечивать не
|
||||
нужно (Р-7 с оговорками). Отменяет §11.1 п. 1 «только даты» и решение № 22 среза 3a. ИНН двойники не трогают.
|
||||
Данные: в четырёх json ИС двойников в суммах и кодах нет вовсе — правило на вырост, не под текущие сканы.
|
||||
- **Р-12** (шаг 8, «Код строки») — структурное подтверждение кода применяется только к неуверенным кодам
|
||||
(уверенность ниже 100 или замена двойника); у Excel и чистых OCR-кодов расхождение с перечнем даёт `info` от ядра,
|
||||
не `needs_check`.
|
||||
- **Р-13** (подтверждение документа) — подтверждение документа блокируется (409 `LINES_UNRESOLVED`), пока есть строки
|
||||
`unreadable` / `missing` с суммами; аналитик назначает код или отмечает строку расшифровкой в режиме правки, через
|
||||
`PATCH …/statement lines` (детали — в «Что уходит из ядра»).
|
||||
- **Р-14** (экран) — раскладка по коду «как на бумаге» остаётся (просьба владельца 09.10.2026); строки со статусами
|
||||
ставятся под ближайшей предыдущей размещённой строкой по `row_index`, расшифровки под родителем и скрыты по
|
||||
умолчанию; переключатель «как в файле» — не в этой работе.
|
||||
- **Р-15** (ядро) — вторая форма того же типа и периода у одного ЮЛ из того же файла хранится; склейка половинок формы
|
||||
из одного файла не предоставляется (файл перезагружают), вопрос — в `07-open-questions.md`. От ИС Сканирование
|
||||
такой случай не ожидается (владелец), основной источник — Excel с листом-продолжением другого типа или двойной
|
||||
печатью. Уточнено Р-18.
|
||||
- **Р-16** (шаг 8, «Код строки»; ядро) — защита от подмены неуверенного кода на соседний: повтор кода, у которого хотя
|
||||
бы одна копия неуверенна, — `needs_review` у этой строки (сервис); для строк с неуверенным кодом условие
|
||||
`RECOGNIZED` в ядре включает совпадение распознанного наименования со справочным по коду, расхождение подсвечивает
|
||||
строку. Закрывает открытый вопрос о сопоставлении наименований: для неуверенных кодов — условие автоподтверждения,
|
||||
для остальных — только показ справочного имени.
|
||||
- **Р-17** (шаг 3, п. 2) — разлепленные строки OQ-156 не держат документ в `needs_check` безусловно (решение 08.10.2026
|
||||
заменяется): их значения нуждаются в покрытии, как грязные, и при сошедшихся соотношениях §7 не подсвечиваются;
|
||||
само разлепление — предупреждение `info`.
|
||||
- **Р-18** (ядро) — при двух и более формах одного типа и периода в одном файле ни одна не автоподтверждается;
|
||||
подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` только внутри файла (между файлами работает
|
||||
автозамена §12 п. 3).
|
||||
- **Р-19** (ядро) — `RECOGNIZED` не зависит от статуса конверта: достаточно `document.status = done` и отсутствия
|
||||
предупреждений `error` уровня файла (потерянный json или таблица ИС блокируют, `needs_check` соседнего документа
|
||||
из-за одной ячейки — нет).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Решения агента 17.10.2026, принятые при оформлении целевой картины без отдельного обсуждения с владельцем; все
|
||||
обратимы правкой спеки или словаря.
|
||||
|
||||
1. **Уровни предупреждений** (шаг 8, п. 2): `error` — только то, что меняет данные или их полноту; полный список — в
|
||||
шаге 8 п. 2, таблица «какая правка какое `error` снимает» — в «Что уходит из ядра». Цена ошибки: лишний или
|
||||
пропущенный `needs_check`, правится таблицей уровней в спеке.
|
||||
2. **`DISCLOSURE` всегда `done`** без условий о раскладке, строках и проверках; по `type_hint` шаги 2–7 не
|
||||
выполняются, по кодам прочих форм строки отбрасываются — сохранено текущее поведение парсера. Цена: пояснения и
|
||||
формы 3, 4, 6 не проверяются; так и сейчас.
|
||||
3. **Столбец кодов** — подпись «Код» принимается при доле кодов под ней ≥ 0,6 или кодах ≥ 3, при нескольких
|
||||
кандидатах — больше кодов; запасной подсчёт «кодов ≥ 3 и доля ≥ 0,6» взят у парсера; сегмент не первый с кодами
|
||||
< 3 — ослабленный поиск по диапазону столбца первого сегмента (как `relaxed` парсера). Цена: таблица без подписи и
|
||||
с одним-двумя кодами в первом сегменте → документ без строк с `needs_check`.
|
||||
4. **Диапазоны кодов** — явные отрезки по формам (шаг 2), объединение для поиска столбца, «диапазон столбца» для
|
||||
разлепления и продолжений; границы форм 3, 4, 6 уточняются в спеке. Цена: код вне отрезка не распознаётся как код;
|
||||
правится словарём.
|
||||
5. **Начальный словарь исключений подписей** («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») — по тестам
|
||||
парсера; пополняется словарём, не кодом.
|
||||
6. **Грамматика `locator`** — пары `ключ:значение` через `;`, ключи по источнику, как сейчас. Цена: нулевая.
|
||||
7. **Грамматика `ref`** — OCR `row:<row_index>[#<k>]`, Excel `<лист>!<A1>`; `ref` строки уникален в документе.
|
||||
8. **`value`, который не число** (дата, логическое) в колонке сумм — `amount = null`, `string_value` = текст,
|
||||
`is_recognized = false`. Цена: минимальная.
|
||||
9. **Предел и квантование суммы** — 16 целых цифр, 2 знака, half-up — только для числового `value` Excel; текст OCR с
|
||||
тремя и более знаками после разделителя остаётся `null` + `string_value` (решение № 30 среза 3a); «догадка из цифр»
|
||||
длиннее 16 — `null`.
|
||||
10. **`parse_amount` знает источник**: запятая и точка у Excel — десятичные, у OCR — тысячи при группах из трёх цифр.
|
||||
Единственное место, где шаги 2–8 смотрят на `source`. Разбор ОСВ не затрагивается.
|
||||
11. **`checks` отсутствует у документа без строк**, обязателен у `BALANCE` / `INCOME` со строками.
|
||||
12. **Правило `detail`**: строка без кода с содержимым под строкой с кодом (родитель продолжается через границу
|
||||
сегмента); ячейка кода без единой цифры — пустая. Цена: строка формы с потерянным кодом под другой строкой
|
||||
скрывается до нарушения §7 у родителя (цель отсутствует — нарушение); лечится назначением кода (Р-13).
|
||||
13. **Посчитанное соотношение** — непуста цель или хотя бы один член; пустая цель при непустых членах и непустая
|
||||
цель при пустых членах — нарушение; сегодня соотношение с отсутствующей целью пропускается у обоих сервисов и
|
||||
ядра. Цена: формы без подытогов — только с упрощённым набором соотношений, иначе `needs_check`, как и сегодня.
|
||||
14. **Упрощённая форма** определяется только по структуре кодов (ББ: есть 1600, нет 1100 и 1200; ОФР: есть 2400 или
|
||||
2110, нет 2100, 2200, 2300) и получает свой набор соотношений и перечень кодов; ОКУД 0710096 признаком не служит.
|
||||
Цена: ошибка определения → не те соотношения → `needs_check`; образца упрощённой формы в fixtures пока нет.
|
||||
15. **Конвенция знака** для членов `abs_terms`: форма записи у большинства членов `abs_terms` колонки с непустым
|
||||
`amount`, `is_recognized = true` и без правки; проверяется только у ячеек, нуждающихся в покрытии. Цена: ОФР, где
|
||||
сомнительные расходы записаны не так, как остальные, подсвечивает их; это и нужно.
|
||||
16. **Подтверждение даты отчёта структурой**: неуверенная дата отчёта подтверждается совпадением с самой поздней
|
||||
датой колонок, неуверенная подпись колонки — попаданием в ожидаемые даты; иначе `error`. Цена: скан с верной, но
|
||||
неуверенной датой без колонок с датами — `needs_check`, снимается правкой даты.
|
||||
17. **Хранить `needs_review`, `text`, подтверждения, нарушения и предупреждения** в ядре (`financial_statement_value`,
|
||||
`financial_statement_line`, `financial_statement_meta`, `document_issue`) вместо пересчёта на каждом GET —
|
||||
пересматривает решение v5.13 «считается, не хранится». Цена: ещё одна таблица и миграция; взамен ядро при чтении
|
||||
ничего не считает, а подтверждения аналитика переживают пересчёт.
|
||||
18. **Эффективная дата документа** = дата отчёта, иначе самая поздняя абсолютная дата колонок — та же формула, что
|
||||
`statement_period` ядра; от неё считаются относительные подписи и выбирается редакция перечня кодов.
|
||||
19. **`document_issue`**: очищается при перезагрузке файла, записи `relation` / `soft` заменяются целиком при
|
||||
пересчёте, `line_id` — список; `error` сервиса снимаются только правкой из таблицы соответствия.
|
||||
20. **Границы пакета `arm_statements`**: свои типы входа и выхода, конвертация в модель контракта на стороне хоста,
|
||||
golden внутри пакета на синтетике, тесты на реальных фикстурах — только в сервисах; расхождение версий копий —
|
||||
`info`.
|
||||
21. **Алгоритм структурного подтверждения кода**: наибольшая подпоследовательность по порядку бланка, редакция с
|
||||
длиннейшей, пересечение при неоднозначности, уверенные коды — якоря, пятизначные — через родителя. Цена: при
|
||||
двух переставленных неуверенных соседях оба `needs_review`; это и нужно.
|
||||
22. **Слияние шаблонной строки с назначенным кодом**: шаблонная строка с суммами аналитика — 409 `LINE_EXISTS`, без
|
||||
сумм — заменяется настоящей. Цена: лишний шаг у аналитика в редком случае; двойного счёта нет.
|
||||
23. **Рабочие имена** `FormCandidate`, `TableLayout`, `RawLine`, `needs_review`, `line_status`, `line_code_text`,
|
||||
`row_index`, `source_ref`, `parent_ref`, `period_kind`, `form_variant`, `document_issue`, `statements_version`,
|
||||
`is_confirmed`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `arm-statements`, `sync-statements` — по образцу существующих
|
||||
(`sync-web.sh`); меняются при написании спеки без последствий.
|
||||
|
||||
@ -0,0 +1,220 @@
|
||||
# Финальное ревью design.md вслепую — третий раунд (17.10.2026, Opus)
|
||||
|
||||
Ревьюер: отдельный агент (модель Opus), без права правки и без доступа к отчётам предыдущих раундов; получил
|
||||
design.md после коммита e6400bf, правила, контракт, DBML, фикстуры и код; прогонял текущий парсер на Excel-фикстурах.
|
||||
Маркеры находок — H-n.
|
||||
|
||||
Сверка перед передачей владельцу (сессия): подтверждены по коду и правилам H-1 (= W-1; манифест парсера, строки 11,
|
||||
24, 32), H-2 (`document.py:203-206` — шлюз требует уверенность 100 у ИНН и организации и распознанную дату),
|
||||
H-8 (`result_mapping.py` R-1: первый документ при повторе типа — `NEEDS_CHECK`), H-10 (§11.1 п. 4, строка 1006:
|
||||
«документ `needs_check`: разбивку подтверждает аналитик», решение владельца 08.10.2026), H-16 (= W-12;
|
||||
`subline_parents`: «те же три цифры»). Пересечения с отчётом Sonnet: H-1 = W-1, H-5 = W-9, H-9 = W-10, H-11б = W-2,
|
||||
H-11в = W-11, H-13б = W-5, H-16 = W-12, H-18 = W-14.
|
||||
|
||||
Отчёт агента ниже без сокращений.
|
||||
|
||||
---
|
||||
|
||||
# Ревью дизайна unified-statement-normalization (свежий взгляд)
|
||||
|
||||
Документ: `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/design.md`. Номера строк дизайна ниже приблизительные (±2).
|
||||
|
||||
## Critical
|
||||
|
||||
**H-1 (critical). Лист только с активом снова получает `done` и автоподтверждается.**
|
||||
- Где: шаг 7, правило «соотношение посчитано, только если хотя бы один член непуст» (F-2, ~стр. 554); «Подтверждение проверкой».
|
||||
- Дыра. Членом считается только правая часть: так прямо сказано по аналогии с AURORA-1527 и так же используется в правиле G-2. Правило `1600 = 1700` при отсутствующей строке 1700 больше не считается.
|
||||
- Сценарий. `unagrande-co/2024-FY/Б.Б Лист 1.xls` — в манифесте парсера `needs_check` «только актив: 1600 = 1700 не сходится» (`tabular-parser/api/tests/statements_manifest.yaml:8-11`). Я прогнал парсер:
|
||||
- 1100, 1200, 1600 сходятся (1200 — в пределах ±1);
|
||||
- 1700, 1300, 1400 и 1500 в файле нет;
|
||||
- по новым правилам `1600 = 1700` не считается, жёстких нарушений нет;
|
||||
- организация, ИНН и период есть, коды Excel структурно не проверяются → `done` → в ядре `RECOGNIZED` → автоподтверждение половины баланса.
|
||||
- То же у `unagrande-group/*/ББ. Лист 1*.xls`.
|
||||
- Сегодня правило проверяется при присутствующей цели, отсутствующие члены = 0 (`tabular-parser/api/src/parser/statements/checks.py:121-127`; ядро `arm-core/api/src/domain/statement_checks.py:200-205`).
|
||||
- Как закрыть: считать цель членом при проверке «посчитано» (пропускать только случай «все пусты, включая цель»). Добавить в обязательные тесты общего кода строки манифеста со статусом `needs_check` — они должны остаться `needs_check`.
|
||||
|
||||
**H-2 (critical). Уверенность шапки выпала из критерия `done`: неверно прочитанная дата отчёта даёт неверный период и автоподтверждение.**
|
||||
- Где: шаг 8 п. 1 (критерий `done`) и п. 2 (уровни предупреждений); «Подтверждение проверкой» — фраза «Не покрыто проверками и остаётся на уверенности: поля шапки» (~стр. 616); шаг 9 (список изменений контракта); шаг 6 («`confidence` → `*_confidence`»).
|
||||
- Дыра. Механизма для полей шапки нет:
|
||||
- `needs_review` есть только у значений и строк;
|
||||
- в критерии `done` уверенности шапки нет;
|
||||
- в уровнях есть только «ИНН не найден или неуверен при найденной организации» (`info`) и «дата не разобрана» (`info`); случая «дата разобрана, но уверенность < 100» нет вовсе;
|
||||
- в контракте v2.0 не добавлены `report_date_confidence`, `units_confidence`, `*_text`: шаг 6 обещает их, шаг 9 — нет.
|
||||
- Сегодня шлюз требует 100 у ИНН и организации и «распознана» у даты (`scan-gateway/api/src/normalizer/ocr/document.py:202-208`, `ocr/__init__.py:79-84,103`). Ядро требует, чтобы конверт был `done`.
|
||||
- Сценарий. В шапке ИС ИНН и организация с уверенностью 100, а «30 сентября 2025» прочитано как «30 сентября 2024» с уверенностью 71. Таблица чистая.
|
||||
- Эффективная дата = дата отчёта (она приоритетнее колонок), период 3 кв. 2024.
|
||||
- Относительные колонки тоже считаются от неё.
|
||||
- Итог: `done` → автоподтверждение и привязка к версиям 3 кв. 2024.
|
||||
- Автозамена (§9.5 п. 3) вытесняет чужой файл того же ключа, в том числе с подтверждённым документом.
|
||||
- В фикстурах дата шапки часто мусор: aep_3 «2|0|2|4|»@75, у aep_6 и «БО 1 кв» в поле даты — название организации с уверенностью 100.
|
||||
- Как закрыть:
|
||||
- ввести признак «требует проверки» для полей шапки (как `needs_review`);
|
||||
- дату отчёта с уверенностью < 100 подтверждать структурой: совпала с самой поздней абсолютной датой колонок — подтверждена, иначе `error`;
|
||||
- то же для подписей колонок OCR с уверенностью < 100;
|
||||
- добавить поля уверенности шапки в список изменений v2.0.
|
||||
|
||||
**H-3 (critical). В сегменте без шапки роль `title` поглощает строки с суммами — данные теряются молча, `missing` недостижим.**
|
||||
- Где: шаг 2, определение ролей (`title` — «а без заголовка — выше первой строки с кодом», ~стр. 410) и п. 4; шаг 3, определение `missing` («между заголовком сегмента и первой строкой с кодом … потерян в самом начале таблицы», ~стр. 450).
|
||||
- Дыра. Для сегмента без шапки всё выше первой строки с кодом — `title`, и в данные оно не попадает. Поэтому строка с потерянным кодом в начале такой таблицы исчезает. Это противоречит принципу 2 и определению `missing`.
|
||||
- Сценарий 1, aep_3 (в ИС нет строки шапки):
|
||||
- table 0, row 0: «Нематериальные активы», код `'НИ'@0`, сумма `'30127'@60`. Первая строка с кодом — row 1 (`1120`), поэтому row 0 — `title`, НМА пропадает;
|
||||
- table 1, row 0: «Выручка», код пуст, `'211(1 |'`. Это выше `2120`, тоже `title`.
|
||||
- Сегодня обе строки хотя бы считаются в «строк без кода с суммами пропущено» (golden `fixtures/ocr/expected_normalize/aep_3.json`). Теперь даже счётчика нет, Р-13 не блокирует подтверждение, ядро достраивает пустую шаблонную 1110.
|
||||
- Сценарий 2, Excel. Лист-продолжение без шапки, первая строка — расшифровка без кода с суммой. В НПЗ такие строки есть: «Депозитные счета» на Листе 3, «Авансы по инвестиционной деятельности» на Листе 1. Если разрыв страницы придётся на них, строка становится `title` и пропадает.
|
||||
- Как закрыть: `title` — только выше строки заголовка (и титульный блок первого сегмента Excel). В сегменте без шапки строки выше первого кода, где в колонках значений есть содержимое: `missing`, а при непустой ячейке кода — `unreadable`.
|
||||
|
||||
**H-4 (critical). Структурная проверка кода не ловит подмену на соседний или незапечатанный код — автоподтверждение с неверной разноской.**
|
||||
- Где: «Код и наименование строки» (LIS по порядку бланка, «Равные соседние коды допустимы (Р-4)»), Р-5, Р-12. Дизайн сам приводит мотив: «перепутанные соседи 1220 и 1230 дают ту же сумму раздела».
|
||||
- Дыра. LIS ловит только нарушение порядка. Есть два пути мимо:
|
||||
- **(а) дубль.** OCR прочитал 1230 как 1220@50. Последовательность 1210, 1220, 1220, 1240 допустима: равные соседи разрешены Р-4. Сумма раздела в `1200 = …` та же → покрыто → `done`. Итог: 1220 = НДС + ДЗ, 1230 пуст. Такой дубль неотличим от законного: в «Бухгалтерская отчетность 1 кв.25.json» row 28 — настоящая вторая строка `1220@50` «НДС, начисленный по отгрузке».
|
||||
- **(б) подмена на незапечатанный код того же раздела.** 66н позволяет не печатать пустые строки; в том же файле 1120 нет: 1105, 1110, 1130. Чтение 1110 как 1120@50 даёт порядок 1105, 1120, 1130 — согласован. Сумма 1100 та же → `done` с НМА в строке «Результаты исследований».
|
||||
- Как закрыть: решение владельца, варианты:
|
||||
- для кода с уверенностью < 100 сделать условием `RECOGNIZED` в ядре совпадение распознанного наименования со справочным по коду (открытый вопрос ядра перестаёт быть открытым — это знание ядра, как квартал);
|
||||
- либо дубль с уверенностью < 100 и подмена в «дыру» бланка → `needs_review`.
|
||||
- В любом случае признать риск в тексте: сейчас текст обещает защиту, которой нет.
|
||||
|
||||
## Important
|
||||
|
||||
**H-5 (important). Диапазоны кодов противоречат сами себе и молча расширены.**
|
||||
- Где: шаг 2 п. 1 («1xxx — ББ, 2xxx — ОФР, 3xxx–6xxx …; `type_hint` неизвестен — объединение»), пункт про tabular-parser («диапазон формы → объединение»), шаг 3 п. 2 (G-21: «"44 30\nI |" aep_6 не множится»), шаг 6 п. 1 (тип по большинству кодов).
|
||||
- Дыра:
|
||||
- Если `type_hint` известен и берётся только его диапазон, то в OCR-таблице с `label` «ББ» и кодами 2xxx колонка кодов не найдётся. Сверка «тип по кодам» в шаге 6 станет недостижимой, а сегодняшнее поведение шлюза #30 (единодушные коды) потеряется.
|
||||
- Если всегда объединение, то «4430» — код 4xxx, и пример G-21 неверен: строка разлепится. Строка aep_6 row 90 `'4 400\nI #4У'` даст строку `ok` с кодом 4400 в балансе.
|
||||
- Расширение до 1000–6999 по сравнению с сегодняшними 1100–1799 / 2100–2999 / 5000–5999 (`tabular-parser/api/rules/statement_headers.yaml:86-88`) делает кодами годы 2000–2099 («2025» титульных блоков) и 4-значные суммы на 1–6.
|
||||
- Как закрыть: явно задать «кодовые диапазоны» (объединение узких диапазонов, как сейчас, плюс 3xxx/4xxx/6xxx по точным границам форм) и «диапазон формы» (для наследования и слабых продолжений). Исправить пример G-21.
|
||||
|
||||
**H-6 (important). Неясно, на каком уровне выдаётся `error` «строка заголовка не найдена»: если на сегмент — НПЗ теряет `done`.**
|
||||
- Где: шаг 2 п. 4 и п. 2.
|
||||
- Сценарий. Книга НПЗ сегодня `done` (оба документа). У листов «Бухгалтерский баланс. Лист 2/3/5» и «Отчет о финансовых результата_2» шапки нет: я выгрузил ячейки. Если предупреждение п. 4 выдаётся на каждый сегмент — документ `needs_check`, регрессия.
|
||||
- Дополнительно. Колонки сегмента без шапки «по числам» сопоставляются с первым сегментом по порядку. Если на листе-продолжении средняя колонка пустая или в прочерках, найдутся две колонки, и значения 2023 окажутся в колонке 2024. Документ `needs_check`, но в БД данные лягут неверно. Сегодня `join_blocker` такой лист не присоединяет (`tabular-parser/api/src/parser/statements/merge.py:39-40`).
|
||||
- Как закрыть: `error` — только если заголовка нет ни у одного сегмента документа. Колонки сегмента без шапки сопоставлять по положению относительно столбца кода или по колонкам первого сегмента; при несовпадении значения сегмента не раскладывать.
|
||||
|
||||
**H-7 (important). Сборка листов до раскладки снимает защиту по числу колонок.**
|
||||
- Где: шаг 1 п. 6 («по маркеру или по позиции листа»; «проверка числа колонок переезжает в шаг 2 п. 2»).
|
||||
- Сценарий. После формы идёт лист без маркера и заголовка с 3+ кодами того же диапазона, например расшифровка или форма другого периода, с другим числом колонок.
|
||||
- Сегодня это отдельное «одинокое продолжение».
|
||||
- По дизайну он вливается сегментом. Его коды дублируют коды формы и по Р-4 суммируются. §7 падает, но разделить данные аналитик может только пометкой каждой строки как `detail`.
|
||||
- Как закрыть: оставить несовпадение числа колонок условием отказа в склейке (продолжение становится отдельным кандидатом с `error`).
|
||||
|
||||
**H-8 (important). Р-15 автоподтверждает первую форму, а сегодня первая — `NEEDS_CHECK`; аналитик может оказаться в тупике.**
|
||||
- Где: Р-15, «Что уходит из ядра» (`statement_status`, «Подтверждение документа»).
|
||||
- Дыра:
|
||||
- Сегодня при второй форме того же типа первая получает `NEEDS_CHECK` (`arm-core/api/src/services/result_mapping.py:352,364`, R-1/OQ-77).
|
||||
- Дизайн запрещает автоподтверждение только второй. Первая может автоподтвердиться, а подтверждение второй — 409.
|
||||
- Если неверна первая (двойная печать, половинка), выхода нет: `CONFIRMED` только для чтения, повторная загрузка того же файла отсекается дедупом по sha256, удаление часто запрещено (§9.5 п. 5).
|
||||
- Неоднозначно и «у ЮЛ» в 409: только тот же файл или любые файлы ЮЛ (после правки даты отчёта)?
|
||||
- Как закрыть: при двух и более формах одного типа и периода в файле не автоподтверждать ни одну. Уточнить область 409.
|
||||
|
||||
**H-9 (important). Противоречие: OCR-таблица без колонки кодов с неопознанным `label` пропадает с `info`.**
|
||||
- Где: шаг 6 п. 1 («`type_hint` нет → не документ, `info`») и список `info` в шаге 8 п. 2 («не документ без `type_hint` и кодов») против списка `error` («таблица или сегмент с содержимым без колонки кодов»).
|
||||
- Сценарий. Плохой скан: `label` ОФР зашумлён (не сопоставился), колонка кодов не найдена. Таблица — `info`, а соседний ББ `done` → конверт `done` → ББ автоподтверждён, потеря ОФР не видна. Сегодня шлюз выдаёт «нет шапки и столбца кода» → `needs_check` (`scan-gateway/api/src/normalizer/ocr/__init__.py:96-98`).
|
||||
- Как закрыть: для источника `ocr` каждая таблица ИС — кандидат, её потеря — `error`. Для Excel «прочее» — `info`.
|
||||
|
||||
**H-10 (important). Разлепление склеенных строк молча меняет решение владельца по OQ-156.**
|
||||
- Где: шаг 3 п. 2; таблица уровней шага 8 п. 2.
|
||||
- Дыра. В §11.1 п. 4 по решению владельца 08.10.2026: «документ `needs_check`: разбивку подтверждает аналитик». В таблице уровней разлепления нет. В «Бухгалтерская отчетность 1 кв.25» строка `1500\n1700` по суммам сходится, и документ может стать `done` и автоподтвердиться. Решение не вынесено ни в Р-n, ни в «Decisions made without discussion».
|
||||
- Как закрыть: явно указать уровень и записать в решения.
|
||||
|
||||
**H-11 (important). Пересчёт ядра после PATCH: устаревшие `error` сервиса и подтверждения, которые нигде не хранятся.**
|
||||
- Где: принцип 3; «Подтверждение проверкой» («предупреждения `error` документа сохраняются и после правки»); PATCH; DBML v5.19.
|
||||
- Дыры:
|
||||
- **(а)** Из `error` сервиса правкой снимается только «колонка без даты». После PATCH `report_date` остаются «период не определён» и «дата не разобрана», после PATCH `inn` — «ни организации, ни ИНН». Статус после правки навсегда `NEEDS_CHECK`, а экран показывает уже исправленные причины.
|
||||
- **(б)** `values[].confirmed` негде хранить: в списке изменений v5.19 у `financial_statement_value` нет признака подтверждения. На следующем пересчёте `needs_review` вычисляется заново из уверенности и §7, и подтверждение пропадает (G-12).
|
||||
- **(в)** Нет способа подтвердить верный код с низкой уверенностью без смены. Переход `ok → ok` описан только «с другим кодом», поэтому `needs_review` строки снять нельзя.
|
||||
- Как закрыть: таблица «какое `error` сервиса снимается какой правкой»; колонка `is_confirmed` (или `confirmed_by/at`) у значения и у строки в v5.19; переход «подтвердить код».
|
||||
|
||||
**H-12 (important). Шаблонные строки и новые поля строки.**
|
||||
- Где: PATCH `lines` («шаблонная строка с таким кодом заменяется настоящей»), DBML v5.19.
|
||||
- Сценарий. Код строки НМА потерян (`unreadable`).
|
||||
1. Аналитик вводит суммы в достроенную шаблонную 1110. Ядро создаёт её в БД (`line_code_original_value = NULL`; адресация по `line_code` — `arm-core/api/src/services/statements.py:219-245`).
|
||||
2. Потом аналитик назначает код 1110 строке `unreadable`.
|
||||
3. Возможны два исхода: обе строки `ok` и суммируются по Р-4 (двойной счёт), либо «замена» стирает ручные суммы (аудит?).
|
||||
- Также не определены:
|
||||
- `row_index`, `source_ref`, `line_status` для строк, созданных правкой;
|
||||
- заполнение этих полей для существующих строк при миграции;
|
||||
- уникальность `source_ref` при NULL.
|
||||
- `repositories/statements.py:74` ключует вставку по `line_code`.
|
||||
- Как закрыть: правило слияния шаблонной строки (перенос правок или запрет с 409), значения полей для строк из правки, заполнение при миграции.
|
||||
|
||||
**H-13 (important). Упрощённые формы: регрессия ОФР и недостижимое определение.**
|
||||
- Где: шаг 6 п. 1 (`form_variant`), шаг 7, решения 14–15.
|
||||
- Дыры:
|
||||
- **(а)** Правило G-2 «цель отсутствует при непустых членах — жёсткое нарушение» делает упрощённый ОФР (2110, 2120, 2330, 2340, 2350, 2410, 2400 без 2100/2200/2300) вечным `needs_check`. Структурный признак упрощённой формы задан только для баланса. Решение 14 утверждает «как и сегодня», но сегодня цель отсутствует — правило пропускается, и упрощённый ОФР может быть `done`.
|
||||
- **(б)** 0710096 — по моим сведениям КНД ФНС, а не ОКУД печатной формы; на бланке ББ/ОФР стоят 0710001/0710002. Проверить. Кроме того, 0710096 подпадает под шаблон DISCLOSURE словаря ОКУД (`statement_headers.yaml:26`) → `type_hint = DISCLOSURE`, а для DISCLOSURE по подсказке шаги 2–7 не выполняются. До определения варианта дело не дойдёт.
|
||||
- Это частный случай противоречия «окончательно — по кодам строк в шаге 6, у обоих» и «DISCLOSURE по `type_hint` — шаги 2–7 не выполняются».
|
||||
- Как закрыть: структурный признак для ОФР (есть 2400/2110, нет 2100/2200/2300); снять 0710096 или поправить его разбор; оговорить исключение для DISCLOSURE.
|
||||
|
||||
**H-14 (important). Объём контракта v2.0 и перехода не охватывает ОСВ и конверт.**
|
||||
- Где: шаг 9, шаг 8 п. 3.
|
||||
- Дыры:
|
||||
- `metrics.warnings` становится структурой. В том же результате tabular-parser отдаёт `osvDocument` и строковые предупреждения ОСВ; переделка пути ОСВ не описана.
|
||||
- «Конверт `done` ⇔ все документы `done`» — у ОСВ нет `document.status`. Не определено, как агрегируется смешанная книга ОСВ + формы.
|
||||
- При приёме 1.x у документа нет `document.status`, значит нужен откат к старому D-6 (`job_done ∧ checks ∧ values_ok …`). Сказано только про `needs_review`.
|
||||
- ack тоже несёт `schema_version` — ядро должно принимать ack 1.x/2.0 в переходный период.
|
||||
- `to_money` используется и ОСВ (`tabular-parser/api/src/parser/classify.py:183`). Замена его общим `parse_amount` (шаг 4 п. 1) без ограничения областью форм меняет разбор ОСВ: двойники, «лучшая догадка».
|
||||
- Как закрыть: описать в спеке контракта ОСВ-часть и агрегацию; запасной статус для 1.x; ограничить замену `to_money` формами.
|
||||
|
||||
**H-15 (important). xlsb + «тыс. руб.» + дробные значения = вероятный масштаб ×1000, а уровень — `info`.**
|
||||
- Где: шаг 4 п. 4; шаг 8 п. 2 (`info`: «дробные при тыс. руб.», «xlsb без числовых форматов»).
|
||||
- Сценарий. Выгрузка 1С в xlsb хранит рубли с копейками, а в тысячи их переводит формат, который для xlsb не читается. Значения в 1000 раз больше, §7 сходится при любом масштабе → `done` → автоподтверждение в КФО. Это унаследовано (сегодня статус тоже не меняется), но дизайн закрепляет это явно.
|
||||
- Как закрыть: сочетание «xlsb без форматов ∧ доля дробных ≥ порога при тыс.» → `error`.
|
||||
|
||||
## Minor
|
||||
|
||||
**H-16 (minor). Правило родителя пятизначной подстроки описано неверно.**
|
||||
- Дизайн: «ближайшая строка выше с теми же четырьмя первыми цифрами, иначе код из первых четырёх цифр — правило `subline_parents`».
|
||||
- Код: первые **три** цифры (M-9): `arm-core/api/src/domain/statement_checks.py:141-162`, `tabular-parser/.../checks.py:91-96`; §12 п. 7 «12310 под 1230».
|
||||
- По тексту дизайна 12310–12314 НПЗ (Лист 2) теряют родителя. Для OCR-кодов с уверенностью < 100 это ещё и неподтверждённая структура → `needs_check`; ломаются мягкая проверка и показ.
|
||||
|
||||
**H-17 (minor). Неточности базовой линии.**
|
||||
- Таблица шага 2, строка 1: шлюз ищет столбец кодов по справочнику кодов (`infer_layout`, `scan-gateway/api/src/normalizer/ocr/layout.py:82`), а `int()` — это классификация строк.
|
||||
- G-2 «сегодня пропуск»: верно только для отсутствующей цели. Цель с `null` при непустых членах уже сегодня жёсткое нарушение (null = 0 при присутствующей строке).
|
||||
|
||||
**H-18 (minor). `parse_amount` не доопределён.**
|
||||
- Не указано, как правило «точка — разделитель тысяч» (sg#37) работает для `excel`. Сегодня Excel «1.500» = 1,5, при общем правиле станет 1500.
|
||||
- Не указана судьба текста OCR с более чем двумя знаками после запятой («0,125», решение №30: `amount = null`).
|
||||
- Не определена многострочная сумма в строке с однострочным кодом: склейка «7А 794\n8 У 201» в одну «догадку». Правило «многострочное → `null`» сформулировано только в контексте разлепления.
|
||||
|
||||
**H-19 (minor). Р-2 и шаг 8 расходятся для пустого текста с уверенностью 0.** aep_3 table 1 row 15: «пустая — без подсветки» или «непрочитанная — `needs_review`»?
|
||||
|
||||
**H-20 (minor). Строки без кода, где в суммах только мусор, выбрасываются как «не строка».** aep_3 row 8: код `[190 :`, значения `'Ё'@0`, `' : ।'@0`, `'" :'@33`. Ячейки с непустым текстом и уверенностью < 100 — признак потерянных сумм; по принципу 2 их стоит считать содержимым.
|
||||
|
||||
**H-21 (minor). `missing` в начале нового сегмента или листа.**
|
||||
- Расшифровка, перенесённая через разрыв страницы, получает `missing` вместо `detail` и блокирует подтверждение (Р-13).
|
||||
- Предложение: родитель `detail` продолжается из предыдущего сегмента документа; `missing` — только в первом сегменте.
|
||||
|
||||
**H-22 (minor). Мусор в ячейке кода, в которой нет ни одной цифры.** aep_6 ОФР row 4: «в том числе: Крестьянская, 175», код `'}'@0`, сумма `'по\n(0 215)'`. Строка становится `unreadable` с суммой и блокирует подтверждение, хотя это расшифровка. Стоит решить, считать ли такой код пустым (`detail`/`missing`).
|
||||
|
||||
**H-23 (minor). Р-11 и вертикальные линии таблицы.** «|» вплотную к цифрам в суммах — частый след границы таблицы (aep_3 `'| : 122477'`). Замена на 1 даёт лишнюю цифру: §7 подсветит, но ложных подсветок станет больше. Для сумм крайние «|» убирать, а не заменять.
|
||||
|
||||
**H-24 (minor). Мелкие противоречия и пробелы.**
|
||||
- DISCLOSURE по кодам при `type_hint` BALANCE — одновременно «`error`» (тип противоречит) и «DISCLOSURE всегда `done`».
|
||||
- Снятие уникальности `document` (Р-10) не должно снимать её у ОСВ по счёту и у DISCLOSURE/OTHER — «один на тип на файл», §9.5 п. 2. Нужен частичный уникальный индекс.
|
||||
- Место `LINES_UNRESOLVED` в порядке 409 (§9.5 п. 4) не задано.
|
||||
|
||||
**H-25 (minor). Сцепка через статус конверта (G-7) шире, чем нужно.**
|
||||
- Сценарий: в «Бухгалтерская отчетность 1 кв.25» ОФР — `needs_check` из-за 2400@71, а 2400 есть только в мягких соотношениях. Из-за этого не автоподтверждается и чистый ББ того же файла.
|
||||
- При статусе на документ достаточно условия «`document.status = done` ∧ нет `error` уровня файла».
|
||||
|
||||
**H-26 (minor). Р-1: сопровождение копии.**
|
||||
- Расхождение `statements_version` с копией ядра → `error` при пересчёте. Но предписанный порядок выкладки («сначала ядро») гарантирует расхождение на всё окно выкладки: правленые документы будут массово `NEEDS_CHECK`. Лучше `info` или сравнение хэша словарей.
|
||||
- Копия «со своими тестами и конфигом линтеров» попадает под ruff и pytest хоста: конфликты правил, `testpaths`, `conftest`.
|
||||
- Срочное исправление требует трёх PR в разное время — нужен описанный порядок.
|
||||
|
||||
## Проверено и признано согласованным
|
||||
|
||||
- Р-4 (сумма по коду) на реальных данных верна. «Бухгалтерская отчетность 1 кв.25»: 1220 = 19 062 + 9 273 сходится с 1200 = 1 677 325; 1520 = 771 605 + 89 221 сходится с 1500 = 917 775. Сегодняшнее «оставлена первая» здесь даёт ложное нарушение.
|
||||
- `detail` по Р-8 на НПЗ («Авансы…», «Депозитные счета») и в «1 кв.25» («в том числе…») сохраняет `done`, если строки стоят под строкой с кодом в том же сегменте.
|
||||
- Защита от титульного блока «Коды» (V-1/G-5) на НПЗ (кол. 23 против кол. 13) и Межениновской (кол. 16 против кол. 13) работает: под титульной подписью 0 кодов. Роль `title` выше строки заголовка корректно убирает «Дата 30 | 9 | 2025».
|
||||
- Конвенция знака (F-1) на ОФР «1 кв.25» и aep_3: «(7744536}» → минус, «{38378)» → подсветка. Ведёт себя, как описано.
|
||||
- Р-7 при посчитанных соотношениях: подтверждение `is_recognized = false` требует компенсирующей ошибки — согласовано с Р-5 и V-13.
|
||||
- Межформенная проверка, `_line_amount` и `statement_amounts` перечислены в F-15. Привязка к версии (`consolidation_documents._key` + «самый поздний `confirmed_at`») согласуется с 409 по Р-15.
|
||||
|
||||
## Что смотрел
|
||||
|
||||
- Дизайн целиком; `05-domain-rules.md` §7, §9.2–9.5, §11, §11.1, §12; контракт (`$comment`, `statementDocument`, `metrics`, `service`, `status`); DBML: `document`, `financial_statement_*`, `ex_snapshot_item`; `03-architecture.md` (контракт и копии словарей).
|
||||
- **scan-gateway:** `normalizer/ocr/__init__.py`, `document.py` (`build_document`, `_resolve_type`, `_columns`, `_choose_edition`, `_value`, `_line`, `_statement_header`), `layout.py`, `rows.py` (`split_merged_row`, `parse_table_rows`, `_count_skipped`), `amounts.py`, `text.py`, `header.py`, `normalizer/rules.py`, `rules/ocr_lookalikes.yaml`, `statement_labels.yaml`.
|
||||
- **tabular-parser:** `statements/columns.py`, `lines.py`, `merge.py`, `statement.py`, `checks.py`, `book.py`, `numbers.py` (`to_money`, `_parse_text`, `quantize_money`), `rules/statement_headers.yaml`, `statement_checks.yaml`, `tests/statements_manifest.yaml`.
|
||||
- **arm-core:** `domain/statement_mapping.py` (`build_statement`, `statement_period`, `statement_status`, `values_ok`), `domain/statement_checks.py` (`run_checks`, `subline_parents`, `cross_form_check`), `services/statements.py` (`form_confirmable`, `_apply_values`, `_resolve_cells`, `_apply_header`), `services/documents.py` (`_check_statement`), `services/result_mapping.py` (OQ-77/R-1), `services/consolidation_documents.py` (`_key`, `bind_confirmed`), `repositories/statements.py`, `web/src/widgets/DocumentReview/statementGrid.ts`.
|
||||
- **Фикстуры:** построчно `fixtures/ocr/aep_3.json`, `aep_6.json`, «БО 1 кв 2025.json», «Бухгалтерская отчетность 1 кв.25.json»; golden-эталоны `expected_normalize/*`. Прогон текущего парсера на `unagrande-co/2024-FY/Б.Б Лист 1.xls`, `Б.Б. Лист 2.xls`, НПЗ, Межениновская Форма1/Форма2; выгрузка ячеек листов НПЗ и Межениновской. Скрипты лежат в `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/runp.py` и `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/dump.py`.
|
||||
- Файлы не правил.
|
||||
@ -0,0 +1,249 @@
|
||||
# Независимое ревью 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}`.
|
||||
@ -0,0 +1,256 @@
|
||||
# Проверка закрытия V-1…V-13 и G-1…G-30 (17.10.2026, третий раунд, Sonnet)
|
||||
|
||||
Ревьюер: отдельный агент (модель Sonnet), без права правки; получил design.md после коммита e6400bf, отчёты
|
||||
`review-2026-10-17-closure.md` (V-n) и `review-2026-10-17-blind.md` (G-n). Маркеры новых проблем — W-n, наблюдения — O-n.
|
||||
|
||||
Отчёт агента ниже без сокращений.
|
||||
|
||||
---
|
||||
|
||||
# Проверка закрытия V-1…V-13 и G-1…G-30 в обновлённом design.md (коммит e6400bf)
|
||||
|
||||
Файлы не правил и ничего не коммитил. Читал design.md целиком (850 строк), оба отчёта ревью и фикстуры. Сценарии из находок прогонял по новым правилам. Данные проверял скриптами `python3 -I`, для xls — venv парсера с `-I`.
|
||||
|
||||
Итог: 43 находки.
|
||||
- `closed` — 31.
|
||||
- `closed, new issue` — 9: V-3, V-13, G-2, G-12, G-16, G-17, G-19, G-25 и ещё G-1 в связке с W-6.
|
||||
- `partial` — 3: V-4, V-12, G-8.
|
||||
- `not closed` — нет.
|
||||
|
||||
Новых противоречий найдено 16 (W-1…W-16). Серьёзные из них:
|
||||
- W-1 — регрессия детектора «только актив», три файла из манифеста.
|
||||
- W-2 — `confirmed` нигде не хранится.
|
||||
- W-4 — наследование столбца кодов ломает лист «_2» НПЗ.
|
||||
- W-3, W-5, W-6, W-9 — средней серьёзности.
|
||||
|
||||
## 1. Таблица
|
||||
|
||||
| Находка | Где закрыто | Статус | Что осталось / новая проблема |
|
||||
|---|---|---|---|
|
||||
| V-1 | Шаг 2 п. 1 (а): подпись принимается при доле кодов ≥ 0,6 или кодах ≥ 3; при нескольких кандидатах — больше кодов; «документ из двух строк» = 2 из 2; решение № 3 | closed | Проверил на данных. Унагранде ББ Лист 1: «Коды» (5,33) даёт 1 код из 9, доля 0,11; «Код» (27,21) — 43 из 43. ОФР Лист 1 3кв.25: «Коды» 1 из 25, «Код» 17 из 17. Титульный столбец отсекается, форма берётся верно |
|
||||
| V-2 | «Код и наименование строки»: пятизначный код подтверждён через четырёхзначного родителя; применяется только к кодам с уверенностью < 100 (Р-12); Excel и чистые OCR-коды дают `info` ядра | closed | Формулировки уточнить в спеке: W-12 (правило родителя), W-13 (границы алгоритма) |
|
||||
| V-3 | Шаг 4 (`values[].text`); DBML v5.19 `source_text`; конвенция знака «от сохранённого `text`» | closed, new issue | W-8: у правленой ячейки `text` устаревает и участвует в голосовании за конвенцию колонки |
|
||||
| V-4 | Шаг 2 п. 2: сегмент без шапки и с кодами < 3 «наследует столбец кодов» предыдущего сегмента | partial | W-4: у НПЗ столбец кодов на каждом листе свой, наследование по индексу не работает |
|
||||
| V-5 | DBML v5.19: `financial_statement_line.needs_review`; PATCH: `line_code_modified` ⇒ `needs_review` строки = false | closed | — |
|
||||
| V-6 | Шаг 8 п. 2: «ИНН не найден при найденной организации» — `info`, «ни организации, ни ИНН» — `error`; «формат не совпал» — `info` | closed | Согласовано с критерием `done` п. 1 и решением № 1. НПЗ ОФР без ИНН остаётся `done` |
|
||||
| V-7 | Р-15; «Что уходит из ядра»: вторая форма не автоподтверждается, подтверждение второй — 409; вопрос о склейке — в `07-open-questions.md` | closed | Решено владельцем. Формулировка 409 без оговорки «из одного файла», но между файлами работает автозамена (§12 п. 3), поэтому на практике это только внутри файла |
|
||||
| V-8 | Шаг 2: роль `title` для всего выше строки заголовка; шаг 3: `missing` только между заголовком и первой строкой с кодом | closed | Межениновская: «Дата 30 \| 9 \| 2025» теперь `title`. Повторная шапка «Коды» (стр. 48 Формы 1) открывает новый сегмент по п. 2, а не `unreadable` |
|
||||
| V-9 | Шаг 3 п. 2: разлепление «до разбора кода»; шаг 2 п. 1: многострочная ячейка — код, если каждая часть код | closed | — |
|
||||
| V-10 | DBML v5.19: `recognition_job.statements_version` | closed | — |
|
||||
| V-11 | Принцип 4, шаг 5 п. 2, «Что уходит из ядра»: словарь ядра хранит только имена, редакции берутся из копии общего кода, `sync-statements --check` | closed | Побочно: коды перечня без имён (1105, 1530, …) дают `info` «не в словаре имён» — безвредно |
|
||||
| V-12 | PATCH `lines`: переходы, 422 для пары «`ok` без кода», пересчёт родителя `detail`, аудит | partial | W-11: не сказано про `line_code_confidence` после назначения; у строк нет аналога `confirmed` |
|
||||
| V-13 | «Подтверждение проверкой»: чистые значения в покрытии не нуждаются | closed, new issue | W-7: фраза «ни одним правилом ниже не подсвечиваются» противоречит следующему пункту про непройденное жёсткое соотношение |
|
||||
| G-1 | Р-12 + «Код и наименование строки»: источник перечня — коды шаблонов банка плюс коды печатных форм 1С; пятизначные через родителя; для Excel — `info` | closed | Перечень всегда неполон: в `ББ. Лист 1` (unagrande-group) есть 1231, которой нет ни в словаре ядра, ни в названных 1С-кодах. Для Excel и чистого OCR это `info`, для кодов с уверенностью < 100 — `needs_review`, что принято. Связанная дыра — W-6 |
|
||||
| G-2 | Шаг 7: «цель отсутствует/`null`, а хотя бы один член непуст — жёсткое нарушение» | closed, new issue | W-1: симметричный случай (цель есть, все члены пусты) не считается — три фикстуры «только актив» станут `done`. O-2: остаточный риск для строк вне §7 |
|
||||
| G-3 | То же, что V-3: `values[].text` в контракте v2.0, `source_text` в DBML, `string_value` в прежнем смысле | closed | Семантика `string_value` теперь одна (шаг 4, вводная) |
|
||||
| G-4 | Шаг 2 п. 3: словарь периодов объединяет `statement_periods.yaml` и шаблоны `dates.py`; `test_parses_real_forms` — обязательные тесты | closed | — |
|
||||
| G-5 | То же, что V-1 | closed | — |
|
||||
| G-6 | То же, что V-6 | closed | — |
|
||||
| G-7 | Шаг 8 п. 3 и «Что уходит из ядра»: `RECOGNIZED` ⇔ конверт `done` ∧ `document.status = done` | closed | O-1: `document.status = done` избыточен, так как конверт `done` уже означает «все документы `done`». Прежнее поведение сохранено |
|
||||
| G-8 | Шаг 1 п. 6: слабое продолжение присоединяется, только если коды в диапазоне `type_hint`; иначе отдельный кандидат + `error`; шаг 2 п. 2 | partial | W-4 (наследование); W-10 (отдельный кандидат: «не документ `info`» против `error`) |
|
||||
| G-9 | Решение № 16: `ref` OCR = `row:<row_index>[#<k>]`, Excel = `<лист>!<A1>`, уникален в документе | closed | Мелочь, W-15: для `Cell.ref` у OCR позиция без столбца не уникальна на уровне ячейки |
|
||||
| G-10 | Р-13: 409 `LINES_UNRESOLVED` при `unreadable` / `missing` с непустым `amount`; `detail` не попадает в ЕХ | closed | — |
|
||||
| G-11 | DBML v5.19: `line.needs_review`, `recognition_job.statements_version` | closed | Маркера G-11 в тексте нет, но содержание внесено |
|
||||
| G-12 | PATCH `values[].confirmed`: `needs_review` = false, `is_modified` не ставится, аудит | closed, new issue | W-2: флаг подтверждения нигде не хранится, следующий пересчёт его теряет |
|
||||
| G-13 | PATCH `columns: [{column_id, period_date}]`: снимает `error` «колонка без даты», пересчитывает `is_current`; `report_date: null` обнуляет относительные | closed | W-16: исключение из правила F-8 «`error` сохраняются» нужно оформить явным списком |
|
||||
| G-14 | Р-1: пакет владеет своими типами, хост конвертирует; golden пакета на синтетике; `sync-statements --check` в `make check-full` | closed | — |
|
||||
| G-15 | Р-14: раскладка по коду остаётся; строки со статусами — под ближайшей предыдущей размещённой; `detail` скрыты | closed | W-15: не определено место `missing`-строки, у которой нет предыдущей размещённой (aep_3 ОФР, «Выручка» — первая строка таблицы) |
|
||||
| G-16 | Шаг 4 п. 1: `parse_amount(text, source)`, «−» как знак, тонкие пробелы, запятая по источнику | closed, new issue | W-14: квантование тихо округляет OCR-мусор вроде «12,3456»; правило точки-тысяч (sg#37) не названо |
|
||||
| G-17 | Шаг 6 п. 1: `form_variant = simplified` (ОКУД 0710096 или структура), свой набор соотношений и перечень | closed, new issue | W-5: в `statement_headers.yaml` ОКУД 0710096 уже мапится на `DISCLOSURE` |
|
||||
| G-18 | Принцип 5: ИНН — только цифры; шаг 2 п. 1: порядок «пробелы и переносы → двойники → проверка», `\|` → 1 только если результат попадает в диапазон | closed | — |
|
||||
| G-19 | Шаг 6 п. 1: `DISCLOSURE` по `type_hint` — без шагов 2–7; по кодам — шаги 2–5 и отбрасывание строк | closed, new issue | W-9: диапазоны «1xxx / 2xxx / 3xxx–6xxx» шире словарных |
|
||||
| G-20 | Шаг 8 п. 2: уровни; «организация не найдена при найденном ИНН» — `info`, «ни того ни другого» — `error` | closed | — |
|
||||
| G-21 | Шаг 3 п. 2: разлепление, только если хотя бы одна часть — код | closed | При известном `type_hint` «44 30» (=4430) вне диапазона ББ, не множится. При неизвестном 4430 станет кодом из-за расширения диапазонов (W-9) |
|
||||
| G-22 | Шаг 2 п. 1: «краевая пунктуация» («1220 ,» → 1220) | closed | — |
|
||||
| G-23 | PATCH: `report_date: null` обнуляет `period_end` относительных колонок | closed | — |
|
||||
| G-24 | Р-15 | closed | — |
|
||||
| G-25 | `line_refs` — список; `document_issue`: список `line_id`, очистка при перезагрузке, `core`-записи заменяются | closed, new issue | W-3: устаревшие нарушения источника `service` после PATCH |
|
||||
| G-26 | Принцип 3: ядро добавляет только `info` по словарю имён и перечню | closed | — |
|
||||
| G-27 | Утверждение «в трёх из четырёх» в тексте дизайна отсутствует | closed | Маркера нет, исправлять нечего |
|
||||
| G-28 | Шаг 2 п. 1: словарь подписей расширен («Код строки») | closed | — |
|
||||
| G-29 | Шаг 2 п. 7: одно правило столбца наименования | closed | В запасной ветке («ближайшая текстовая левее кода») пропало «кроме Пояснения»; для столбцов левее кода из 66н не критично |
|
||||
| G-30 | PATCH: аудит `RECOGNIZED_DATA_EDIT` с `before / after` по коду, статусу, родителю; назначенный код снимает `needs_review` | closed | — |
|
||||
|
||||
## 2. Разбор незакрытых проблем и новых противоречий
|
||||
|
||||
### W-1 (important). «Посчитано, если хотя бы один член непуст» (F-2) + G-2 теряют детектор односторонней формы
|
||||
|
||||
Где: шаг 7, пункты «цель отсутствует» и «соотношение считается посчитанным».
|
||||
|
||||
В G-2 «член» — это правая часть соотношения, отдельно от цели. Значит, цель есть, а все члены пусты — «не проверяется».
|
||||
|
||||
Сценарий: «ББ. Лист 1» с одним активом. 1600 есть, 1700 нет.
|
||||
- `1600 = 1700`: цель 1600 непуста, член 1700 отсутствует, соотношение не считается.
|
||||
- `1700 = 1300 + 1400 + 1500`: цели нет, членов нет, тоже пропуск.
|
||||
- Остальные соотношения сходятся, org/ИНН и период есть, строки `ok`, значения чистые — получается `done`.
|
||||
|
||||
Сегодня эти файлы `needs_check`, потому что отсутствующий член считается нулём (`tabular-parser/api/src/parser/statements/checks.py` ~122; `arm-core/api/src/domain/statement_checks.py::run_checks`). Манифест `tabular-parser/api/tests/statements_manifest.yaml`: строки 11, 24, 32 — три файла (unagrande-co 2024-FY, unagrande-group 2024-FY и 3кв.25), `failed_relations: ["1600 = 1700"]`. Половина баланса автоподтвердилась бы.
|
||||
|
||||
Предложение: соотношение посчитано, если непуста цель или хотя бы один член; пропуск только когда непусто ничего. Тогда правило «все null — ничего не подтверждает» (F-2) сохраняется, а G-2 становится его зеркалом. Обновить golden и манифест.
|
||||
|
||||
### W-2 (important). `confirmed` (G-12) не хранится, пересчёт его сбросит
|
||||
|
||||
Где: PATCH `values[].confirmed`, «Подтверждение проверкой», список DBML v5.19.
|
||||
|
||||
Для правки есть `is_modified`, для акцепта есть `checks_accepted_*`. Для `confirmed` в v5.19 колонки нет: ни флага, ни `confirmed_by/at`, только `audit_event`. Пересчёт общим кодом «после PATCH» применяется ко всем значениям.
|
||||
|
||||
Сценарий: ячейка с уверенностью 71, верная, в строке вне §7. Аналитик подтвердил её, `needs_review` = false. Любой следующий PATCH другой ячейки пересчитывает её обратно: «нуждается в покрытии и не покрыто» даёт true. Правило G-12 не работает дольше одного PATCH.
|
||||
|
||||
Предложение: добавить в `financial_statement_value` признак (`review_confirmed_by/at` или `is_confirmed`) и вход «подтверждено» в общую функцию `needs_review`. Альтернатива: пересчёт монотонен для подтверждённых, но тогда сломанное соотношение должно возвращать true.
|
||||
|
||||
### W-3 (medium). `document_issue`: устаревшие нарушения источника `service`
|
||||
|
||||
Где: «Что уходит из ядра», DBML v5.19.
|
||||
|
||||
Сказано только, что записи источника `core` заменяются при каждом пересчёте. Нарушения §7, пришедшие от сервиса (`kind` relation/soft, `source = service`), после правки значения остаются и показываются как действующие. Рядом появляются новые `core`-записи — дубли и устаревшее.
|
||||
|
||||
Предложение: при пересчёте после PATCH заменять все записи `relation` / `soft` независимо от источника. Предупреждения `warning` источника `service` сохранять, кроме явно снимаемых (W-16).
|
||||
|
||||
### W-4 (important). V-4 / G-8: «наследование столбца кодов» не работает на геометрии НПЗ
|
||||
|
||||
Где: шаг 2 п. 2, решение № 3.
|
||||
|
||||
У НПЗ столбец кодов на каждом листе свой. Проверено openpyxl, номера столбцов с 1:
|
||||
- ББ: листы 1–5 — 13, 7, 7, 7, 12.
|
||||
- ОФР: лист 1 — 10, лист `_2` — 6, лист `_3` — 12.
|
||||
- Лист `_2` (единственная строка 2400) шириной 9 столбцов.
|
||||
|
||||
Если «наследуется» значит «тот же индекс, что в предыдущем сегменте» (столбец 10), то в `_2` его просто нет. Получается ошибка сегмента и документ `needs_check` вместо `done`. То же с `st07_single_code_continuation` (там индексы совпадают, но принцип тот же). Прежний `relaxed` (`statement.py` `_continuation`, `min_codes_continuation: 1` в `statement_headers.yaml`) ищет столбец по содержимому, а не наследует индекс.
|
||||
|
||||
Предложение: заменить формулировку на ослабленный поиск для сегмента не первого, без шапки, с кодами < 3: столбец с наибольшим числом кодов из диапазона формы, минимум 1. Оставить предупреждение `error`, если и так не найден. Сам индекс предыдущего сегмента не использовать. Сохранить проверку диапазона `type_hint` (`_weak_blocker`).
|
||||
|
||||
### W-5 (medium). ОКУД 0710096 как признак упрощённой формы конфликтует со словарём типов
|
||||
|
||||
Где: шаг 6 п. 1 (G-17), `evidence`.
|
||||
|
||||
В `tabular-parser/api/rules/statement_headers.yaml` (`okud: DISCLOSURE: '\b07100(0(3|…|9)|(1|…|9)\d)\b'`) значение 0710096 попадает в `DISCLOSURE`. По шагу 1 п. 4 `type_hint` = `DISCLOSURE`, коды строк 1xxx → по шагу 6 это «противоречие» → тип по кодам + `error` → `needs_check`. Пока словарь не правится, упрощённая форма с этим ОКУД никогда не будет `done`, как бы ни работал вариант G-17.
|
||||
|
||||
В фикстурах 0710096 нет вообще (только в `admin/source/СУЖЦ…xls`). Утверждение, что упрощённые ББ/ОФР несут именно этот ОКУД, ничем не подтверждено. По памяти упрощённые бланки 66н идут с обычными 0710001/0710002, а 0710096 относится к комплекту отчётности. Это надо подтвердить на образце или у владельца; сам я не верифицировал.
|
||||
|
||||
Предложение: либо убрать ОКУД из признаков упрощённой формы (оставить структурный: есть 1600, нет 1100 и 1200), либо явно исключить 0710096 из `DISCLOSURE` в словаре. Образец упрощённой формы — в фикстуры.
|
||||
|
||||
### W-6 (medium). Р-11 vs Р-12: код, исправленный из двойников с уверенностью 100, не проходит ни одной проверки
|
||||
|
||||
Где: принцип 5 / Р-11 и «Код и наименование строки» / Р-12.
|
||||
|
||||
Р-11 велит заменять двойники и в кодах, а для суммы вводит `is_recognized = false`, чтобы её проверил §7. У кода такого флага нет. Структурное подтверждение применяется только при `line_code_confidence < 100`. Код «I140» или «1l50», прочитанный ИС с уверенностью 100, получает `ok` без `needs_review`. §7 перепутанные соседи не ловит (сумма раздела та же), поэтому запись сохраняется без проверки.
|
||||
|
||||
Предложение: считать код после замены двойника «неуверенным» (эффективная уверенность < 100, или признак `code_corrected`) и пропускать через структурную проверку. Это одна строка в «Код и наименование строки».
|
||||
|
||||
### W-7 (minor). Внутреннее противоречие в «Подтверждении проверкой»
|
||||
|
||||
Первый пункт: чистые значения «ни одним правилом ниже не подсвечиваются (V-13)». Пятый пункт: `true` «у всех ячеек жёсткого соотношения … независимо от уверенности». Второе включает чистые Excel-значения, поэтому первое надо сузить: «ни правилом покрытия, ни правилом знака».
|
||||
|
||||
### W-8 (minor). Устаревший `text` правленой ячейки в голосовании за конвенцию знака
|
||||
|
||||
Конвенция — форма записи у большинства членов `abs_terms` с `is_recognized = true`, считается от сохранённого `source_text`. Если аналитик исправил знак у члена, `source_text` прежний, а `amount` новый, и ячейка голосует старой формой. Что делает PATCH с `is_recognized`, не сказано.
|
||||
|
||||
Предложение: исключить из голосования ячейки с `is_modified` (или считать форму от знака `amount`).
|
||||
|
||||
### W-9 (medium). G-19: диапазоны кодов шире словарных
|
||||
|
||||
Где: шаг 2 п. 1, шаг 6 п. 1.
|
||||
|
||||
Словарь парсера: 1100–1799, 2100–2999, 5000–5999 (`tabular-parser/api/rules/statement_headers.yaml`, `line_codes.ranges`). В дизайне «1xxx — ББ, 2xxx — ОФР, 3xxx–6xxx — прочие», при неизвестном `type_hint` объединение.
|
||||
|
||||
Два риска:
|
||||
- Годы в шапках (2024, 2025) попадают в 2xxx. В НПЗ ОФР в столбце 16 стоит «2025».
|
||||
- Значения в тысячах от 3000 до 6999 считаются кодами, а столбец сумм начинает конкурировать со столбцом кодов за «наибольшее число» (кодов ≥ 3, доля ≥ 0,6 — слабая защита). Для разлепления G-21 «44 30» (=4430) тоже становится кодом, если `type_hint` неизвестен.
|
||||
|
||||
Предложение: в дизайне явно записать числовые диапазоны (оставить 1100–1799, 2100–2999, расширить DISCLOSURE до 3000–6999 только при неизвестном `type_hint` и тестами) вместо «1xxx / 2xxx».
|
||||
|
||||
### W-10 (minor/medium). Одинокий лист-продолжение: «не документ, `info`» против `error`
|
||||
|
||||
Где: шаг 6 п. 1 и шаг 8 п. 2.
|
||||
|
||||
Лист «Справка» с кодом 1370 после ОФР не присоединяется (шаг 1 п. 6) и становится отдельным кандидатом. У него кодов < 3 и `type_hint` нет. Шаг 6 п. 1 говорит «не документ, `info`», шаг 8 п. 2 — «сегмент с содержимым без колонки кодов (в том числе потерянный лист-продолжение) — `error`». Данные листа теряются, `done` у соседних документов возможен: нарушается принцип 2.
|
||||
|
||||
Предложение: в шаге 6 п. 1 добавить исключение — лист с числами и не присоединившийся продолжением даёт файловое `error` (и строки сохраняются как `unreadable` / `detail`, если возможно).
|
||||
|
||||
### W-11 (minor). Остатки V-12 / G-30
|
||||
|
||||
- После назначения кода (`unreadable` / `missing` / `detail` → `ok`) не сказано, чему равна `line_code_confidence` (100?). Сейчас остаётся прежняя, возможно 25.
|
||||
- У строки есть `needs_review` (структура кода), но нет `lines[].confirmed`. Код с уверенностью 50, стоящий не по порядку, но верный, снимается только повторным назначением того же кода, которое в тексте как «другой код» не описано (аналог G-12 для строк).
|
||||
|
||||
### W-12 (minor). Правило родителя пятизначного кода
|
||||
|
||||
В «Код и наименование строки»: «ближайшая строка выше с теми же четырьмя первыми цифрами, иначе код из первых четырёх цифр — правило `subline_parents`». В реальном коде (`arm-core/api/src/domain/statement_checks.py::subline_parents`) ближайшая четырёхзначная строка выше с теми же тремя цифрами, иначе код из первых четырёх. Первая половина фразы при буквальном чтении матчится на пятизначного соседа (11101 выше 11102). Привести к тексту кода.
|
||||
|
||||
### W-13 (minor). Границы алгоритма подтверждения структурой
|
||||
|
||||
- Наибольшая подпоследовательность не единственна. Для двух поменявшихся соседей (1230, 1220) подтверждается один из двух, и не определено, какой остаётся без подтверждения. Если выпавший код имеет уверенность 100, он освобождён от проверки, а второй числится «подтверждённым».
|
||||
- «Хотя бы в одной из проверяемых редакций»: берётся объединение подпоследовательностей двух редакций или одна общая? Правка: выбирать редакцию с наибольшей подпоследовательностью.
|
||||
- Родитель с уверенностью 100 (освобождён) для пятизначного дочернего кода считается «подтверждённым» или нет? Записать.
|
||||
|
||||
### W-14 (minor). OCR: тихое округление и правило точки
|
||||
|
||||
Решение № 13 («2 знака, half-up») в общем `parse_amount` для OCR-текста. Сегодня шлюз отвергает такие значения: `scan-gateway/api/src/normalizer/ocr/document.py:~143-150`, решение № 30: «0,125», «12.3456» — артефакт, `amount = null`, текст в `string_value`. По дизайну «12,3456» станет 12,35, причём `is_recognized` не оговорён. Оставить квантование только для числового `value` (Excel), а для OCR-текста сохранить `null` + `string_value`.
|
||||
|
||||
Правило точки-тысяч (`837.994`, `1234.567` — sg#37, `amounts.py` `_DOT_THOUSANDS_RE`) в шаге 4 п. 1 не названо. Указать, что оно сохраняется для `ocr` и как ведёт себя для `excel`.
|
||||
|
||||
### W-15 (minor). Экран и `ref`
|
||||
|
||||
- Р-14: у `missing`-строки без предыдущей размещённой (aep_3 ОФР, «Выручка» — первая строка таблицы) место не задано. Предложение: в начало первого раздела.
|
||||
- `Cell.ref` у OCR — `row:<row_index>` без столбца: уникально для строки, но не для ячейки. В шаге 1 сказано «уникальная в документе», уточнить, что это `RawLine.ref`.
|
||||
|
||||
### W-16 (minor). Исключения из «`error` сохраняются после правки» (F-8)
|
||||
|
||||
PATCH `columns` снимает `error` «колонка без даты» (G-13). Остальные `error` сервиса (несовпадение колонок сегмента, «строка заголовка не найдена», тип по кодам и др.) сохраняются. Записать явный перечень «какая операция PATCH какое `error` снимает», иначе реализация разойдётся.
|
||||
|
||||
### Наблюдения (не противоречия)
|
||||
|
||||
- O-1. Вторая часть `RECOGNIZED` («`document.status = done`») избыточна при требовании «конверт `done`»: конверт `done` означает «все документы `done`». Один плохой документ блокирует автоподтверждение всех остальных в файле. Так же было и раньше (`job_done` в `statement_status`). Если нужна независимость документов, это отдельное решение.
|
||||
- O-2. Строки вне §7 с утерянным кодом (справочные 25xx / 29xx и подобные) уходят в `detail` без нарушения. Раньше это был счётчик, но любое предупреждение шлюза давало `needs_check`. Теперь такая строка скрыта, а документ может стать `done` и автоподтвердиться. Принято Р-8, но шапка Р-13 ограничена `unreadable` / `missing`, а не `detail`.
|
||||
- O-3. В критерии `done` нет уверенности полей шапки. Шлюз требовал 100 для ИНН и организации. Остаётся сверка ядра (`MISMATCH`). Намеренно ли это ослабление, в дизайне не сказано.
|
||||
|
||||
## 3. Согласованность правок между собой
|
||||
|
||||
Проверил и признал согласованным:
|
||||
- **Р-12, G-1, V-2 и перечень V-11.** Перечень (superset словаря ядра) — в общем коде, имена — только в ядре, `--check` в одну сторону (словарь ⊂ перечень) достаточен. Пятизначные коды, 1105, 1530, 2420, 2460, 2930 покрыты; Excel и чистые OCR-коды дают `info`. Оговорки: W-6 (двойники), W-12 и W-13.
|
||||
- **G-2 + Р-8 + G-17.** Для «БАЛАНС» без кода: `detail` → цель 1600 отсутствует → жёсткое нарушение, подсвечивается родитель. Для сегмента «Лист 2» цель 1600 отсутствует, член 1700 непуст → нарушение, документ `needs_check` (как в golden). Упрощённая форма использует свой набор. Оговорки: W-1 (симметричный случай), W-5 (ОКУД), O-2.
|
||||
- **G-3 и V-3.** `text` и `string_value` с разными ролями; `source_text` в DBML; контракт и DBML соответствуют. Оговорка: W-8.
|
||||
- **V-1 и G-5 «из двух строк».** Для двухстрочного документа 2/2 = 1,0, титульный столбец отсекается: проверено на Унагранде (доли 0,11, 0,12, 0,04).
|
||||
- **Уровни V-6 / G-6 / G-20 и критерий `done`.** Список `error` и `info` согласован между п. 1 и п. 2 шага 8 и решением № 1. НПЗ ОФР без ИНН — `done`.
|
||||
- **Статус конверта в `RECOGNIZED` (G-7).** Согласовано с шагом 8 п. 3. Замечание — O-1.
|
||||
- **PATCH `lines` / `columns` / `confirmed` и аудит.** Один PATCH — один пересчёт, аудит `RECOGNIZED_DATA_EDIT` с `before / after`. Оговорки: W-2, W-11, W-16.
|
||||
- **DBML v5.19 и контракт v2.0.** Проверил соответствие:
|
||||
- `line_status`, `line_code_text`, `row_index`, `ref` → `source_ref`, `parent_ref` → `parent_row_index`, `needs_review` строки и значения;
|
||||
- `values[].text` → `source_text`, `columns[].period_kind`, `form_variant` в meta;
|
||||
- `warnings[]` и `checks` → `document_issue`, `statements_version` → `recognition_job`;
|
||||
- снятие уникальности `document` и `(meta_id, line_code)` плюс порядковый номер формы.
|
||||
Не хватает только флага подтверждения (W-2).
|
||||
- **Раскладка экрана (Р-14).** Совместима с Р-4 (дубли кодов в порядке файла), шаблонными строками и `detail` под родителем. Оговорка — W-15.
|
||||
- **Р-15 и автозамена.** Автозамена между файлами (§12 п. 3) убирает дубли ББ/ОФР между файлами, поэтому Р-15 действует только внутри файла.
|
||||
|
||||
## 4. Что смотрел
|
||||
|
||||
**Дизайн и ревью** (`.wt/normalization/docs/openspec/changes/unified-statement-normalization/`):
|
||||
- `design.md` целиком, включая «Целевую картину», «Решения владельца Р-1…Р-15» и «Decisions made without discussion» 1–19;
|
||||
- `review-2026-10-17-closure.md` (V-1…V-13) и `review-2026-10-17-blind.md` (G-1…G-30) целиком.
|
||||
|
||||
**Правила и модель:**
|
||||
- `docs/context/05-domain-rules.md` §12 п. 3 (автозамена), строки 680–695;
|
||||
- `docs/model/data-model-v5.18.dbml`: `document` (757–780), `recognition_job`, `financial_statement_meta/column/line/value`.
|
||||
|
||||
**tabular-parser:**
|
||||
- `api/rules/statement_checks.yaml`, `statement_headers.yaml` (`okud`, `line_codes`);
|
||||
- `api/src/parser/statements/checks.py` (`run_statement_checks`);
|
||||
- `api/tests/statements_manifest.yaml`;
|
||||
- `CLAUDE.md`.
|
||||
|
||||
**scan-gateway:** `api/src/normalizer/ocr/amounts.py` (`parse_amount`), `document.py` (`_value`).
|
||||
|
||||
**arm-core:**
|
||||
- `api/src/domain/statement_checks.py` (`subline_parents`, `_subline_rules`, `run_checks`);
|
||||
- `api/src/services/statements.py` (PATCH-цикл, пропуск несуществующей ячейки);
|
||||
- `api/rules/statement_lines.yaml`.
|
||||
|
||||
**Фикстуры:**
|
||||
- `fixtures/golden/expected_statement_parse/**`: `Б.Б. Лист 2`, `ОФР. Лист 2`, `st05` — статусы и предупреждения;
|
||||
- `fixtures/ocr/expected_normalize/*.json` и golden BALANCE/INCOME — коды вне словаря (найдены 1105, 1231, 2930);
|
||||
- `fixtures/statements-xlsx/npz/2025-Q3/*.xlsx` — столбцы кодов по листам;
|
||||
- `fixtures/statements-xlsx/mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` — повторная шапка и title;
|
||||
- `fixtures/statements-xlsx/synthetic/*.xlsx`;
|
||||
- `unagrande-co/2024-FY/Б.Б Лист 1.xls`, `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`, `ББ. Лист 1 3кв.25.xls` — подписи «Код» / «Коды» и доли кодов.
|
||||
|
||||
Скрипты — в scratchpad (`npz.py`, `g2.py`, `ux.py`, `xl.py`, `codes.py`). Не смотрел: arm-web; `services/documents.py` и `auto_confirm.py` — для Р-13 и Р-15 опирался на текст дизайна, а не на код.
|
||||
@ -0,0 +1,148 @@
|
||||
# Повторное ревью design.md — проверка закрытия F-1…F-25 (17.10.2026, второй раунд)
|
||||
|
||||
Ревьюер: отдельный агент (модель Fable), без права правки; получил обновлённый design.md (после коммита 63f134b) и
|
||||
`review-2026-10-17.md`. Задание: по каждой находке F-1…F-25 найти место закрытия, прогнать сценарий через новые
|
||||
правила, оценить статус; отдельно — согласованность правок между собой. Маркеры новых проблем — V-n.
|
||||
|
||||
Отчёт агента ниже без сокращений.
|
||||
|
||||
---
|
||||
|
||||
# Повторное ревью design.md (unified-statement-normalization) — закрытие F-1…F-25
|
||||
|
||||
Ревьюер: независимый агент, без права правки. Проверено: `design.md` целиком (763 строки), `review-2026-10-17.md`, §7/§11/§11.1/§12 `05-domain-rules.md`, контракт v1.2, DBML v5.18, код трёх сервисов (через codegraph + Read), фикстуры OCR (4 успешных json, golden-эталоны) и Excel (НПЗ, Унагранде ×2, Межениновская, синтетика) скриптами `python3 -I` / venv парсера. Решения Р-8…Р-11 не пересматривались.
|
||||
|
||||
## 1. Таблица по находкам
|
||||
|
||||
| F-n | Где закрыто | Статус | Что осталось / новая проблема |
|
||||
|---|---|---|---|
|
||||
| F-1 | Шаг 7 (abs_terms «подтверждает только величину»), «Подтверждение проверкой» (знак по конвенции колонки), шаг 4 п. 1 (`}`/`]` — закрывающая скобка), Р-7, решение № 10 | closed, new issue | Правило верно на данных (aep_3 ОФР: все члены abs в скобках, `(7744536}` подтверждается, `{38378)` — подсвечивается). Но «форма записи знака» не доезжает ни до контракта, ни до БД — ядро не может пересчитать конвенцию после PATCH (**V-3**); область применения правила не ограничена сомнительными ячейками (**V-13**) |
|
||||
| F-2 | Шаг 7 («посчитано, если хотя бы один член непуст»), «Подтверждение проверкой» (покрыто ⇒ `amount` не `null`), Р-7 | closed | Сценарий «колонка залита полосой» даёт `needs_review = true` у всех мусорных ячеек; сценарий «1600 мусор, 1100/1200 читаемы» — нарушение, все три подсвечены |
|
||||
| F-3 | Шаг 3 (статус `detail`, родитель — ближайшая строка с кодом выше в сегменте), Р-8, решение № 9, «Что уходит из ядра» (экран скрывает `detail`) | closed, new issue | По фикстурам все 90 строк без кода с суммами в 8 OCR-таблицах — `detail`, единственная `missing` — «Выручка» aep_3 (код 2110 в колонке значений, верно). У Excel НПЗ/Унагранде `missing` нет. Но роль строк титульного блока над заголовком не определена — у Межениновской «Дата 30 \| 9 \| 2025» станет `missing` (**V-8**) |
|
||||
| F-4 | Принцип 2, шаг 3 (`row_index`, `ref`), шаг 8 п. 5, DBML v5.19 (`row_index`, `source_ref`, `parent_row_index`), «Экран показывает строки как в файле» | closed | — |
|
||||
| F-5 | Шаг 2 п. 1 (а) «подпись принимается только с кодом под ней, последняя над первой строкой с кодом», п. 4 (обе ветки парсера сохранены), решение № 3 | partial | Защита «хотя бы один код под подписью» пробивается ОКОПФ **12267** (АО) в титульном блоке 1С: Унагранде ББ Лист 1 и Унагранде Групп ОФР 3кв.25 — под «Коды» стоит 12267 (1226 ∈ [1100, 1799]); правила выбора между двумя столбцами-кандидатами нет (**V-1**, critical). Ветка «заголовок по периодам» перенесена — верно |
|
||||
| F-6 | Шаг 2 п. 3 (эффективная дата), шаг 8 п. 1 («дата отчёта **или** самая поздняя абсолютная дата колонок»), п. 2 (`info` «дата отчёта не разобрана при наличии дат колонок»), решение № 12 | closed | Совпадает с `statement_period` ядра (`statement_mapping.py:73-80`) |
|
||||
| F-7 | Шаг 9 (v2.0, порядок «сначала ядро», два маппера), Р-9 | closed | Паттерн `^1\.\d+$` в `contract/common.py:33` поднимается до `^(1\|2)\.\d+$`; запрос `recognition-request` остаётся 1.x (шаг 0) — не затронут. В спеке нужно задать, чем заполняются новые поля при приёме 1.x (`line_status = ok`, `row_index` по порядку, `needs_review` — старое правило) — не противоречие, а пробел спеки |
|
||||
| F-8 | Шаг 8 п. 1 (одна функция `done`), «Подтверждение проверкой» (is_modified ⇒ false, акцепт, `error` сохраняются), DBML v5.19 (`line_status`, `line_code_text`, `row_index`, `value.needs_review`, `document_issue`), решение № 11 | partial | Пп. 2–4 закрыты. П. 1 закрыт для значений, но **`needs_review` строки** (F-9) в DBML v5.19 не добавлен, и правило «назначение кода снимает `needs_review` строки» не записано (**V-5**). Пересчёт конвенции знака после PATCH невозможен без формы записи (**V-3**) |
|
||||
| F-9 | «Код и наименование строки» (упорядоченный перечень по редакциям, порядок бланка, редакция по эффективной дате / обе, равные соседние допустимы), шаг 5 п. 2, Р-5 | closed, new issue | Итоги после слагаемых — верно. Но пятизначные подстрочные коды (11101, 12101, 23201 — все выгрузки 1С: НПЗ, Унагранде) ни в какой перечень бланка не входят → `needs_review` у каждой такой строки → НПЗ ББ/ОФР и Унагранде ОФР из `done` уходят в `needs_check` (**V-2**, critical). Алгоритм «положение относительно соседних подтверждённых» циклически определён. Порядок/состав редакций теперь в двух местах (**V-11**) |
|
||||
| F-10 | Шаг 2 п. 3 (`period_kind = relative:<вид>`, `period_end = null`), «Что уходит из ядра» (PATCH даты → ядро досчитывает общим кодом), Р-6, DBML `column.period_kind` | closed | Согласовано с F-6: эффективная дата из колонок даёт относительным датам опору и без правки |
|
||||
| F-11 | «Что уходит из ядра» (снять уникальность `document`, порядковый номер формы), Р-10 | closed, new issue | Потеря устранена, но не сказано, что делать с двумя документами одного типа и периода у одного ЮЛ (чек-лист, консолидация, `_pair_document`), и как быть с половинками формы (OCR: «одна таблица = одна форма, склейки нет») — склейки внутри файла нет (**V-7**) |
|
||||
| F-12 | Шаг 8 п. 2 (таблица уровней), решение № 1 | closed, new issue | Все блокираторы из ревью перечислены (json набора, `BACKEND_PARSING_ERROR`, лист без кодов, сегменты, колонка без даты, заголовок, тип), xlsb/дробные решены. Но «ИНН не распознан» → `error` противоречит критерию `done` «(организация **или** ИНН)» и ломает НПЗ ОФР без ИНН (`inn_optional_for: [INCOME]`, сегодня `done`); «формат не совпал с `file_type`» → `error` противоречит собственному принципу решения № 1 (**V-6**) |
|
||||
| F-13 | Шаг 7 («не сошлось» = вне допуска ±1 **и** вне округления 0,001 %; округление и мягкие → `soft`, `info`), «Подтверждение проверкой» (покрыто в пределах допуска или округления; мягкие подсветки не дают), шаг 8 п. 1 | closed | НПЗ (1600 = 1700 на 1,94 при ~17 млрд) и Унагранде (мягкие 2400/2410) остаются `done` по этому пункту |
|
||||
| F-14 | «Назначение кода строке» (`PATCH …/statement lines: [{line_id, line_code \| null, line_status}]`, аудит, пересчёт, `missing → detail`, `detail → ok`), экран: поле кода у `unreadable`/`missing`/`detail` | closed | Мелкие пробелы спеки: переход `ok → detail`, недопустимая пара `line_code: null` + `line_status: ok`, `parent_row_index` при смене статуса (**V-12**, minor) |
|
||||
| F-15 | «Что уходит из ядра» (одна функция «агрегат формы по коду», перечислены `_line_amount`, `statement_amounts`, `subline_parents`/`parent_code`, `formLayout`, `_resolve_cells`, `metrics.fields_*`), Р-4 | closed | Все точки из ревью названы. Уточнить формулировку: сумма — по строкам `ok` (шаг 7 говорит это, «Что уходит из ядра» — «сумма значений по колонке» без оговорки) |
|
||||
| F-16 | Шаг 1 (`segment_breaks`, «граница листа — всегда граница сегмента»), шаг 2 п. 2 (своя раскладка, по числам без шапки, сопоставление по порядку, число не совпало → `error`), п. 6 шага 1 | closed, new issue | Сегмент-продолжение **без шапки и с 1–2 кодами** (НПЗ ОФР лист `_2` — единственная строка 2400, синтетика st07) по правилу п. 1 (б) «кодов ≥ 3» столбца кодов не получит; решение № 3 снимает `relaxed`, ссылаясь на склейку в шаге 1, но склейка не помогает — раскладка у сегмента своя (**V-4**) |
|
||||
| F-17 | Р-1 (пакет `arm_statements` с тестами и конфигом линтеров, импорт по имени пакета, `service.statements_version`, `sync-statements --check` в docs, словари со своим загрузчиком), шаг 9, «Что уходит из ядра» (версия в `recognition_job`, расхождение — `error`) | closed | Все 5 пунктов закрыты. `recognition_job` не имеет колонки под `statements_version` (есть только `service_version`), в список DBML v5.19 не внесена (**V-10**, minor) |
|
||||
| F-18 | «Что уходит из ядра» (`RECOGNIZED ⇔ done ∧ ИНН ≠ MISMATCH ∧ период — конец квартала`), шаг 8 п. 1 («квартал — знание ядра») | closed | — |
|
||||
| F-19 | Принцип 5, Р-11 (двойники в суммах и кодах ⇒ `is_recognized = false`, далее §7 с оговорками F-1/F-2), шаг 4 п. 2 («замена двойника — исправление») | closed | Решение владельца, зафиксировано; §11.1 п. 1 помечен к отмене. Данные: в 4 json двойников в суммах/кодах нет (`(Ю4)` в aep_6 — «Ю» не в списке двойников, остаётся грязной) |
|
||||
| F-20 | Шаг 4 п. 1 (квантование half-up, 16 целых цифр, дата/логическое → `null` + `string_value`, догадка > 16 цифр → `null`), решения № 6, № 13 | closed | — |
|
||||
| F-21 | Шаг 2 п. 1 («код — текст без пробелов и переносов»), шаг 3 п. 1 («1 100» → 1100) | closed | Порядок с разлеплением (F-25) не оговорён: «переносы убираются» в п. 1 против «N частей через перевод строки» в п. 2 (**V-9**, minor) |
|
||||
| F-22 | Шаг 6 п. 1 (5xxx и прочие → `DISCLOSURE`; без кодов — `type_hint`; `DISCLOSURE` без шагов 2–7), решение № 2 | closed | Форма 3/4 (3xxx/4xxx не в диапазонах, label не в словаре) → «не документ, `info`» — как сегодня (golden aep_8_form4 = `error` по ответу ИС); согласовано |
|
||||
| F-23 | Шаг 1 (`locator file:…`), шаг 6 п. 3, шаг 8 п. 3 (`RECOGNITION_FAILED` / `INVALID_DOCUMENT` — различие записано), решение № 5 | closed | — |
|
||||
| F-24 | Шаг 2 п. 1 («новое поведение для обоих: шлюз сегодня пропускает со счётчиком, кодом считает любое целое») | closed | — |
|
||||
| F-25 | Шаг 3 п. 2 (разлепление и при нечитаемой части → `unreadable`; число частей не совпало → одна строка, многострочное значение → `null` + `string_value`) | closed | См. V-9 о порядке операций |
|
||||
|
||||
Итого: closed — 15, partial — 2 (F-5, F-8), closed с новой проблемой — 8 (F-1, F-3, F-9, F-11, F-12, F-16 + мелкие у F-14, F-17, F-21).
|
||||
|
||||
## 2. Разбор незакрытых и новых проблем
|
||||
|
||||
### V-1 — critical — F-5: защита «под подписью есть код» пробивается ОКОПФ в титульном блоке 1С; тай-брейка между столбцами-кандидатами нет
|
||||
**Где:** шаг 2 п. 1 (а), решение № 3.
|
||||
**Что проверил.** Скриптом по xls/xlsx прошёл все ячейки `^код(ы| строки)?$` и содержимое столбца под ними:
|
||||
- `unagrande-co/2024-FY/Б.Б Лист 1.xls`: «Коды» r6c34 → под ней `0710001, 31, 47888105, 3249004697, 10.51.3, **12267**, 384, 7716021332, 1037739271701`; «Код» r28c22 → `1110, 11101, 11102, …`.
|
||||
- `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`: «Коды» r3c18 → `0710002, 30, 69583357, 7728757141, **12267**, 384`, а дальше в том же столбце **значения таблицы** (`За Январь - Сентябрь 2024 г.`, `287017990.57`, `-302669265.62`…) — титульный столбец совпал с колонкой сумм.
|
||||
`12267` (ОКОПФ «АО») по правилу дизайна — код: 4–5 цифр, первые четыре `1226 ∈ [1100, 1799]` (`statement_headers.yaml:86`; `columns.py:63-71` `line_code`). Для ООО (ОКОПФ 12300) то же. У НПЗ и Межениновской ОКОПФ в этом столбце нет — там защита работает.
|
||||
**Сценарий.** Два столбца удовлетворяют (а). Правило «последняя такая подпись над первой строкой с кодом»: если «первая строка с кодом» — глобально по сетке, это r13 (ОКОПФ) → единственная подпись над ней «Коды» r6 → столбец c34 → каждая строка формы становится `unreadable`/`missing`, у Унагранде Групп ОФР столбец сумм объявляется кодами. Если «первая строка с кодом» — по столбцу, кандидатов два, а правила выбора в дизайне нет; реализация «первый найденный сверху/слева» выберет титульный блок. Сегодня парсер защищён порядком: сначала столбец по подсчёту (`find_code_column`, `columns.py:74-95`: кодов ≥ 3, доля ≥ 0,6), потом подпись только в этом столбце (`_find_header_row`, `:127-146`).
|
||||
**Как закрыть.** Требовать под подписью не «хотя бы один код», а долю кодов среди непустых ≥ `min_share` (под «Коды» r6c34 — 1 из 9 = 0,11, под «Код» r28c22 — ~1,0; «документ из двух строк» сохраняется: 2 из 2); при нескольких кандидатах — столбец с наибольшим числом кодов. Либо вернуть порядок парсера (подсчёт → подпись в найденном столбце → заголовок по периодам), а исключение для малых таблиц делать явной веткой. Любой вариант зафиксировать в решении № 3.
|
||||
|
||||
### V-2 — critical — F-9 / Р-5: пятизначные подстроки не входят ни в один перечень бланка → каждая выгрузка 1С с детализацией теряет `done`
|
||||
**Где:** «Код и наименование строки», шаг 5 п. 2, Р-5.
|
||||
**Что проверил.** Пятизначные коды в фикстурах: НПЗ ББ — `11101…11103, 11501…11509, 11901, 12101…12106, 12201…, 12501…, 13401…, 15201…`, НПЗ ОФР — `23201…23203, 23301, 23401…, 25101, 25102`; Унагранде ББ — `11101…11507`. Это подстроки «в том числе» 1С (§12 п. 5: «пятизначные подстроки отдаются как обычные строки»; §7 «родитель = Σ подстрок», `statement_checks.py:141-184` `subline_parents`/`_subline_rules`). В словаре ядра `statement_lines.yaml` (132 строки, редакции 66n/fsbu4) пятизначных кодов нет — и в «упорядоченном перечне кодов по редакциям» их быть не может: они свои у каждой организации.
|
||||
**Сценарий.** Правило дизайна: «Код подтверждён, если он **есть в перечне** и его положение … совпадает с порядком бланка; … иначе `needs_review` у строки, документ — `needs_check`». Уверенность не спасает: «Подтверждён — уверенность не важна; иначе `needs_review`» — оговорки для `confidence = 100` нет. НПЗ ББ (43 кода на листе 2, большинство пятизначные), НПЗ ОФР, Унагранде ОФР 3кв.25 — сегодня `done` (манифест строки 18, 39, 47–48) — по дизайну `needs_check`; Excel-путь теряет автоподтверждение целиком, у шлюза 1С-подобных сканов тоже.
|
||||
Второе: «положение относительно соседних **подтверждённых** кодов» — определение циклическое, алгоритм не задан.
|
||||
**Как закрыть.** (а) Пятизначный код подтверждён, если его четырёхзначный родитель по правилу `subline_parents` (ближайшая строка выше с теми же тремя цифрами, иначе код из первых четырёх цифр) подтверждён и подстрока стоит после него; (б) алгоритм — наибольшая подпоследовательность кодов, согласованная с порядком бланка хотя бы в одной из проверяемых редакций; коды вне её — `needs_review`; (в) явно спросить владельца: подтверждать ли структурой коды с уверенностью 100 (Excel) — по тексту шага 8 п. 1 «Excel выполняет условия уверенности автоматически» это, видимо, не задумывалось.
|
||||
|
||||
### V-3 — important — F-1 ↔ F-8: «форма записи знака» не хранится — ядро не может пересчитать конвенцию после PATCH; «сырой текст в подсказке» для распознанных ячеек пуст
|
||||
**Где:** «Подтверждение проверкой» (знак), «Что уходит из ядра» (пересчёт `needs_review` общим кодом после PATCH), шаг 4 п. 1–3, шаг 9.
|
||||
**Что проверил.** Контракт v1.2: `values[].string_value` — `["string","null"]` (схема l.199), DBML `financial_statement_value.string_value` — «как распознано, если не число»; шлюз пишет его только при `amount = null` (`document.py:149-151`). Дизайн (шаг 4 п. 3) расширяет это до «догадки» (`is_recognized = false`), но у ячейки, разобравшейся чисто, `string_value` остаётся `null`. Форма записи (скобки / минус / без знака) из `amount = −7744536` не восстанавливается.
|
||||
**Сценарий.** aep_3 ОФР, колонка c2: 2120 `(7744536}` 88,9 → покрыто по величине, знак по конвенции (2210 `(693052)` 100, 2220 `(278598)` 100 — скобки) → `needs_review = false`. Аналитик правит любую ячейку документа → PATCH → ядро «пересчитывает `checks` и `needs_review` общим кодом»; входа «форма записи» у ядра нет → либо ветка знака пропускается (подсветка знака исчезает молча — то, против чего F-1), либо «сравнивать не с чем» → `needs_review = true` у всех членов `abs_terms` → `done` недостижим правкой. Второе следствие: 2210 `(546441)` с уверенностью 62,5 в c4 (чистый разбор, `is_recognized = true`) — «сырой текст и уверенность остаются в подсказке ячейки», но сырого текста в результате нет.
|
||||
**Как закрыть.** В контракт v2.0 — `values[].text` (сырой текст источника всегда, как `Cell.text` шага 1), в DBML v5.19 — `financial_statement_value.source_text`; конвенция и подсказка считаются от него. Альтернатива — `sign_form: brackets | minus | none` + `text` только у `is_recognized = false`, но это два поля вместо одного.
|
||||
|
||||
### V-4 — important — F-16 / решение № 3: сегмент-продолжение без шапки с 1–2 кодами не получает столбца кодов — строка 2400 НПЗ теряется
|
||||
**Где:** шаг 2 п. 1 (б) («кодов ≥ 3»), п. 2 («без шапки — столбец кодов по правилу п. 1»), решение № 3 («`relaxed` … снят, потому что продолжения склеиваются в шаге 1»).
|
||||
**Что проверил.** НПЗ, лист «Отчет о финансовых результата_2»: подписи нет, кодов в столбце — **один** (2400); манифест строка 48: «лист с единственной строкой 2400 присоединён (O-9)», документ `done` по 3 листам. Сегодня: `_continuation` → `find_layout(relaxed=True)` (`statement.py:94-105`, `min_codes_continuation: 1`, `statement_headers.yaml:92`), затем `_weak_blocker` (`:145-160`). Синтетика `st07_single_code_continuation.xlsx` — тот же случай.
|
||||
**Сценарий.** По дизайну лист дописывается в сетку в шаге 1, граница листа открывает сегмент, «у сегмента своя раскладка: … без шапки — столбец кодов по правилу п. 1» → (а) подписи нет, (б) кодов 1 < 3 → столбца нет. Что происходит с сегментом без столбца кодов, не сказано; при любом чтении строка 2400 либо теряется (принцип 2 нарушен), либо весь документ получает `error` «лист без колонки кодов» → `needs_check`. Склейка в шаге 1 этому не помогает — раскладка всё равно своя.
|
||||
**Как закрыть.** Для сегмента не первого: подписи нет → столбец кодов наследуется от предыдущего сегмента, если в нём хотя бы один код (это и есть сегодняшний `relaxed` + `_weak_blocker` «коды из диапазона формы»); иначе — правило (б); не найден → `error` сегмента. Поправить решение № 3.
|
||||
|
||||
### V-5 — important — F-8 / F-9: `needs_review` строки негде хранить; снятие его назначением кода не описано
|
||||
**Где:** «Код и наименование строки» (`StatementLine.needs_review`), шаг 8 п. 1 («нет значений **и строк** `needs_review`»), DBML v5.19 (только `financial_statement_value.needs_review`), «Назначение кода строке».
|
||||
**Сценарий.** Результат с `needs_review = true` у строки 1230 (код с уверенностью 50 не на месте) → ядро «при загрузке ничего не пересчитывает, хранит как пришло» — колонки нет → признак теряется; после PATCH функция `done` получает вход «статусы строк, `needs_review`» неполным. Аналитик назначает код `unreadable`-строке (F-14) — `line_code_modified = true`, но правила «`line_code_modified` ⇒ `needs_review` строки = false» нет (для значений аналогичное `is_modified` ⇒ false записано) → строка подсвечена навсегда.
|
||||
**Как закрыть.** DBML v5.19: `financial_statement_line.needs_review` (как пришло, пересчёт после PATCH); правило `line_code_modified = true` ⇒ `needs_review = false`; либо явно: признак строки хранится записью `document_issue(kind = warning, line_id)` и критерий `done` читает его оттуда — но тогда так и написать.
|
||||
|
||||
### V-6 — important — F-12: уровень `error` для «ИНН не распознан» и «формат не совпал с `file_type`» противоречит критерию `done` и принципу решения № 1; регрессия НПЗ ОФР
|
||||
**Где:** шаг 8 п. 1 («(организация или ИНН)»), п. 2 (`error`: «ИНН не распознан», «формат файла не совпал с `file_type`»), решение № 1 («`error` — то, что меняет данные или их полноту»).
|
||||
**Сценарий.** НПЗ ОФР: в шапке нет ИНН (манифест строка 45 `inn_optional_for: [INCOME]`), организация есть; сегодня `done`. По дизайну критерий 1 проходит (организация есть), но предупреждение «ИНН не распознан» уровня `error` даёт `needs_check` — два правила одного документа спорят, побеждает `error`. Формулировка «не распознан» — из шлюза; что сервис выдаёт у Excel при отсутствующем ИНН, не сказано (сегодня парсер об ИНН не предупреждает). «Формат не совпал с `file_type`»: файл прочитан верно, данные и полнота не изменились — по собственному принципу решения № 1 это `info`; сегодня — предупреждение без влияния на статус.
|
||||
**Как закрыть.** «ИНН не найден/не распознан» → `error` только если и организации нет (= критерий «организация или ИНН»); при организации — `info`, сверку с ЮЛ делает ядро (`MISMATCH`). «Формат не совпал» → `info`. Таблицу уровней выровнять с принципом решения № 1.
|
||||
|
||||
### V-7 — important — Р-10 / F-11: два документа одного типа и периода у одного ЮЛ — правила выбора нет; половинки формы внутри файла не склеиваются
|
||||
**Где:** «Что уходит из ядра» (DBML `document`, «привязка к версии консолидации — по периоду, как у ОСВ»), Р-10, шаг 1 п. 6 (OCR: «одна таблица = одна форма, склейки нет — без изменений»).
|
||||
**Сценарий 1.** Лист-продолжение с маркером другого типа → отдельный кандидат того же периода; или ИС вернула ББ двумя таблицами (актив/пассив) — у ОСВ второй документ различим по счёту, у ББ — нет. Чек-лист `required_document_rule` (индекс `(legal_entity_id, document_type, osv_account_code, period_year, period_quarter, status)`), привязка к консолидации «по периоду», межформенная проверка `_pair_document` (`repositories/statements.py:330-346`: подтверждённый, затем последний по `created_at` — в одном файле `created_at` одинаков) — все берут «один документ на тип и период». Два подтверждённых ББ одного периода → неопределённость в КФО.
|
||||
**Сценарий 2.** Половинки (актив `needs_check` по `1600 = 1700`, пассив `needs_check`) теперь обе хранятся, но операции «склеить» нет (OQ-140 запрещает только между файлами, внутри файла не описано) — аналитик не может довести файл до `done` иначе как перезагрузкой.
|
||||
**Как закрыть.** Записать: в чек-лист и консолидацию идёт подтверждённый документ; подтверждение второго документа того же типа и периода у ЮЛ — 409 с указанием первого (или автозамена, как между файлами, §12 «Автозамена», `05-domain-rules.md:689`); склейка половинок не предоставляется — зафиксировать как известное ограничение или вопрос в `07-open-questions.md`.
|
||||
|
||||
### V-8 — minor — F-3 / шаг 2–3: роль строк над строкой заголовка не определена — титульный блок с числами становится `missing`
|
||||
**Где:** выход шага 2 (`TableLayout`: роли `header | numbering | data`), шаг 3 («по строке на каждую строку с ролью `data`, у которой есть код или хотя бы одно число»).
|
||||
**Что проверил.** Межениновская Форма1: над заголовком (r23) строки r6 «Дата (число, месяц, год) | 30 | 9 | 2025» и r15 «подлежит обязательному аудиту | 0 | ДА | 1» — кода нет, числа правее столбца кодов; Форма2 — r6. Сегодня они не данные: `find_layout` ставит `first_data_row = header_last_row + 1` (`columns.py:294-310`), `collect_lines` начинается с неё.
|
||||
**Сценарий.** Если в `TableLayout` эти строки — `data` (третьей роли нет), по шагу 3 они без кода и с числами над первой строкой с кодом → `missing` → `done` недостижим у Межениновской (сегодня `done`, манифест строки 50–56).
|
||||
**Как закрыть.** Роль `title` (или «не строка») для всего, что выше строки заголовка сегмента; при отсутствии заголовка — выше первой строки с кодом; `missing` — только между заголовком и первой строкой с кодом.
|
||||
|
||||
### V-9 — minor — F-21 ↔ F-25: порядок «разлепить по переводу строки» и «убрать переносы в коде» не задан
|
||||
**Где:** шаг 2 п. 1 и шаг 3 п. 1 («пробелы и переносы убираются») против шага 3 п. 2 («ячейка кода содержит N частей через перевод строки → N строк»).
|
||||
**Сценарий.** «1500\n1700» по п. 1 → «15001700» → не код → `unreadable`; по п. 2 → две строки `ok`. Записать явно: сначала п. 2 (разлепление), затем п. 1; для поиска столбца (шаг 2) многострочная ячейка считается кодом, если **каждая** часть — код.
|
||||
|
||||
### V-10 — minor — F-17: `recognition_job.statements_version` не в списке DBML v5.19
|
||||
`recognition_job` имеет `service_name`, `service_version`, `result_schema_version`, `metrics jsonb` — отдельной колонки под версию общего кода нет, а «Что уходит из ядра» требует писать её и сравнивать. Добавить `statements_version varchar` в ту же миграцию (или оговорить хранение в `metrics`).
|
||||
|
||||
### V-11 — minor — F-9 ↔ Р-3: состав и порядок кодов по редакциям живут в двух местах
|
||||
Общий пакет получает «упорядоченный перечень кодов по редакциям», ядро оставляет `statement_lines.yaml` с теми же редакциями и порядком бланка (`editions: 66n` l.9, `fsbu4` l.72) ради наименований и проверки «код не из редакции». Это та же пара копий, которую дизайн устраняет для §7. Предложение: словарь ядра — только `код → наименование` (по редакциям), порядок и состав редакций ядро берёт из своей копии пакета `arm_statements`; `sync-statements --check` сверяет, что все коды словаря ядра есть в перечне пакета.
|
||||
|
||||
### V-12 — minor — F-14: пробелы в операции назначения кода
|
||||
Не описаны: переход `ok → detail` (ложный код, который аналитик хочет вывести из агрегата, не удаляя строку), недопустимость пары `line_code: null` + `line_status: ok`, пересчёт `parent_row_index` при переходе `missing → detail` (родитель — ближайшая строка `ok` выше), что происходит с `line_code_confidence` после назначения (100?).
|
||||
|
||||
### V-13 — minor — F-1: область правила знака не ограничена сомнительными ячейками; `null`-члены не исключены из большинства
|
||||
Текст: «не совпадает или сравнивать не с чем — `needs_review = true` независимо от соотношения». При буквальном чтении правило применяется и к чистым ячейкам (`confidence = 100`, `is_recognized = true`): Excel-ОФР, где 2120 в формате `(#,##0)`, а 2410 — `-#,##0`, получит подсветку расходов; OCR-ОФР малой компании, где из членов `abs_terms` заполнена одна 2120 с уверенностью 100 — «сравнивать не с чем» → `needs_review`. Решение № 10 про «вперемешку скобками и минусом — это и нужно» относится к сомнительным ячейкам. Записать: знак проверяется только у ячеек, которым нужно покрытие (`confidence < 100` или `is_recognized = false`); в большинство входят члены с непустым `amount`.
|
||||
|
||||
## 3. Согласованность правок между собой
|
||||
|
||||
- **F-2 ↔ F-1 ↔ F-13 ↔ F-3/Р-8 ↔ Р-4/F-15.** Цепочка «агрегат по коду = Σ строк `ok` → соотношение посчитано, если хотя бы один член непуст → сошлось в пределах допуска или округления → покрывает только ячейки с `amount ≠ null` → для `abs_terms` только величину» внутренне непротиворечива; `detail`/`unreadable`/`missing` исключены из агрегата, подстроки 5xxxx — `ok` и проверяются по AURORA-1527 (мягко, подсветки не дают). Сбоит только хранение формы знака (V-3) и область правила (V-13).
|
||||
- **F-6 ↔ F-10 ↔ F-9.** Одна «эффективная дата» (решение № 12) питает относительные подписи, редакцию перечня кодов и критерий `done`; пересчёт после PATCH даты описан. Согласовано. Не задан `period_kind` у колонки без подписи (шаг 2 п. 4) — предположительно `none`, стоит записать.
|
||||
- **Статус `done` ↔ пересчёт после PATCH (F-8).** Входы функции названы; `is_modified`, акцепт, сохранение `error` — есть. Пробелы: `needs_review` строки (V-5), форма знака (V-3).
|
||||
- **F-9 ↔ Р-3.** Перечень без наименований Р-3 не нарушает; но дублирует редакции/порядок словаря ядра (V-11) и не покрывает подстроки (V-2).
|
||||
- **Контракт v2.0 (F-7/Р-9) ↔ DBML v5.19 (F-4/F-8/F-11/Р-10).** Соответствие полей: `line_status`, `line_code_text`, `row_index` → `row_index`, `ref` → `source_ref`, `parent_ref` → `parent_row_index`, `values[].needs_review` → `value.needs_review`, `columns[].period_kind` → `column.period_kind`, `document.status` → `document.status`, `document.warnings[]`/`checks` → `document_issue`, `document` без уникальности + порядковый номер, `crm_id`. `line_code` уже nullable в DBML v5.18, `period_date` nullable — миграция по ним не нужна. Не хватает: `financial_statement_line.needs_review` (V-5), `recognition_job.statements_version` (V-10), сырого текста значения (V-3). `service.statements_version` ложится в существующий объект `service {name, version}` контракта — аддитивно.
|
||||
- **Р-11 ↔ F-1/F-2.** «Двойник ⇒ `is_recognized = false` ⇒ нужно покрытие ⇒ покрытие требует посчитанного соотношения и `amount ≠ null`» — согласовано; `}`/`]` как закрывающая скобка: не сказано, считается ли это «исправлением символа» (влияет только на то, нужно ли покрытие ячейке с уверенностью 100 — у `(7744536}` уверенность 88,9, вопрос не возникает).
|
||||
- **Решение № 1 ↔ шаг 8 п. 2.** Списки `error` совпадают, кроме `BACKEND_PARSING_ERROR` и «формат не совпал» (есть только в п. 2); принцип решения № 1 нарушен двумя пунктами (V-6).
|
||||
- **Р-8 ↔ §12 п. 5 / §11.1 п. 4 / строка 688.** Дизайн отменяет OQ-36/OQ-141; OQ-149 («восстановление строк по наименованию») не упомянут и остаётся открытым — противоречия нет, но при правке §12 его надо сохранить.
|
||||
|
||||
## 4. Что проверил и признал согласованным
|
||||
|
||||
- Р-2: ИС отдаёт ячейку на каждую позицию (по 8 таблицам повторно подтверждено, позиций не хватает 0).
|
||||
- Статусы строк без кода по OCR: aep_3 ББ `detail` 2 / `unreadable` 6 / `missing` 0; aep_3 ОФР 1 / 1 / 1 («Выручка»); aep_6 ББ 19 / 2 / 0, ОФР 2 / 3 / 0; «БО 1 кв 2025» ББ 20 / 3 / 0, ОФР 2 / 2 / 0; «Русский бисквит 1 кв.25» ББ 40 / 3 / 0, ОФР 12 / 2 / 0 — цель Р-7 («чистый скан может стать `done`») по строкам достижима.
|
||||
- Конвенция знака на данных: aep_3 ОФР c2 — 2120 `(7744536}` 88,9; 2210 `(693052)` 100; 2220 `(278598)` 100; 2330 `{38378)` 71,4; 2350 `(241654)` 75; 2410 `(108450)` 87,5 → конвенция «скобки», 2120 подтверждается, 2330 подсвечивается — как и описано в дизайне. aep_6 c4: 2220 `(124)` 100, 2350 `(Ю4)` 75 — «Ю» не двойник, остаётся грязной и подсвечивается.
|
||||
- Диапазоны кодов словаря (BALANCE 1100–1799, INCOME 2100–2999, DISCLOSURE 5000–5999; `min_share 0.6`, `min_codes 3`, `min_codes_continuation 1`) — шаг 2 п. 1 (б) воспроизводит парсер.
|
||||
- Шаг 6 п. 1 ↔ `statement_labels.yaml` шлюза (4 подписи: ББ, ОФР ×3) ↔ `statement_headers.yaml` парсера (ОКУД, «Пояснения» → DISCLOSURE) — слияние словарей не противоречит; форма 4 (aep_8_form4) сегодня `error` от ИС, регрессии нет.
|
||||
- Шаг 9 / Р-9 ↔ `contract/common.py` (`SCHEMA_VERSION_PATTERN`, `ContractModel extra="ignore", strict=True`) — два маппера реализуемы; запрос остаётся 1.x.
|
||||
- F-13 ↔ `statement_checks.yaml` (жёсткие 1100…1700, 2100/2200/2300; мягкие 2400 ×2, 2410, 2500; `abs_terms` 2120/2210/2220/2330/2350/2410) — формулировки дизайна совпадают со словарём.
|
||||
- F-18 ↔ `form_confirmable` (`services/statements.py:71-81`: квартал обязателен) — `period_known` в ядре сохранён.
|
||||
- F-6 ↔ `statement_period` (`statement_mapping.py:73-80`), F-9 ↔ `edition_for` (`:83-87`, граница 2024-12-31) — одна формула эффективной даты и редакции.
|
||||
- F-15: `_pair_document` уже имеет порядок выбора (подтверждённый → последний), агрегат по коду его не ломает; `line_origin` (`schemas/statements.py:157-159`) по `line_code_original_value` при появлении `line_status` можно заменить, но это не противоречие.
|
||||
- Уровень «xlsb без форматов», «дробные при тыс.», «подпись из словаря исключений» = `info` — совпадает с сегодняшним поведением парсера (документ `done`).
|
||||
|
||||
## Файлы и символы, которые смотрел
|
||||
|
||||
**docs** (worktree `.wt/normalization/docs`): `openspec/changes/unified-statement-normalization/design.md` (целиком), `review-2026-10-17.md` (целиком), `context/05-domain-rules.md` (строки 505–530 §7, 686–690, 990–999 §11.1 п. 4, 1105–1115 §12 п. 2, 1137–1155 §12 п. 5/7), `contracts/recognition-result.schema.json` (`$comment`, `service`, `lines[]`, `values[]`, `checks`, `string_value`), `model/data-model-v5.18.dbml` (`document`, `recognition_job`, `financial_statement_meta/column/line/value`).
|
||||
**tabular-parser**: `api/src/parser/statements/columns.py` (`_CODE`, `line_code` 63–71, `find_code_column` 74–95, `_first_code_row`, `_find_header_row` 127–146, `_value_columns` 229–238, `find_layout` 294–310), `statement.py` (`_block_draft` хвост, `_continuation` 94–135, `_sheet_codes`, `_weak_blocker` 145–160, `_fill`), `detect.py` (`is_weak_continuation` 131–134, `classify_statement_sheet`, `_Candidate`), `api/rules/statement_headers.yaml` (19–34, 78–96), `statement_checks.yaml` (6–28), `api/tests/statements_manifest.yaml` (1–75).
|
||||
**scan-gateway**: `api/src/normalizer/ocr/amounts.py` (`parse_amount` 44–84, `is_blank`), `rows.py` (`classify_row` 58–71, `split_merged_row` 92–118, `_data_rows`), `document.py` (`_value` 134–159), `api/rules/statement_labels.yaml`, `api/src/gateway/merge.py` (контекст склейки).
|
||||
**arm-core**: `api/src/domain/statement_checks.py` (`subline_parents` 141–162, `_subline_rules` 165–184, `run_checks` 187–197), `statement_mapping.py` (`statement_period` 73–80, `edition_for` 83–87), `contract/common.py` (30–70), `services/statements.py` (`form_confirmable` 71–81), `repositories/statements.py` (`_pair_document` 330–346), `schemas/statements.py` (`line_origin` 157–159), `api/rules/statement_lines.yaml` (структура редакций, отсутствие пятизначных кодов), `services/result_mapping.py` (точки `MappingFatal`).
|
||||
**fixtures** (скриптами `python3 -I` и venv парсера с `-I`): `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/*.json` + README; `statements-xlsx/unagrande-co/2024-FY/Б.Б Лист 1.xls`, `Б.Б. Лист 2.xls`, `ОФР. Лист 1.xls`, `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`, `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`, `npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (…).xlsx` (все 9 листов), `synthetic/st07_single_code_continuation.xlsx`.
|
||||
|
||||
Не смотрел: arm-web (не индексирован, по заданию), `services/documents.py`, `auto_confirm.py`, чек-лист (`repositories/consolidations.py`) — следствия Р-10 для них описаны по индексу DBML, а не по коду.
|
||||
@ -0,0 +1,212 @@
|
||||
# Независимое ревью 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`).
|
||||
Loading…
x
Reference in New Issue
Block a user