From a1c6c10869d13505fa581445b172540aaf16e2a2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:02:44 +0400 Subject: [PATCH 01/12] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0:=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=D1=80?= =?UTF-8?q?=D1=8F=D0=B4=D0=BE=D0=BC=20=D1=81=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=BC=20=E2=80=94=20?= =?UTF-8?q?8=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20(#?= =?UTF-8?q?35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/osv-inn-rows-layouts/design.md | 129 ++++++++++++++ .../changes/osv-inn-rows-layouts/proposal.md | 61 +++++++ .../specs/osv-parsing/spec.md | 165 ++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 openspec/changes/osv-inn-rows-layouts/design.md create mode 100644 openspec/changes/osv-inn-rows-layouts/proposal.md create mode 100644 openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md new file mode 100644 index 0000000..b4a7d39 --- /dev/null +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -0,0 +1,129 @@ +## Context + +Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs/context/05-domain-rules.md` §1.1 (реквизит +`<группировка>.ИНН`, OQ-162) и §1.3 (ИНН контрагента: колонка → строка ИНН → хвост названия; OQ-100, OQ-130); новое правило +заносится в §1.3 и новым OQ в `07-open-questions.md`. + +Восемь раскладок (ИНН над / под наименованием) × (в дереве / не в дереве) × (с суммами / без сумм). Поведение main на +синтетике без подсказки в шапке: + +| Раскладка | main | +|---|---| +| над, не в дереве, без сумм | склеивается (OQ-130) | +| над, не в дереве, с суммами | ИНН — отдельный контрагент, `needs_check` | +| над, в дереве, с суммами | ИНН вместо наименования, `done` | +| над, в дереве, без сумм | `needs_check` | +| под, в дереве, с суммами | ИНН теряется, `done` (с подсказкой в шапке — склеивается, #32) | +| под, в дереве, без сумм | `needs_check` | +| под, не в дереве, с суммами | ИНН — отдельный контрагент, `needs_check` | +| под, не в дереве, без сумм | **ИНН первого контрагента у второго, `done`** | + +Проверено при проектировании: стратегии иерархии `outline`, `indent`, `account_pattern` ставят строку ИНН (с суммами и +без) на тот же уровень, что и наименования (OQ-130 отключался), то есть дерево до склейки корректно; стратегия `sums` не +проверялась. Контрольная сумма ИНН на golden: реальные ИНН (≈ 5 852) верны, кроме трёх (физлица: Цимлянский, ОСВ 76, +`611801003692`; Новопетровская `233370449085`; НПЗ `614002279560`), вся синтетика (`5001234567`, `7701234567`, +`7812345678` и др.) неверна. + +## Decisions + +Решения владельца в brainstorming 13.10.2026: + +1. **Подход A — один проход по дереву.** Новый модуль `api/src/parser/inn_rows.py` работает между `resolve_levels` и + `classify_rows`. Он заменяет `_glue_inn_rows` (OQ-130, до иерархии) и `glue_inn_attributes` (#32, после + классификации). Классификатор получает дерево без строк ИНН и ИНН для строк-наименований. +2. **Контрольная сумма.** Без подсказки в шапке строка — строка ИНН только при верной контрольной сумме ИНН (алгоритм ФНС: + 10 цифр — весовые коэффициенты 2, 4, 10, 3, 5, 9, 4, 6, 8; 12 цифр — два контрольных разряда, 7, 2, 4, 10, 3, 5, 9, 4, + 6, 8 и 3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8), во всех восьми раскладках, включая прежнюю OQ-130. С подсказкой + `<группировка>.ИНН` контрольная сумма не проверяется (файл банка #32 не меняется). +3. **Неверная контрольная сумма.** Строка из 10/12 цифр (по `inn_only_row`) рядом с наименованием, не прошедшая + контрольную сумму без подсказки, — не строка ИНН: классифицируется как обычно, предупреждение «строка N похожа на ИНН + …, контрольная сумма не сходится», документ `needs_check`. +4. **Ведущий ноль (#34).** Числовая ячейка строки ИНН из 9/11 цифр дополняется нулём слева (как R-24 для колонки), + контрольная сумма проверяется после дополнения. +5. **«Над, в дереве»** — склейка только с единственным ребёнком; несколько детей — не склеивать, `needs_check`. +6. **Направление для соседей** (предложено в дизайне, не оспорено): пары с суммами — по совпадению сумм; без сумм — по + направлению документа: порядок в шапке → пары по суммам → однозначные блоки. Противоречие или нет однозначных блоков — + не склеивать, `needs_check`. +7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32). +8. **Строка ИНН — только ИНН**: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП» (`inn_only_row`); счёт строкой ИНН не + бывает. + +### Алгоритм `inn_rows.py` + +Вход: строки `BodyRow`, `Levels` (уровень и родитель), подсказка шапки (`InnHint`: есть ли `<группировка>.ИНН` и +направление по порядку элементов), позиции схемы группировок (для защиты, п. «Защита» ниже), словари. Выход: строки и +`Levels` без склеенных строк ИНН, ИНН для строк-наименований (`row → inn`), множество строк ИНН без пары (классификатор +ставит им `OTHER`), предупреждения, признак «всё склеено». + +1. **Кандидаты.** Строка ИНН — не счёт, не «Итого», не строка Вал./Кол.; подпись совпадает с `inn_only_row` (числовая + ячейка из 9/11 цифр — с ведущим нулём); без подсказки ИНН проходит контрольную сумму (иначе п. 3 решений). С + подсказкой пустая подпись строки-ребёнка наименования (только положение «под, в дереве») — тоже кандидат, «ИНН не + указан» (R-1 #32). +2. **Наименование** — строка не счёт, не «Итого», не Вал./Кол., не кандидат. +3. **Положение кандидата** (по `Levels`): + - есть дети → **над, в дереве**: пара — единственный ребёнок-наименование; он и его поддерево поднимаются на уровень + вверх, родителем становится родитель кандидата; + - родитель — наименование → **под, в дереве**: пара — родитель; дети кандидата переходят к родителю; + - иначе (родитель — счёт или корень, детей нет) → **не в дереве**: пара — сосед того же родителя, непосредственно + выше или ниже. + Связь в дереве важнее соседства. +4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе + пары нет. +5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа. +6. **Направление документа** (лист ОСВ), первое определённое: + 1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над; + 2. пары соседей, найденные по суммам (п. 5, один совпавший сосед); + 3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не + сопоставленных; начинается с I и не кончается I — над; кончается I и не начинается I — под; начинается и кончается I + — блок сломан (его кандидаты без пары); ни то ни другое — нет сведений. + Сведения пп. 2–3 противоречат друг другу или их нет — направление неизвестно. +7. **Соседи без сумм**: пара — соседнее наименование по направлению (над → следующий сосед, под → предыдущий). + Направление неизвестно — пары нет. +8. **Конфликты**: два кандидата претендуют на одно наименование — оба без пары. ИНН в колонке ИНН у наименования отличается + от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает + (прежний порядок §1.3). +9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к + контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). +10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32), + предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`. + +**Защита «над, в дереве».** Контрагент, названный ИНН, с одним договором неотличим по форме от строки ИНН над +контрагентом. Если шапка задаёт группировки (позиции схемы) и глубина поддерева кандидата не превышает схему (лишнего +уровня нет), кандидат не склеивается и классифицируется по схеме; без схемы — по пп. 1–10. + +### Изменения в коде + +- `inn_rows.py` — новый модуль (пп. 1–10); контрольная сумма ИНН — `numbers.py` (`inn_checksum_ok`). +- `header.py`: `InnAttribute` и позиции-реквизиты удаляются; `GroupSchema` получает подсказку (`inn_hint`: владелец и + направление); `<группировка>.ИНН` распознаётся и **перед** группировкой-владельцем (сейчас только после). +- `classify.py`: удаляются `_glue_inn_rows`, `glue_inn_attributes`, `_not_glued_reason`, поля `is_inn_attribute`, + `attribute_inn`; `_classify_row` берёт ИНН строки из результата `inn_rows` вместо `inn_above` и ставит `OTHER` + строкам без пары. +- `hierarchy.py`: поле `BodyRow.inn_above` удаляется. +- `osv.py`: вызов `inn_rows` между `resolve_levels` и `classify_rows`; признак «всё склеено» и предупреждения — в статус и + `warnings`. + +### Тесты + +- Юнит-тесты `inn_rows.py` на деревьях вручную: распознавание (10/12, число/текст, «ИНН …», «ИНН/КПП», ведущий ноль), + контрольная сумма с подсказкой и без, все отказы пп. 4–8 и 10, защита «над, в дереве», направление (шапка, суммы, + блоки, противоречие, нет сведений, сломанный блок), расхождение с колонкой, R-1. +- Синтетика `s20`…`s27` (по одной на раскладку, ИНН с верной контрольной суммой, шапка без подсказки), golden — после + ручной проверки: ИНН у всех контрагентов, итоги сходятся, `done`. +- `s18`: ИНН с верной контрольной суммой (только в `s18`, общие узлы генератора не меняются). +- Сравнение результатов main и ветки на всех файлах fixtures и синтетике; ожидаемые отличия — `s18`, новые `s20`…`s27`, + файлы со строкой ИНН с неверной контрольной суммой; Новопетровская без изменений. Каждое отличие согласуется с + владельцем до `make golden-update`. + +## Decisions made without discussion + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 13.10.2026 | Защита «над, в дереве»: при известной схеме группировок склейка только при лишнем уровне в дереве | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | Файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия | +| 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка | +| 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке | +| 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки | +| 5 | 13.10.2026 | Подсказка шапки задаёт направление сильнее пар по суммам; пары по суммам склеиваются независимо от направления | Суммы однозначно указывают пару; шапка — явная настройка отчёта | Противоречие шапки и сумм не поднимается как ошибка | +| 6 | 13.10.2026 | Суммы сравниваются с `None` = 0 (в #32 — точное равенство кортежей) | Пустая ячейка и 0 в ОСВ 1С значат одно и то же | Пара по суммам у строк, различающихся только пустотой ячейки; на Новопетровской не влияет (проверяется golden) | +| 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` | +| 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста | diff --git a/openspec/changes/osv-inn-rows-layouts/proposal.md b/openspec/changes/osv-inn-rows-layouts/proposal.md new file mode 100644 index 0000000..b59c9d9 --- /dev/null +++ b/openspec/changes/osv-inn-rows-layouts/proposal.md @@ -0,0 +1,61 @@ +## Why + +Требование владельца (13.10.2026, RSHB/arm-tabular-parser#35): ОСВ, где ИНН контрагента выведен **отдельной строкой** +рядом с наименованием, должна разбираться во всех восьми раскладках: (ИНН над / под наименованием) × (в дереве / не в +дереве) × (с суммами / без сумм). Сейчас парсер знает две частные раскладки: «над, не в дереве, без сумм» (OQ-130, +`_glue_inn_rows`) и «под, в дереве, с суммами» только при подсказке «Контрагенты.ИНН» в шапке (OQ-162, #32, +`glue_inn_attributes`). Остальные шесть дают задвоение контрагентов, ИНН вместо наименования или потерю ИНН, а «под, не в +дереве, без сумм» — **дефект main**: ИНН первого контрагента молча достаётся второму, документ `done`. + +Входит RSHB/arm-tabular-parser#34: числовая ячейка строки ИНН из 9/11 цифр (потерянный ведущий ноль, R-24) не +распознаётся как ИНН. + +Срез: доработка Среза 2a (парсер ОСВ, `09-delivery-plan.md`). Репозитории: tabular-parser (код и эта спека), arm-ka-docs +(`05-domain-rules.md` §1.1, §1.3, новый OQ), arm-ka-fixtures (синтетика и golden). Таблицы DBML не затрагиваются +(`osv_entry.counterparty_inn` заполняет ядро из контракта, контракт не меняется). + +## What Changes + +- Новый модуль `api/src/parser/inn_rows.py`: один проход по дереву строк между построением иерархии и классификацией. + Находит строки ИНН, сопоставляет каждую с её контрагентом, убирает склеенную строку из дерева (дети переходят к + контрагенту), отдаёт классификатору чистое дерево и ИНН для строк контрагентов. +- Строка ИНН распознаётся по содержимому (`inn_only_row` в `osv_headers.yaml`, 10/12 цифр; число из 9/11 цифр — с + ведущим нулём, #34). Без подсказки в шапке ИНН MUST пройти проверку контрольной суммы; строка из 10/12 цифр рядом с + контрагентом с неверной контрольной суммой — не строка ИНН, предупреждение и `needs_check`. +- Подсказка в шапке — элемент `<группировка>.ИНН` (OQ-162): снимает проверку контрольной суммы и задаёт направление + порядком («Контрагенты, Контрагенты.ИНН» — под; «Контрагенты.ИНН, Контрагенты» — над; второй порядок сейчас не + распознаётся). +- Сопоставление: связь в дереве (строка ИНН — родитель единственного ребёнка или ребёнок наименования) важнее соседства; + соседи — по равенству сумм или по направлению документа (шапка → пары по суммам → однозначные блоки). Неоднозначность + — строка не склеивается, `group_type = OTHER`, предупреждение с причиной, `needs_check`. +- Удаляются `_glue_inn_rows` (OQ-130), `glue_inn_attributes` и тип `InnAttribute` в схеме классификатора (#32); поле + `BodyRow.inn_above` заменяется ИНН из соседней строки. +- Синтетика `s20`…`s27` (по одной на раскладку); `s18` получает ИНН с верной контрольной суммой. +- Требование «Классификация строк» спеки `osv-parsing` переписывается целиком: текущая версия устарела («ИНН только из + колонки», R-17) и не отражает OQ-100, OQ-130, OQ-162. + +## Non-goals + +- Контракт результата (`counterparty_inn`, `rows`, `warnings`) не меняется, `CONTRACT_VERSION` прежний. +- ИНН в тексте названия (OQ-100) и в колонке ИНН (R-15, R-24, «ИНН/КПП») — прежние правила, порядок приоритета прежний: + колонка → строка ИНН → хвост названия. +- Проверка контрольной суммы ИНН из колонки и из хвоста названия не вводится. +- Реквизиты, кроме ИНН (`Контрагенты.КПП` и др.), строками не склеиваются. +- Follow-ups #36 (флейк интеграционного теста O-1, дубли `extra` O-2) — не здесь. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `osv-parsing`: требование «Шапка документа и схема группировок» (подсказка `<группировка>.ИНН` в обоих порядках); + требование «Классификация строк» (переписано целиком: ИНН из колонки, строки ИНН, хвоста названия); новое требование + «Строки ИНН рядом с контрагентом». + +## Impact + +- Документы с ИНН строкой, у которых контрольная сумма ИНН неверна и подсказки в шапке нет, переходят из `done` в + `needs_check` (решение владельца). Какие реальные файлы fixtures затронуты, покажет сравнение main и ветки на всех + файлах; каждое отличие golden согласуется с владельцем до `make golden-update`. +- Файл банка Новопетровской (#32, подсказка в шапке) MUST NOT измениться. +- После выкладки: пересобрать контейнер парсера на стенде; в ядре правок нет. diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md new file mode 100644 index 0000000..4db1d3c --- /dev/null +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -0,0 +1,165 @@ +## MODIFIED Requirements + +### Requirement: Шапка документа и схема группировок +Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (первая непустая текстовая строка, trim), +`account_code_raw` (после «по счету»/«по счёту», как в файле: «60», «58.03»), `period_text` (после «за» до «г.»/конца), +`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки +строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее +перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки → +`has_inn = true`. Элемент вида `<группировка>.ИНН` (суффикс после последней точки — по словарю `service_columns.inn`, +префикс — имя группировки `COUNTERPARTY` этой же шапки; вариант 2 — элемент списка «субконто: …», вариант 1 — строка блока +заголовка) MUST NOT попадать в `group_schema`: группировка-владелец получает `has_inn = true`, а элемент служит +**подсказкой** для строк ИНН (требование «Строки ИНН рядом с контрагентом»): стоит после владельца — ИНН под +наименованием, перед владельцем — над. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по +`rules/periods.yaml` (§1.5, `ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме +обязательных по контракту, — `null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и +предупреждение (контракт требует строку). + +#### Scenario: Вариант 1 с тремя строками группировок +- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` +- **THEN** `group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)]`, `header_variant = 1`, + `account_code_raw = "62"`, `period_text = "3 квартал 2024 г."`, `period_quarter = "Q3"`, `is_cumulative = false`, + `data_type = "BU"`, `currency = "RUB"` + +#### Scenario: ОСВ по субсчёту и годовой период +- **WHEN** разбираются `rb/2024-Q3/сч 583_3 кв 2024.xlsx` и `rb/2024-FY/ОСВ_66 за 2024.xls` +- **THEN** у первого `account_code_raw = "58.03"`; у второго `period_quarter = "Q4"`, `is_cumulative = true` + +#### Scenario: Вариант 2 +- **WHEN** разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто: + Контрагенты, Договоры», заголовком «Субконто» +- **THEN** `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = "Q1"` + +#### Scenario: Реквизит ИНН после группировки +- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` (шапка «субконто: Контрагенты, + Контрагенты.ИНН») +- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН под наименованием» + +#### Scenario: Реквизит ИНН перед группировкой +- **WHEN** в синтетической ОСВ шапка «субконто: Контрагенты.ИНН, Контрагенты» +- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН над наименованием» + +### Requirement: Классификация строк +Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с +контрагентом»). Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр +учитывается, IGNORECASE нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или** 1–3 +заглавные буквы кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название: +`^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой +пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1); +`account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции +(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; строка ИНН без пары — OTHER; +последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. + +Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` — +исходный текст; OQ-100), а `counterparty_inn` — первым найденным из: +1. колонки ИНН: цифры текстовой ячейки («ИНН 352819549855» → «352819549855», R-15), склеенная «ИНН/КПП» (`inn_kpp_cell`) + → ИНН; результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр (ИНН регионов 01–09 с потерянным + ведущим нулём) дополняется нулём слева (R-24); +2. строки ИНН, склеенной с этой строкой (требование «Строки ИНН рядом с контрагентом»); +3. конца текста названия: 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН» + (`counterparty_inn_suffix`; «Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»). +Контрольная сумма ИНН из колонки и из названия MUST NOT проверяться. Строки «Вал.», «Кол.», трёхбуквенного кода валюты +SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST NOT занимать позицию в `group_schema` и MUST NOT +наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки, R-19); их суммы остаются в +шести полях. Остальные строки получают `data_type` шапки. Все строки, включая TOTAL, SHALL присутствовать в `rows` +(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро); склеенные строки ИНН в `rows` MUST NOT попадать. + +#### Scenario: Подразделение, контрагент с ИНН, физлицо +- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` +- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр; + «Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null` + +#### Scenario: ИНН в конце названия +- **WHEN** разбирается синтетическая ОСВ `s17` (ИНН в тексте названия с разными разделителями) +- **THEN** у контрагентов `counterparty_name` без ИНН, `counterparty_inn` — ИНН из текста, `row_value` — исходный текст + +#### Scenario: «ИНН/КПП» в одной ячейке колонки ИНН +- **WHEN** разбирается синтетическая ОСВ `s19` +- **THEN** `counterparty_inn` — 10 цифр до «/» + +#### Scenario: Название субсчёта в строке счёта +- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками» +- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст + +#### Scenario: Буквенный субсчёт +- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика) +- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается + +#### Scenario: Строки Вал./Кол. +- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.» +- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`, + `account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done` + +## ADDED Requirements + +### Requirement: Строки ИНН рядом с контрагентом +Между построением иерархии и классификацией парсер SHALL найти строки ИНН и сопоставить каждую с её строкой-наименованием +в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве +(сосед того же родителя) × с суммами / без сумм. + +**Строка ИНН** — не счёт, не «Итого», не строка Вал./Кол., подпись которой целиком — ИНН по словарю `inn_only_row` +(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр +дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН +(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН: +она классифицируется как обычно, в `warnings` — «строка N похожа на ИНН …, контрольная сумма не сходится», документ — +`needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН +«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол., +не строка ИНН. + +**Пара строки ИНН** (связь в дереве важнее соседства): +- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если шапка задаёт + группировки и поддерево строки ИНН не глубже схемы (лишнего уровня нет), строка ИНН не склеивается и классифицируется + по схеме; +- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет; +- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); + без сумм — сосед по направлению листа (над → следующий, под → предыдущий). + +Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление +листа** — первое определённое из: порядок `<группировка>.ИНН` в шапке; пары соседей, найденные по суммам; однозначные блоки +детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН — +под; начинается и кончается — блок сломан, его строки ИНН без пары). Противоречие сведений или их отсутствие — направление +неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары. + +**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»), +её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` — +«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся, +в `warnings` — расхождение, документ — `needs_check`. + +**Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N: +ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться. + +#### Scenario: Восемь раскладок без подсказки +- **WHEN** разбираются синтетические ОСВ `s20`…`s27` (по одной на раскладку; ИНН с верной контрольной суммой; шапка без + `Контрагенты.ИНН`) +- **THEN** у каждого контрагента `counterparty_inn` — ИНН его строки ИНН, строк ИНН в `rows` нет, контрагентов столько же, + сколько наименований, контрольные суммы сходятся, статус `done` + +#### Scenario: ИНН под наименованием не уходит следующему контрагенту +- **WHEN** в синтетической ОСВ `s27` (ИНН под наименованием, не в дереве, без сумм) идут «ООО Альфа», ИНН Альфы, + «ООО Бета», ИНН Беты +- **THEN** ИНН Альфы — у «ООО Альфа», ИНН Беты — у «ООО Бета» + +#### Scenario: ИНН над наименованием (OQ-130) +- **WHEN** разбирается синтетическая ОСВ `s18` (строка ИНН без сумм над наименованием, ИНН с верной контрольной суммой) +- **THEN** ИНН — у контрагента под строкой, строк ИНН в `rows` нет, статус `done` + +#### Scenario: Реквизит «Контрагенты.ИНН» (OQ-162) +- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` +- **THEN** результат совпадает с golden до изменения: 713 из 716 контрагентов с ИНН (включая ИНН с неверной контрольной + суммой), 3 физлица без ИНН, статус `done` + +#### Scenario: Неверная контрольная сумма без подсказки +- **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится) +- **THEN** строка не склеивается, в `warnings` — «похожа на ИНН, контрольная сумма не сходится», статус `needs_check` + +#### Scenario: Ведущий ноль у числовой строки ИНН +- **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму) +- **THEN** `counterparty_inn = "0123456788"` + +#### Scenario: Неоднозначное направление +- **WHEN** под счётом блок «наименование, ИНН без сумм, наименование» и других сведений о направлении нет +- **THEN** строка ИНН без пары, `group_type = OTHER`, предупреждение «направление неизвестно», статус `needs_check` + +#### Scenario: Несколько детей у строки ИНН +- **WHEN** у строки ИНН в дереве два ребёнка-наименования +- **THEN** строка ИНН без пары, `group_type = OTHER`, статус `needs_check` -- 2.47.2 From a2744b3e404f6534cbc36b25c9cf6c81356d7059 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:20:56 +0400 Subject: [PATCH 02/12] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=20=D1=81=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=BC=20(#35);=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D0=B0=20=E2=80=94=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20=D0=BF=D0=BE=20=D0=BB=D0=B8=D1=81=D1=82=D1=83,?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=20=C2=AB?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B5=D0=B7.=C2=BB,=20=D0=98=D0=9D=D0=9D=20?= =?UTF-8?q?=D1=81=20=D0=B4=D0=B5=D1=82=D1=8C=D0=BC=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-13-osv-inn-rows-layouts.md | 1899 +++++++++++++++++ .../changes/osv-inn-rows-layouts/design.md | 16 +- .../specs/osv-parsing/spec.md | 13 +- 3 files changed, 1919 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md diff --git a/docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md b/docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md new file mode 100644 index 0000000..35dd114 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md @@ -0,0 +1,1899 @@ +# Строки ИНН рядом с контрагентом — 8 раскладок: план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Парсер ОСВ находит строку ИНН рядом с наименованием контрагента в любой из восьми раскладок (над/под × в дереве/не в дереве × с суммами/без) и отдаёт ИНН своему контрагенту; неоднозначное — `needs_check`. + +**Architecture:** Новый модуль `api/src/parser/inn_rows.py` — один проход по дереву строк между `resolve_levels` и `classify_rows`: распознаёт строки ИНН (без подсказки шапки — только с верной контрольной суммой), сопоставляет их с наименованием (связь в дереве, затем соседи по суммам и по направлению листа), убирает склеенные строки из дерева и отдаёт классификатору ИНН по строкам и множество строк без пары. Старые склейки `_glue_inn_rows` (OQ-130) и `glue_inn_attributes` (#32) удаляются; подсказка шапки `<группировка>.ИНН` становится `InnHint` (направление по порядку). + +**Tech Stack:** Python 3.11, pytest, ruff, mypy; openpyxl (генератор синтетики); OpenSpec. + +**Spec:** `tabular-parser/openspec/changes/osv-inn-rows-layouts/` (proposal.md, design.md, specs/osv-parsing/spec.md). Issue RSHB/arm-tabular-parser#35 (входит #34). + +**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/.wt/inn-rows-35/` — `tabular-parser/`, `fixtures/`, `docs/` на ветке `feat/35-inn-rows-around-counterparty`. Ниже `$WT` = этот путь. Git — только `git -C $WT/`. Команды Python — из `$WT/tabular-parser/api` с `ENV_FILE=.env.test .venv/bin/python -m pytest …`; `make` — из `$WT/tabular-parser`. codegraph: `codegraph_explore` с `projectPath` = `$WT/tabular-parser`. + +## Global Constraints + +- Язык комментариев, докстрингов, предупреждений, коммитов — русский; идентификаторы — английские. +- Никаких координат в `src/parser/**` (кроме `grid.py`, `readers/`): AST-линтер `tests/unit/parser/test_no_coordinates.py` — литералы букв колонок, адреса ячеек, сравнения номера строки с числом, `sheet.cell(<константа>, …)` запрещены. +- Деньги — `Decimal`, `float` в домене нет. +- Контракт результата не меняется (`CONTRACT_VERSION` прежний), поля `counterparty_inn`, `rows`, `metrics.warnings` — те же. +- Порядок источников ИНН контрагента: колонка ИНН → строка ИНН → хвост названия (OQ-100). +- Без подсказки `<группировка>.ИНН` строка ИНН MUST пройти контрольную сумму ИНН (ФНС); с подсказкой — не проверяется. +- Строка ИНН без пары — `group_type = OTHER`, остаётся в `rows`, документ `needs_check`. +- Тексты предупреждений склейки при подсказке совпадают с #32 побайтно: `строка N: ИНН X («Контрагенты.ИНН») отнесён к контрагенту в строке M`, `строка N: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке M без ИНН` — golden Новопетровской (`osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls`) MUST NOT измениться. +- Golden-снимки правятся только `make golden-update`, после согласования diff с владельцем (Task 6), отдельным коммитом в fixtures. +- Каждое решение без обсуждения — строкой в `openspec/changes/osv-inn-rows-layouts/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). Маркеры находок T/C/I/M/R/O — по CLAUDE.md. +- Перед каждым коммитом в tabular-parser — `make check` из `$WT/tabular-parser` зелёный (≈ 2 мин). Коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 `. +- Валидные ИНН для тестов (контрольная сумма верна): `7701234560`, `7707654321`, `5001234561`, `7812345675`, `2370012036`, `3528052718`, `500100732259`, `0123456788`. Неверные: `7701234567`, `5001234567`, `7812345678`. + +## Review Focus + +1. **Числовая ячейка строки ИНН** (1С пишет ИНН числом; xls даёт целое `Decimal`, 9/11 цифр — потерянный ноль) — распознаётся как ИНН с ведущим нулём; тест — Task 1 (`row_inn`) и Task 2 (`test_number_with_lost_leading_zero_is_restored`). +2. **Контрагент, у которого вместо имени ИНН, с одним договором** (схема «Контрагенты, Договоры», лишнего уровня нет) — не склеивается, договор не становится контрагентом; тест — Task 2 (`test_no_extra_level_keeps_inn_named_counterparty`). +3. **Строка ИНН с неверной контрольной суммой рядом с наименованием без подсказки** — не склеивается, предупреждение, `needs_check`; одиночный «контрагент-число» без соседей — без предупреждения; тесты — Task 2. +4. **Лист, где направление не выводится** (каждый блок «имя, ИНН, имя») — строки ИНН без пары, `needs_check`, а не склейка «на удачу»; тест — Task 3 (`test_ambiguous_block_leaves_inn_unpaired`). +5. **Файл банка #32 (подсказка «под», пустые ИНН физлиц, ИНН числом)** — результат и тексты предупреждений неизменны; тест — Task 4 (существующие `test_inn_subkonto.py`) и Task 6 (golden без diff по этому файлу). + +--- + +### Task 1: Контрольная сумма ИНН и ИНН строки (`numbers.py`) + +**Files:** +- Modify: `api/src/parser/numbers.py` (после `inn_only`, конец файла; импорт `Cell` уже есть — проверить) +- Test: `api/tests/unit/parser/test_numbers.py` + +**Interfaces:** +- Produces: `inn_checksum_ok(inn: str) -> bool`; `row_inn(cell: Cell, rules: Rules) -> str | None`. + +- [ ] **Step 1: Write the failing tests** — дописать в конец `api/tests/unit/parser/test_numbers.py`: + +```python +# --- Контрольная сумма ИНН и ИНН строки (#35) --- + + +@pytest.mark.parametrize( + ("inn", "ok"), + [ + ("7701234560", True), + ("7707654321", True), + ("2370012036", True), + ("0123456788", True), + ("500100732259", True), + ("7701234567", False), + ("5001234567", False), + ("500100732258", False), + ("770123456", False), + ("77012345601", False), + ("77012345a0", False), + ], +) +def test_inn_checksum_ok(inn: str, ok: bool) -> None: + assert inn_checksum_ok(inn) is ok + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +@pytest.mark.parametrize( + ("cell", "expected"), + [ + (num(7701234560), "7701234560"), + (num(123456788), "0123456788"), # 9 цифр числом — потерянный ведущий ноль (R-24, #34) + (num(50010073225), "050010073225"), # 11 цифр числом + (text("7701234560"), "7701234560"), + (text("ИНН 7701234560"), "7701234560"), + (text("ИНН: 7701234560 / 770101001"), "7701234560"), + (text("123456788"), None), # текст из 9 цифр нулём не дополняется + (text("ООО Альфа 7701234560"), None), # не только ИНН + (num(Decimal("7701234560.5")), None), + (EMPTY_CELL, None), + ], +) +def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None: + assert row_inn(cell, rules) == expected +``` + +и расширить импорты в начале файла: + +```python +from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money +from src.parser.rules import Rules, load_rules +``` + +(если фикстура `rules` в файле уже есть — второй не добавлять, использовать существующую). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_numbers.py -q -k "checksum or row_inn"` +Expected: FAIL — `ImportError: cannot import name 'inn_checksum_ok'`. + +- [ ] **Step 3: Implement** — в `api/src/parser/numbers.py` рядом с прочими константами модуля: + +```python +# Контрольная сумма ИНН (алгоритм ФНС): весовые коэффициенты контрольных разрядов; остаток от деления на 11, затем на 10 +_INN10_WEIGHTS = (2, 4, 10, 3, 5, 9, 4, 6, 8) +_INN12_WEIGHTS_FIRST = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_INN12_WEIGHTS_SECOND = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_CHECKSUM_MODULUS = 11 +_CHECK_DIGIT_MODULUS = 10 +_INN_LEGAL_LENGTH = 10 +_INN_PERSON_LENGTH = 12 +``` + +и в конец файла: + +```python +def _check_digit(digits: str, weights: tuple[int, ...]) -> int: + total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False)) + return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS + + +def inn_checksum_ok(inn: str) -> bool: + """Контрольная сумма ИНН по алгоритму ФНС: у 10 цифр — последний разряд, у 12 — два последних (#35). + + Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки. + """ + if not inn.isdigit(): + return False + if len(inn) == _INN_LEGAL_LENGTH: + return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1]) + if len(inn) == _INN_PERSON_LENGTH: + return ( + _check_digit(inn, _INN12_WEIGHTS_FIRST) == int(inn[-2]) + and _check_digit(inn, _INN12_WEIGHTS_SECOND) == int(inn[-1]) + ) + return False + + +def row_inn(cell: Cell, rules: Rules) -> str | None: + """ИНН строки, где написан только он (строка ИНН, `inn_rows`): число — 10/12 цифр или 9/11 с потерянным ведущим + нулём (R-24, #34, как у колонки ИНН); текст — по `inn_only_row` («7701234560», «ИНН 7701234560 / 770101001»).""" + if cell.kind == "number": + return inn_digits(cell) + if cell.kind != "text": + return None + return inn_only(cell.text, rules) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_numbers.py -q` +Expected: PASS. + +- [ ] **Step 5: `make check` и коммит** + +```bash +cd $WT/tabular-parser && make check +git -C $WT/tabular-parser add api/src/parser/numbers.py api/tests/unit/parser/test_numbers.py +git -C $WT/tabular-parser commit -m "ОСВ: контрольная сумма ИНН и ИНН строки (#35) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Модуль `inn_rows.py` — распознавание и склейка в дереве + +**Files:** +- Create: `api/src/parser/inn_rows.py` +- Modify: `api/src/parser/header.py` (только добавить dataclass `InnHint`, без изменения поведения) +- Modify: `api/src/parser/classify.py` (переименовать `_row_data_type` → публичная `row_data_type`, все вызовы; добавить в `__all__`) +- Test: `api/tests/unit/parser/test_inn_rows.py` (новый) + +**Interfaces:** +- Consumes: `numbers.inn_checksum_ok`, `numbers.row_inn`, `numbers.column_inn` (Task 1); `hierarchy.BodyRow`, `hierarchy.Levels`, `hierarchy.parents`. +- Produces: + - `header.InnHint(name: str, direction: Literal["above", "below"])` — frozen dataclass; + - `classify.row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None`; + - `inn_rows.InnGlue` — frozen dataclass: `rows: list[BodyRow]`, `levels: Levels`, `inns: dict[int, str]`, `unpaired: frozenset[int]`, `warnings: list[str]`, `ok: bool`; + - `inn_rows.glue_inn_rows(rows: Sequence[BodyRow], levels: Levels, total_row: int | None, sheet: Sheet, *, inn_col: int | None, schema: Sequence[OsvGroup], hint: InnHint | None, rules: Rules) -> InnGlue`. + В этой задаче строки ИНН **не в дереве** (соседи) остаются без пары с причиной «направление строк ИНН в листе не определено» — Task 3 заменяет это сопоставлением соседей. + +- [ ] **Step 1: Write the failing tests** — `api/tests/unit/parser/test_inn_rows.py`: + +```python +"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`, #35). + +Тесты модуля `inn_rows` на деревьях, собранных вручную: строка листа — (уровень, подпись, шесть сумм, ИНН колонки). +Подпись — колонка 1, колонка ИНН — 2, суммы — 3…8; уровни — как у стратегии outline, родители — `hierarchy.parents`. +""" + +from decimal import Decimal + +import pytest +from gridbuilder import sheet_from_rows + +from src.contract.result import OsvGroup +from src.parser.classify import ACCOUNT_RE +from src.parser.header import InnHint +from src.parser.hierarchy import BodyRow, Levels, parents +from src.parser.inn_rows import InnGlue, glue_inn_rows +from src.parser.rules import Rules, load_rules + +Sums = tuple[object, object, object, object, object, object] +NO: Sums = (None, None, None, None, None, None) +A: Sums = (None, None, 100, 40, 60, None) +B: Sums = (10, None, None, 30, None, 20) +INN_A = "7701234560" +INN_B = "7707654321" +INN_12 = "500100732259" +BAD = "7701234567" # контрольная сумма не сходится +LABEL_COL = 1 +INN_COL = 2 +SCHEMA = [OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="COUNTERPARTY", name="Контрагенты")] +SCHEMA_WITH_CONTRACTS = [*SCHEMA, OsvGroup(type="CONTRACT", name="Договоры")] +BELOW = InnHint("Контрагенты.ИНН", "below") +ABOVE = InnHint("Контрагенты.ИНН", "above") + +Row = tuple[int, object, Sums, object] + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def r(level: int, label: object, sums: Sums = NO, inn: object = None) -> Row: + return level, label, sums, inn + + +def run( + rules: Rules, *rows: Row, hint: InnHint | None = None, schema: list[OsvGroup] | None = None +) -> InnGlue: + sheet = sheet_from_rows([[label, inn, *sums] for _, label, sums, inn in rows]) + body: list[BodyRow] = [] + for number, (level, _, sums, _) in enumerate(rows, start=1): + text = sheet.text_at(number, LABEL_COL) + account = ACCOUNT_RE.match(text) + body.append( + BodyRow( + row=number, + text=text, + is_account=account is not None, + account_rank=None if account is None else account.group(1).count(".") + 1, + bold=False, + indent=0, + outline=level, + sums=tuple(None if value is None else Decimal(str(value)) for value in sums), + label_col=LABEL_COL, + ) + ) + by_row = {item.row: level for item, (level, *_) in zip(body, rows, strict=True)} + levels = Levels(by_row=by_row, parent=parents(body, by_row), strategy="outline") + return glue_inn_rows( + body, levels, None, sheet, inn_col=INN_COL, schema=schema or SCHEMA, hint=hint, rules=rules + ) + + +def texts(glue: InnGlue) -> list[str]: + return [item.text for item in glue.rows] + + +def has_warning(glue: InnGlue, *parts: str) -> bool: + return any(all(part in warning for part in parts) for warning in glue.warnings) + + +# --- ИНН под наименованием, в дереве --- + + +def test_inn_below_in_tree_with_sums_is_glued(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A)) + + assert texts(glue) == ["60", "ООО Альфа"] + assert glue.inns == {2: INN_A} + assert (glue.unpaired, glue.ok) == (frozenset(), True) + assert has_warning(glue, "строка 3", INN_A, "отнесён к контрагенту в строке 2") + + +def test_inn_below_in_tree_without_sums_is_glued(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_12)) + + assert glue.inns == {2: INN_12} + assert glue.ok + + +def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(2, INN_A, A), + r(3, "Договор № 1", A), + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"] + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2) + + +def test_inn_with_contracts_under_counterparty_belongs_to_parent(rules: Rules) -> None: + # Без подсказки: родитель по схеме — контрагент, значит ИНН его, а дети строки ИНН — договоры + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(2, INN_A, A), + r(3, "Договор № 1", A), + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert glue.inns == {2: INN_A} + assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"] + + +def test_inn_between_department_and_name_is_above(rules: Rules) -> None: + # Родитель по схеме — подразделение: строка ИНН стоит над наименованием + schema = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), + ] + glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A, A), r(3, "ООО Альфа", A), schema=schema) + + assert glue.inns == {4: INN_A} + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2) + + +def test_two_inn_rows_under_one_name_stay_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A), r(2, INN_B)) + + assert glue.unpaired == frozenset({3, 4}) + assert not glue.ok + assert has_warning(glue, "строка 3", "несколько строк ИНН") + + +def test_inn_row_with_other_sums_stays_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, B)) + + assert glue.unpaired == frozenset({3}) + assert glue.inns == {} + assert has_warning(glue, "строка 3", "суммы не равны") + + +# --- ИНН над наименованием, в дереве --- + + +def test_inn_above_in_tree_with_sums_is_glued_and_name_moves_up(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A)) + + assert texts(glue) == ["60", "ООО Альфа"] + assert glue.inns == {3: INN_A} + assert (glue.levels.by_row[3], glue.levels.parent[3]) == (1, 1) + + +def test_inn_above_in_tree_without_sums_is_glued(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A)) + + assert glue.inns == {3: INN_A} + assert glue.ok + + +def test_inn_above_with_two_children_stays_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A), r(2, "ООО Бета", B)) + + assert glue.unpaired == frozenset({2}) + assert texts(glue) == ["60", INN_A, "ООО Альфа", "ООО Бета"] + assert has_warning(glue, "строка 2", "несколько") + + +def test_no_extra_level_keeps_inn_named_counterparty(rules: Rules) -> None: + # Схема «Контрагенты, Договоры», глубже схемы строк нет: «7701234560» — контрагент с одним договором, не строка ИНН + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "Договор № 1", A), schema=SCHEMA_WITH_CONTRACTS) + + assert texts(glue) == ["60", INN_A, "Договор № 1"] + assert (glue.inns, glue.unpaired, glue.warnings, glue.ok) == ({}, frozenset(), [], True) + + +def test_extra_level_allows_inn_above_with_contract_schema(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, INN_A, A), + r(2, "ООО Альфа", A), + r(3, "Договор № 1", A), + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert glue.inns == {3: INN_A} + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 3) + + +# --- Распознавание и контрольная сумма --- + + +@pytest.mark.parametrize( + ("label", "inn"), + [ + (Decimal(7701234560), INN_A), + (123456788, "0123456788"), # 9 цифр числом (#34) + ("ИНН 7701234560", INN_A), + ("ИНН: 7701234560 / 770101001", INN_A), + (INN_12, INN_12), + ], +) +def test_inn_row_forms(rules: Rules, label: object, inn: str) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, label, A)) + + assert glue.inns == {2: inn} + + +def test_number_with_lost_leading_zero_is_restored(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), hint=BELOW) + + assert glue.inns == {2: "0105012345"} + + +def test_bad_checksum_without_hint_is_lookalike(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A)) + + assert texts(glue) == ["60", "ООО Альфа", BAD] + assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), False) + assert has_warning(glue, "строка 3", "похожа на ИНН", "контрольная сумма не сходится") + + +def test_lone_number_named_row_without_names_around_is_silent(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, BAD, A)) + + assert (glue.warnings, glue.ok) == ([], True) + + +def test_hint_skips_checksum(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A), hint=BELOW) + + assert glue.inns == {2: BAD} + assert has_warning(glue, f"ИНН {BAD} («Контрагенты.ИНН») отнесён к контрагенту в строке 2") + + +def test_hint_below_empty_label_is_glued_without_inn(rules: Rules) -> None: + # R-1 #32: физлицо — 1С выводит строку реквизита с пустой ячейкой + glue = run(rules, r(0, "60", A), r(1, "Иванов Иван", A), r(2, None, A), hint=BELOW) + + assert texts(glue) == ["60", "Иванов Иван"] + assert (glue.inns, glue.ok) == ({}, True) + assert has_warning(glue, "строка 3: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке 2 без ИНН") + + +def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), hint=BELOW) + + assert glue.unpaired == frozenset({3}) + assert not glue.ok + assert has_warning(glue, "строка 3", "не распознан") + + +def test_column_inn_wins_over_inn_row(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A)) + + assert texts(glue) == ["60", "ООО Альфа"] + assert glue.inns == {} + assert not glue.ok + assert has_warning(glue, "строка 3", "расходится", INN_B) + + +def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A)) + + assert (glue.inns, glue.ok) == ({2: INN_A}, True) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py -q` +Expected: FAIL — `ImportError: cannot import name 'InnHint'` / `No module named 'src.parser.inn_rows'`. + +- [ ] **Step 3: Add `InnHint` and `row_data_type`** + +В `api/src/parser/header.py` рядом с `InnAttribute` (и в `__all__`): + +```python +@dataclass(frozen=True) +class InnHint: + """Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку + элементов — после группировки-владельца ИНН стоит под наименованием (`below`), перед ней — над (`above`). + + С подсказкой контрольная сумма ИНН строки не проверяется (`inn_rows`).""" + + name: str + direction: Literal["above", "below"] +``` + +(`Literal` — импортировать из `typing`, если ещё нет.) + +В `api/src/parser/classify.py`: `_row_data_type` → `row_data_type` (определение и вызов в `_classify_row`), добавить `"row_data_type"` в `__all__`. + +- [ ] **Step 4: Implement `api/src/parser/inn_rows.py`** + +```python +"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`; +`05-domain-rules.md` §1.3; OQ-130, OQ-162, #35). + +Один проход между каскадом иерархии и классификацией. Строка, где написан только ИНН, сопоставляется с наименованием +в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве +(сосед того же родителя) × с суммами / без сумм. Связь в дереве важнее соседства. Склеенная строка уходит из строк и +дерева (её дети — к наименованию), ИНН — наименованию; строка без пары остаётся (классификатор ставит ей OTHER), а +документ уходит на проверку. Классификатор получает чистое дерево. + +Без подсказки шапки (`InnHint`) строка — строка ИНН только при верной контрольной сумме; строка из 10/12 цифр с +неверной суммой рядом с наименованием — «похожа на ИНН»: остаётся как есть, предупреждение, `needs_check`. +""" + +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Literal + +from src.contract.result import OsvGroup +from src.parser.classify import row_data_type +from src.parser.grid import Cell, Sheet +from src.parser.header import InnHint +from src.parser.hierarchy import BodyRow, Levels, parents +from src.parser.numbers import column_inn, inn_checksum_ok, row_inn +from src.parser.rules import Rules + +__all__ = ["InnGlue", "glue_inn_rows"] + +Kind = Literal["total", "account", "indicator", "candidate", "name"] +_ZERO = Decimal(0) +_UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено" + + +@dataclass(frozen=True) +class InnGlue: + """Результат склейки: строки и дерево без склеенных строк ИНН, ИНН по строкам наименований, строки ИНН без пары + (классификатор ставит им OTHER), предупреждения и признак «всё склеено, похожих на ИНН строк и расхождений нет».""" + + rows: list[BodyRow] + levels: Levels + inns: dict[int, str] + unpaired: frozenset[int] + warnings: list[str] + ok: bool + + +@dataclass +class _Tree: + """Строки листа, их дерево и вид: счёт, «Итого», Вал./Кол., строка ИНН или наименование.""" + + rows: Sequence[BodyRow] + by_row: dict[int, BodyRow] + parent: dict[int, int | None] + children: dict[int | None, list[int]] + kind: dict[int, Kind] + inn: dict[int, str | None] = field(default_factory=dict) # ИНН строки ИНН; None — пустой или не распознан + lookalike: dict[int, str] = field(default_factory=dict) # 10/12 цифр с неверной контрольной суммой + + def kids(self, row: int) -> list[int]: + """Дети строки, кроме Вал./Кол. (они не уровень аналитики).""" + return [kid for kid in self.children.get(row, []) if self.kind[kid] != "indicator"] + + def candidates(self, done: "_Pairs") -> list[int]: + return [item.row for item in self.rows if self.kind[item.row] == "candidate" and not done.decided(item.row)] + + +@dataclass +class _Pairs: + partner: dict[int, int] = field(default_factory=dict) # строка ИНН -> строка наименования + reason: dict[int, str] = field(default_factory=dict) # строка ИНН без пары -> причина + flat: list[int] = field(default_factory=list) # строки ИНН не в дереве (соседи), по порядку строк + + def decided(self, row: int) -> bool: + return row in self.partner or row in self.reason + + +def _label_cell(sheet: Sheet, item: BodyRow) -> Cell: + if item.label_col is None: + return Cell(item.text or None, "text" if item.text else "empty") + return sheet.cell(*sheet.merged_origin(item.row, item.label_col)) + + +def _has_sums(item: BodyRow) -> bool: + return any(value is not None for value in item.sums) + + +def _same_sums(left: BodyRow, right: BodyRow) -> bool: + """Шесть сумм равны; пустая ячейка = 0 (в ОСВ 1С это одно и то же).""" + return [value or _ZERO for value in left.sums] == [value or _ZERO for value in right.sums] + + +def _build(rows: Sequence[BodyRow], levels: Levels, total_row: int | None, sheet: Sheet, hint: InnHint | None, rules: Rules) -> _Tree: # noqa: PLR0913 - всё нужное для вида строки приходит явно + currency = frozenset(rules.data_types.currency_codes) + children: dict[int | None, list[int]] = {} + for item in rows: + children.setdefault(levels.parent[item.row], []).append(item.row) + tree = _Tree(rows, {item.row: item for item in rows}, dict(levels.parent), children, {}) + for item in rows: + tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree) + if hint is not None and hint.direction == "below": + # «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения + for item in rows: + parent = tree.parent[item.row] + if tree.kind[item.row] == "name" and not item.text and parent is not None and tree.kind[parent] == "name": + tree.kind[item.row] = "candidate" + tree.inn[item.row] = None + return tree + + +def _kind( # noqa: PLR0913 - вид строки зависит от всех этих данных + item: BodyRow, + total_row: int | None, + sheet: Sheet, + hint: InnHint | None, + rules: Rules, + currency: frozenset[str], + tree: _Tree, +) -> Kind: + if total_row is not None and item.row >= total_row: + return "total" + if item.is_account: + return "account" + if row_data_type(item.text, rules, currency) is not None: + return "indicator" + found = row_inn(_label_cell(sheet, item), rules) + if found is None: + return "name" + if hint is None and not inn_checksum_ok(found): + tree.lookalike[item.row] = found + return "name" + tree.inn[item.row] = found + return "candidate" + + +def _depths(tree: _Tree) -> dict[int, int]: + """Позиция строки в схеме группировок, как у классификатора: счёт и «Итого» — 0, Вал./Кол. — позиция родителя, + прочие — позиция родителя + 1 (строки ИНН считаются уровнем: склейки ещё не было).""" + depth: dict[int, int] = {} + for item in tree.rows: + parent = tree.parent[item.row] + base = 0 if parent is None else depth.get(parent, 0) + kind = tree.kind[item.row] + if kind in {"account", "total"}: + depth[item.row] = 0 + elif kind == "indicator": + depth[item.row] = base + else: + depth[item.row] = base + 1 + return depth + + +def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool: + """Защита «над, в дереве»: схема задаёт группировки и ни одна строка листа не стоит глубже схемы — лишнего уровня + нет, строка ИНН с детьми — наименование по схеме (контрагент, названный ИНН, с одним договором).""" + deepest = len(schema) - 1 + return deepest >= 1 and all(value <= deepest for value in depth.values()) + + +def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool: + """Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка + шапки решает; без неё — «под», если родитель по схеме — контрагент (design, решение без обсуждения №10).""" + if hint is not None: + return hint.direction == "below" + position = depth[parent] + return position < len(schema) and schema[position].type == "COUNTERPARTY" + + +def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: + """С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка + реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9).""" + if hint is None or hint.direction != "below": + return + for item in tree.rows: + if tree.kind[item.row] != "name": + continue + kids = tree.kids(item.row) + if len(kids) != 1: + continue + kid = tree.by_row[kids[0]] + if tree.kind[kid.row] == "name" and kid.text and _same_sums(kid, item): + tree.kind[kid.row] = "candidate" + tree.inn[kid.row] = None + pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)" + + +def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None: + """Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми — + над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`.""" + depth = _depths(tree) + no_extra_level = _no_extra_level(depth, schema) + for row in tree.candidates(pairs): + kids = tree.kids(row) + parent = tree.parent[row] + below = parent is not None and tree.kind[parent] == "name" + if below and kids and parent is not None: + below = _owned_by_parent(parent, depth, schema, hint) + if below and parent is not None: + rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"] + if len(rivals) > 1: + pairs.reason[row] = f"у контрагента в строке {parent} несколько строк ИНН" + else: + pairs.partner[row] = parent + elif kids: + if no_extra_level: + tree.kind[row] = "name" + elif len(kids) > 1: + pairs.reason[row] = f"под ней несколько строк ({len(kids)}), а не одно наименование" + elif tree.kind[kids[0]] != "name": + pairs.reason[row] = "под ней не наименование" + else: + pairs.partner[row] = kids[0] + else: + pairs.flat.append(row) + + +def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: # noqa: ARG001 - hint нужен в Task 3 + """Строки ИНН не в дереве (соседи наименования) — Task 3; пока без пары.""" + for row in pairs.flat: + pairs.reason[row] = _UNKNOWN_DIRECTION + + +def _check_sums(tree: _Tree, pairs: _Pairs) -> None: + for row, partner in list(pairs.partner.items()): + if _has_sums(tree.by_row[row]) and not _same_sums(tree.by_row[row], tree.by_row[partner]): + del pairs.partner[row] + pairs.reason[row] = f"суммы не равны суммам строки {partner}" + + +def _drop_conflicts(pairs: _Pairs) -> None: + claimed = Counter(pairs.partner.values()) + for row, partner in list(pairs.partner.items()): + if claimed[partner] > 1: + del pairs.partner[row] + pairs.reason[row] = f"на строку {partner} претендуют несколько строк ИНН" + + +def _near_name(tree: _Tree, row: int) -> bool: + """Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование.""" + parent = tree.parent[row] + siblings = tree.children.get(parent, []) + index = siblings.index(row) + near = [*siblings[max(index - 1, 0) : index], *siblings[index + 1 : index + 2], *tree.children.get(row, [])] + if parent is not None: + near.append(parent) + return any(tree.kind[other] == "name" for other in near) + + +def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]: + """Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень, родители пересчитываются.""" + if not removed: + return list(rows), levels + lifted: dict[int, int] = {} + by_row: dict[int, int] = {} + kept: list[BodyRow] = [] + for item in rows: + parent = levels.parent[item.row] + lift = 0 if parent is None else lifted[parent] + (1 if parent in removed else 0) + lifted[item.row] = lift + if item.row in removed: + continue + by_row[item.row] = levels.by_row[item.row] - lift + kept.append(item) + return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy) + + +def _result( # noqa: PLR0913 - сборка результата из всех частей прохода + rows: Sequence[BodyRow], + levels: Levels, + tree: _Tree, + pairs: _Pairs, + sheet: Sheet, + inn_col: int | None, + hint: InnHint | None, + rules: Rules, +) -> InnGlue: + label = "" if hint is None else f" («{hint.name}»)" + warnings: list[str] = [] + inns: dict[int, str] = {} + ok = not pairs.reason + for item in rows: + row = item.row + inn = tree.inn.get(row) + if row in pairs.partner: + partner = pairs.partner[row] + if inn is None: + warnings.append( + f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН" + ) + continue + column = None if inn_col is None else column_inn(sheet.cell(partner, inn_col), rules) + if column is not None and column != inn: + ok = False + warnings.append( + f"строка {row}: ИНН {inn}{label} расходится с ИНН {column} колонки ИНН контрагента в строке " + f"{partner} — оставлен ИНН колонки" + ) + continue + inns[partner] = inn + warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}") + elif row in pairs.reason: + shown = "" if inn is None else f" {inn}" + warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}") + elif row in tree.lookalike and tree.kind[row] == "name" and _near_name(tree, row): + ok = False + warnings.append( + f"строка {row}: похожа на ИНН {tree.lookalike[row]}, но контрольная сумма не сходится — строка " + "оставлена как есть" + ) + kept, kept_levels = _without(rows, levels, set(pairs.partner)) + return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok) + + +def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно + rows: Sequence[BodyRow], + levels: Levels, + total_row: int | None, + sheet: Sheet, + *, + inn_col: int | None, + schema: Sequence[OsvGroup], + hint: InnHint | None, + rules: Rules, +) -> InnGlue: + """Строки ИНН рядом с наименованием → ИНН наименованию (spec «Строки ИНН рядом с контрагентом»). + + `levels` — дерево каскада иерархии со строками ИНН; `schema` — группировки шапки (`group_schema`) для защиты «над, + в дереве»; `hint` — подсказка `<группировка>.ИНН` (без неё ИНН строки должен пройти контрольную сумму). + """ + tree = _build(rows, levels, total_row, sheet, hint, rules) + pairs = _Pairs() + _unrecognised_attributes(tree, hint, pairs) + _pair_in_tree(tree, schema, hint, pairs) + _pair_flat(tree, hint, pairs) + _check_sums(tree, pairs) + _drop_conflicts(pairs) + return _result(rows, levels, tree, pairs, sheet, inn_col, hint, rules) +``` + +Примечания для исполнителя: +- `_build` создаёт `_Tree` с пустым `kind` и заполняет его в цикле — `_kind` пишет в `tree.inn`/`tree.lookalike`. +- Строка `"60"` в тестах — счёт (`ACCOUNT_RE`), её `kind` — `account`; «Итого» в тестах нет (`total_row=None`). +- Если ruff требует переформатировать длинную сигнатуру `_build` — `ruff format`; правила `noqa` оставить только те, что ruff действительно требует (лишние `noqa` ruff помечает `RUF100`). + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py tests/unit/parser/test_classify.py -q` +Expected: PASS (`test_classify` не меняется — модуль ещё не подключён). + +- [ ] **Step 6: `make check` и коммит** + +```bash +cd $WT/tabular-parser && make check +git -C $WT/tabular-parser add api/src/parser/inn_rows.py api/src/parser/header.py api/src/parser/classify.py api/tests/unit/parser/test_inn_rows.py +git -C $WT/tabular-parser commit -m "ОСВ: модуль строк ИНН — склейка в дереве и контрольная сумма (#35) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Модуль `inn_rows.py` — соседи и направление листа + +**Files:** +- Modify: `api/src/parser/inn_rows.py` (`_pair_flat` и новые помощники) +- Test: `api/tests/unit/parser/test_inn_rows.py` (дописать) + +**Interfaces:** +- Consumes: всё из Task 2 (`_Tree`, `_Pairs`, `_has_sums`, `_same_sums`, `InnHint`). +- Produces: `glue_inn_rows` сопоставляет строки ИНН не в дереве; сигнатура не меняется. + +- [ ] **Step 1: Write the failing tests** — дописать в `api/tests/unit/parser/test_inn_rows.py`: + +```python +# --- Не в дереве: соседи того же родителя --- + + +def test_inn_above_sibling_without_sums(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B)) + + assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"] + assert glue.inns == {3: INN_A, 5: INN_B} + assert glue.ok + + +def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None: + # Дефект main: ИНН первого контрагента доставался второму + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B)) + + assert glue.inns == {2: INN_A, 4: INN_B} + assert glue.ok + + +def test_inn_above_sibling_with_sums(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B)) + + assert glue.inns == {3: INN_A, 5: INN_B} + + +def test_inn_below_sibling_with_sums(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B)) + + assert glue.inns == {2: INN_A, 4: INN_B} + + +def test_sums_match_no_neighbour(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A)) + + assert glue.unpaired == frozenset({3}) + assert has_warning(glue, "строка 3", "суммы не совпали") + + +def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None: + rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A)) + + assert run(rules, *rows).unpaired == frozenset({3}) + assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A} + + +def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B)) + + assert glue.unpaired == frozenset({3}) + assert not glue.ok + assert has_warning(glue, "строка 3", "направление") + + +def test_direction_from_other_block(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "60.01", A), + r(2, "ООО Альфа", A), + r(2, INN_A), + r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт + r(1, "60.02", B), + r(2, "ООО Гамма", B), + r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием + ) + + assert glue.inns == {3: INN_A, 7: INN_B} + + +def test_direction_from_sum_pair(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B), r(1, "Иванов Иван", B)) + + assert glue.inns == {2: INN_A, 4: INN_B} + + +def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "60.01", A), + r(2, INN_A), + r(2, "ООО Альфа", A), # начинается строкой ИНН — над + r(1, "60.02", B), + r(2, "ООО Бета", B), + r(2, INN_B), # кончается строкой ИНН — под + ) + + assert glue.unpaired == frozenset({3, 7}) + + +def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B)) + + assert glue.unpaired == frozenset({2, 4}) + assert has_warning(glue, "строка 2", "начинается и заканчивается") + + +def test_hint_direction_wins_over_blocks(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE) + + assert glue.inns == {4: BAD} + + +def test_two_inn_rows_claim_one_name(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A)) + + assert glue.unpaired == frozenset({2, 4}) + assert has_warning(glue, "претендуют") + + +def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE) + + assert glue.unpaired == frozenset({3}) + assert has_warning(glue, "строка 3", "нет наименования") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py -q` +Expected: FAIL — новые тесты соседей (строки ИНН без пары с причиной «направление … не определено»). + +- [ ] **Step 3: Implement** — в `api/src/parser/inn_rows.py` заменить заглушку `_pair_flat` на: + +```python +Direction = Literal["above", "below"] + + +def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]: + """Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование).""" + siblings = tree.children.get(tree.parent[row], []) + index = siblings.index(row) + before = siblings[index - 1] if index > 0 else None + after = siblings[index + 1] if index + 1 < len(siblings) else None + return ( + before if before is not None and tree.kind[before] == "name" else None, + after if after is not None and tree.kind[after] == "name" else None, + ) + + +def _block_direction(tree: _Tree, pending: list[int], pairs: _Pairs) -> set[Direction]: + """Сведения о направлении из блоков: дети одного родителя — несопоставленные строки ИНН и наименования. + + Начинается строкой ИНН — над (иначе первой строке ИНН не досталось бы владельца), кончается — под; начинается и + кончается — блок сломан, его строки ИНН без пары; ни то ни другое — сведений нет. + """ + waiting = set(pending) + claimed = set(pairs.partner.values()) + found: set[Direction] = set() + for parent in dict.fromkeys(tree.parent[row] for row in pending): + block = [ + row + for row in tree.children.get(parent, []) + if row in waiting or (tree.kind[row] == "name" and row not in claimed) + ] + starts, ends = block[0] in waiting, block[-1] in waiting + if starts and ends: + for row in block: + if row in waiting: + pairs.reason[row] = "блок строк начинается и заканчивается строкой ИНН — направление не определить" + elif starts: + found.add("above") + elif ends: + found.add("below") + return found + + +def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: + """Строки ИНН не в дереве: с суммами — сосед с теми же суммами; без сумм (или совпали оба соседа) — сосед по + направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и блоков.""" + pending: list[int] = [] + evidence: set[Direction] = set() + for row in pairs.flat: + item = tree.by_row[row] + if not _has_sums(item): + pending.append(row) + continue + before, after = _neighbours(tree, row) + matches: list[tuple[int, Direction]] = [ + (other, side) + for other, side in ((before, "below"), (after, "above")) + if other is not None and _same_sums(tree.by_row[other], item) + ] + if len(matches) == 1: + other, side = matches[0] + pairs.partner[row] = other + evidence.add(side) + elif matches: + pending.append(row) + else: + pairs.reason[row] = "суммы не совпали ни с одним соседним наименованием" + if not pending: + return + if hint is not None: + direction: Direction | None = hint.direction + else: + evidence |= _block_direction(tree, pending, pairs) + direction = next(iter(evidence)) if len(evidence) == 1 else None + for row in pending: + if row in pairs.reason: + continue + if direction is None: + pairs.reason[row] = _UNKNOWN_DIRECTION + continue + before, after = _neighbours(tree, row) + other = after if direction == "above" else before + if other is None: + where = "под" if direction == "above" else "над" + pairs.reason[row] = f"{where} строкой ИНН нет наименования" + else: + pairs.partner[row] = other +``` + +Удалить `# noqa: ARG001` у прежней заглушки (её больше нет). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py -q` +Expected: PASS (все тесты Task 2 и Task 3). + +- [ ] **Step 5: `make check` и коммит** + +```bash +cd $WT/tabular-parser && make check +git -C $WT/tabular-parser add api/src/parser/inn_rows.py api/tests/unit/parser/test_inn_rows.py +git -C $WT/tabular-parser commit -m "ОСВ: строки ИНН — соседи по суммам и направление листа (#35) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Подключение: подсказка шапки в обоих порядках, конвейер, удаление старых склеек + +**Files:** +- Modify: `api/src/parser/header.py` — `GroupSchema` без `positions`, с `inn_hint: InnHint | None`; удалить `InnAttribute`, `_attach_inn_attribute`, `_positions`; `<группировка>.ИНН` распознаётся и перед владельцем. +- Modify: `api/src/parser/classify.py` — удалить `_glue_inn_rows`, `glue_inn_attributes`, `_not_glued_reason`, поля `is_inn_attribute`/`attribute_inn`, импорт `InnAttribute`; `classify_rows` получает `inns` и `unpaired`; докстринг модуля. +- Modify: `api/src/parser/hierarchy.py` — удалить поле `BodyRow.inn_above`. +- Modify: `api/src/parser/osv.py` — шаг `glue_inn_rows` между `resolve_levels` и `classify_rows`. +- Modify: `api/tests/unit/parser/test_classify.py` — помощник `parse` с шагом склейки; тесты OQ-130 на валидные ИНН. +- Modify: `api/tests/unit/parser/test_inn_subkonto.py` — тесты подсказки «над» (вариант 2 и 1). +- Modify: `api/tests/fixtures/real_osv.py` — шаг склейки. +- Modify: `api/tests/synthetic/test_synthetic_files.py` — только если тест `s18` падает из-за текста предупреждения (см. Step 6). + +**Interfaces:** +- Consumes: `InnHint`, `glue_inn_rows`, `InnGlue` (Task 2–3). +- Produces: + - `header.GroupSchema(groups: list[OsvGroup], variant: HeaderVariant, inn_hint: InnHint | None = None)`; + - `classify.classify_rows(rows, levels, schema: Sequence[OsvGroup], header, sheet, doc_data_type, total_row, rules, *, inns: Mapping[int, str] | None = None, unpaired: AbstractSet[int] = frozenset())`. + +- [ ] **Step 1: Write the failing tests** — в `api/tests/unit/parser/test_inn_subkonto.py` дописать: + +```python +# --- Подсказка в обратном порядке: «Контрагенты.ИНН, Контрагенты» — ИНН над наименованием --- + +DETAIL_ABOVE = "Детализация по субсчетам, субконто: Контрагенты.ИНН, Контрагенты" + + +def test_inn_attribute_before_owner_gives_hint_above(rules: Rules) -> None: + sheet = _sheet(r(0, "60.01", A), r(1, 2370012036, A), r(2, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE) + + document, warnings, ok = _parse(sheet, rules) + + assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [ + ("ACCOUNT", "Субконто", None), + ("COUNTERPARTY", "Контрагенты", True), + ] + assert ok + assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"] + counterparty = document.rows[1] + assert (counterparty.counterparty_inn, counterparty.level, counterparty.parent_row_number) == ("2370012036", 1, 9) + assert any("«Контрагенты.ИНН»" in warning for warning in warnings), warnings + + +def test_hint_above_glues_sibling_without_checksum(rules: Rules) -> None: + # Подсказка снимает проверку контрольной суммы: «7701234567» — опечатка, но шапка говорит, что это ИНН + sheet = _sheet(r(0, "60.01", A), r(1, "7701234567", (None,) * 6), r(1, "ООО Альфа", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE) + + document, _, ok = _parse(sheet, rules) + + assert ok + assert [(row.row_value, row.counterparty_inn) for row in document.rows if row.group_type == "COUNTERPARTY"] == [ + ("ООО Альфа", "7701234567") + ] + + +def test_variant1_inn_attribute_row_above_owner(rules: Rules) -> None: + head: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60 за 9 месяцев 2025 г."], + [], + ["Выводимые данные: БУ (данные бухгалтерского учета)"], + [], + ["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], + ["Контрагенты.ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["Контрагенты"], + ] + sheet = sheet_from_rows([*head, r(0, "60.01", A), r(1, 2370012036, A), r(2, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A]]) + + document, _, ok = _parse(sheet, rules) + + assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [ + ("ACCOUNT", "Счет", None), + ("COUNTERPARTY", "Контрагенты", True), + ] + assert ok + assert document.rows[1].counterparty_inn == "2370012036" +``` + +(Если `_sheet` не принимает строку без сумм через `r(...)` — строка `r(1, "7701234567", (None,) * 6)` даёт пустые суммы, это ожидаемо. Если вариант 1 с меткой группировки `Контрагенты.ИНН` в строке «Дебет/Кредит» не распознаётся детектором заголовка — поменять местами строки 7 и 8 `head` так, чтобы «Контрагенты.ИНН» шла второй строкой блока (как в `_variant1_sheet`), а «Контрагенты» — третьей; цель теста — порядок «реквизит перед владельцем».) + +В `api/tests/unit/parser/test_classify.py` заменить помощник `parse` и тесты OQ-130: + +```python +def parse( + sheet: Sheet, rules: Rules, doc_data_type: DataType | None = "BU", deadline: Deadline | None = None +) -> Parsed: + deadline = deadline or Deadline(TIMEOUT_SECONDS) + header = find_table_header(sheet, rules) + schema = build_group_levels(sheet, header, rules) + body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline) + levels = resolve_levels(body, total_row, deadline) + glue = glue_inn_rows( + body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules + ) + rows = classify_rows( + glue.rows, glue.levels, schema.groups, header, sheet, doc_data_type, total_row, rules, + inns=glue.inns, unpaired=glue.unpaired, + ) + return Parsed(rows, [*warnings, *glue.warnings], glue.levels, total_row) +``` + +(импорты: `from src.parser.header import build_group_levels`, `from src.parser.inn_rows import glue_inn_rows`; `build_group_schema` убрать из импорта, если больше не нужен). + +Тесты OQ-130 (`test_inn_only_row_above_is_glued_to_next_counterparty`, `test_inn_row_above_with_label_and_kpp_forms`, `test_inn_row_above_on_other_level_is_not_glued`, `test_inn_row_above_before_row_without_sums_is_not_glued`) заменить на: + +```python +def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна) + r(1, "ООО Поставщик", S100), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик"] + supplier = parsed.rows[1] + assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == ( + "ООО Поставщик", + "7701234560", + ACCOUNT_ROW, + ) + assert any("7701234560" in warning for warning in parsed.warnings), parsed.warnings + + +def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "ИНН 7701234560 / 770101001"), + r(1, "ООО Поставщик", S100), + ) + + assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560" + + +def test_inn_row_above_in_tree_without_extra_level_is_not_glued(rules: Rules) -> None: + # Схема «Контрагенты, Договоры», глубже схемы строк нет — «7701234560» контрагент, «ООО Поставщик» под ним (защита) + sheet = osv( + r(0, "60", S100), + r(1, "7701234560", S100), + r(2, "ООО Поставщик", S100), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"] + assert parsed.one("ООО Поставщик").counterparty_inn is None + + +def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "7701234560"), + r(1, "ООО Поставщик"), + r(1, "ООО Другой", S100), + ) + + parsed = parse(sheet, rules) + + assert "7701234560" not in [row.body.text for row in parsed.rows] + assert parsed.one("ООО Поставщик").counterparty_inn == "7701234560" + + +def test_inn_row_with_bad_checksum_is_left_as_is(rules: Rules) -> None: + sheet = osv(r(0, "60", S100), r(1, "7701234567"), r(1, "ООО Поставщик", S100)) + + parsed = parse(sheet, rules) + + assert "7701234567" in [row.body.text for row in parsed.rows] + assert parsed.one("ООО Поставщик").counterparty_inn is None + assert any("контрольная сумма не сходится" in warning for warning in parsed.warnings), parsed.warnings + + +def test_unpaired_inn_row_is_other(rules: Rules) -> None: + sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100), r(1, "7701234560"), r(1, "ООО Другой", S100)) + + parsed = parse(sheet, rules) + + inn_row = parsed.one("7701234560") + assert (inn_row.group_type, inn_row.counterparty_name, inn_row.counterparty_inn) == ("OTHER", None, None) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_classify.py tests/unit/parser/test_inn_subkonto.py -q` +Expected: FAIL — `GroupSchema` без `inn_hint`, `classify_rows` без `inns`. + +- [ ] **Step 3: Header** — в `api/src/parser/header.py`: + +```python +@dataclass(frozen=True) +class GroupSchema: + """Группировки контракта (`groups`), вариант шапки и подсказка для строк ИНН (`<группировка>.ИНН`, OQ-162, #35).""" + + groups: list[OsvGroup] + variant: HeaderVariant + inn_hint: InnHint | None = None + + +def _attribute_owner(item: str, rules: Rules) -> str | None: + """Имя группировки-владельца, если `item` — реквизит ИНН («Контрагенты.ИНН» → «контрагенты»); иначе `None`.""" + owner, separator, attribute = item.rpartition(_ATTRIBUTE_SEPARATOR) + if not separator or _find(rules.headers.service_columns["inn"], attribute) is None: + return None + return owner.strip().casefold() + + +def _inn_hint(items: Sequence[str], rules: Rules) -> InnHint | None: + """Подсказка строк ИНН по перечню группировок шапки (без счёта): первый реквизит `<группировка>.ИНН`, чей владелец — + группировка контрагентов того же перечня. Стоит после владельца — ИНН под наименованием, перед — над.""" + for index, item in enumerate(items): + owner = _attribute_owner(item, rules) + if owner is None: + continue + for other_index, other in enumerate(items): + if other.strip().casefold() == owner and rules.groups.classify(other) == "COUNTERPARTY": + return InnHint(item, "below" if index > other_index else "above") + return None + + +def _owns_hint(item: str, hint: InnHint | None, rules: Rules) -> bool: + return hint is not None and _attribute_owner(hint.name, rules) == item.strip().casefold() + + +def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema: + labels = [sheet.text_at(row, header.text_col) for row in header.group_label_rows] + hint = _inn_hint(labels[1:], rules) + groups: list[OsvGroup] = [] + for index, (row, label) in enumerate(zip(header.group_label_rows, labels, strict=True)): + if index > 0 and hint is not None and label == hint.name: + continue + # Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто») + group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label) + has_inn = _row_has_inn(sheet, row, rules) or (group_type == "COUNTERPARTY" and _owns_hint(label, hint, rules)) + groups.append(_group(group_type, label, has_inn=has_inn)) + if not groups: + groups = [_group("ACCOUNT")] + return GroupSchema(groups, 1, hint) + + +def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema: + groups = [_group("ACCOUNT", _first_group_name(sheet, header))] + hint = _inn_hint(items, rules) + inn_pending = header.inn_col is not None + for item in items: + if hint is not None and item == hint.name: + continue + group_type = rules.groups.classify(item) + has_inn = inn_pending and group_type == "COUNTERPARTY" + inn_pending = inn_pending and not has_inn + has_inn = has_inn or (group_type == "COUNTERPARTY" and _owns_hint(item, hint, rules)) + groups.append(_group(group_type, item, has_inn=has_inn)) + return GroupSchema(groups, 2, hint) +``` + +Удалить `InnAttribute`, `_inn_owner`, `_attach_inn_attribute`, `_positions` (и из `__all__`); поправить докстринг `build_group_levels` («группировки, вариант шапки и подсказка строк ИНН»). Если `_variant1_groups` до правки делал что-то ещё (например, `if not groups` ветку) — сохранить. Проверить: `test_inn_attribute_of_unknown_group_stays_a_group` («Договоры.ИНН» — владелец не контрагенты) остаётся зелёным: `_inn_hint` даёт `None`, элемент становится группировкой. + +- [ ] **Step 4: Classify, hierarchy, osv** + +`api/src/parser/hierarchy.py`: удалить строку `inn_above: str | None = None …` у `BodyRow`. + +`api/src/parser/classify.py`: +- докстринг модуля: шаги 1 и 2 без п. 3; добавить «ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`)»; +- удалить `_glue_inn_rows`, `_has_sums` (если больше не используется), `glue_inn_attributes`, `_not_glued_reason`, раздел «Реквизит «Контрагенты.ИНН» (OQ-162)», поля `is_inn_attribute`/`attribute_inn` у `ClassifiedRow` и `_Kind`, импорт `InnAttribute`, `inn_only` из импорта `numbers` (если не используется), `"glue_inn_attributes"` из `__all__`; +- `collect_body_rows` возвращает `body, total_row, warnings` (без `_glue_inn_rows`); +- `_Context`: `schema: Sequence[OsvGroup]`, новые поля `inns: Mapping[int, str]`, `unpaired: AbstractSet[int]`; +- `_schema_group(schema: Sequence[OsvGroup], position: int)` — без ветки `InnAttribute`; +- `_classify_row`: после ветки «Вал./Кол.» и до позиции: + +```python + if item.row in context.unpaired: + # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте + return _Kind("OTHER", None, code, doc_data_type), inherited +``` + + ветку `InnAttribute` удалить; ИНН контрагента: + +```python + # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) + counterparty, in_name = split_inn_suffix(item.text, context.rules) + inn = column or context.inns.get(item.row) or in_name +``` + +- `classify_rows(..., rules: Rules, *, inns: Mapping[int, str] | None = None, unpaired: AbstractSet[int] = frozenset())` — передать в `_Context(schema, header, sheet, doc_data_type, rules, currency_codes, inns or {}, unpaired)`; из `ClassifiedRow(...)` убрать `is_inn_attribute`/`attribute_inn`; докстринг: пункт про реквизит заменить на «строка ИНН без пары (`unpaired`) — OTHER»; `schema: Sequence[OsvGroup]`. + +`api/src/parser/osv.py`, `parse_sheet`: + +```python + step = "иерархия строк" + levels = resolve_levels(body, total_row, deadline) + step = "строки ИНН" + glue = glue_inn_rows( + body, + levels, + total_row, + sheet, + inn_col=header.inn_col, + schema=schema.groups, + hint=schema.inn_hint, + rules=rules, + ) + step = "классификация строк" + classified = classify_rows( + glue.rows, + glue.levels, + schema.groups, + header, + sheet, + doc.data_type, + total_row, + rules, + inns=glue.inns, + unpaired=glue.unpaired, + ) +``` + +`warnings = [..., *body_warnings, *glue.warnings]` (на месте `*inn_warnings`), возврат `_is_ok(checks) and glue.ok`; комментарий над возвратом: «Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — `needs_check`». Импорт `glue_inn_attributes` убрать, добавить `from src.parser.inn_rows import glue_inn_rows`. Предупреждение иерархии `hierarchy={levels.strategy}` — без изменений. + +`api/tests/fixtures/real_osv.py`: `schema = build_group_levels(...)`; после `resolve_levels` — `glue = glue_inn_rows(body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules)`; `classify_rows(glue.rows, glue.levels, schema.groups, …, inns=glue.inns, unpaired=glue.unpaired)`; в `RealParse` передать `schema.groups`, `glue.rows` как `body`, `glue.levels` как `levels`, `[*warnings, *glue.warnings]`. + +- [ ] **Step 5: Run unit tests** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit -q` +Expected: PASS. Падения в `test_inn_subkonto.py` по тексту предупреждения разбирать по существу: тексты склейки при подсказке совпадают с #32 (Global Constraints); текст «не отнесён к контрагенту» стал «без пары» — правка ассерта допустима, если проверяемый смысл тот же (строка, причина). + +- [ ] **Step 6: Run fixtures tests** + +Run: `cd $WT/tabular-parser && make check` +Expected: зелёное всё, кроме ожидаемого до Task 5–6: golden-снимков, которые меняются из-за нового правила (`s18` и файлы со строкой, «похожей на ИНН»), и `test_s18_inn_row_above_is_glued_and_not_other` (в `s18` ИНН с неверной контрольной суммой). Golden в этой задаче **не обновлять**. `test_s18_…` пометить `@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True)`. Для коммита достаточно зелёного прогона без golden-тестов: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest -m "not integration" -q --deselect ` плюс `ruff check . && ruff format --check . && mypy src`; список deselect и суть каждого diff — в отчёт задачи. Любое иное падение (в том числе golden Новопетровской) — дефект: разобрать и исправить. + +- [ ] **Step 7: Коммит** + +```bash +git -C $WT/tabular-parser add -A api/src/parser api/tests +git -C $WT/tabular-parser commit -m "ОСВ: строки ИНН — подключение прохода, подсказка шапки в обоих порядках, старые склейки удалены (#35) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Синтетика `s20`…`s27` и `s18` с верными ИНН + +**Files:** +- Modify: `api/tests/synthetic/generate.py` — поля `Node.blank`, `Node.twin`; `inn_rows_tree`; восемь файлов; `s18` с верными ИНН; докстринги «s01…s27». +- Modify: `api/tests/synthetic/test_synthetic_files.py` — имена, ожидания, тест раскладок, тест `s18` (снять `xfail`, если ставился). +- Create (генератором): `$WT/fixtures/osv/synthetic/s20_…`…`s27_….xlsx`; перезаписать `s18_inn_row_above.xlsx`. +- Modify: `$WT/fixtures/osv/synthetic/README.md` — строки `s18` и `s20`…`s27`. + +**Interfaces:** +- Consumes: конвейер Task 4. +- Produces: файлы синтетики, на которых Task 6 строит golden. + +- [ ] **Step 1: Write the failing tests** — в `api/tests/synthetic/test_synthetic_files.py`: + +добавить в `EXPECTED_NAMES` после `s19`: + +```python + "s20_inn_above_sibling_blank.xlsx", + "s21_inn_above_sibling_sums.xlsx", + "s22_inn_above_parent_blank.xlsx", + "s23_inn_above_parent_sums.xlsx", + "s24_inn_below_child_blank.xlsx", + "s25_inn_below_child_sums.xlsx", + "s26_inn_below_sibling_sums.xlsx", + "s27_inn_below_sibling_blank.xlsx", +``` + +в `EXPECTED`: + +```python + **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES}, +``` + +(константу `INN_LAYOUT_FILES` — кортеж восьми имён выше — объявить до `EXPECTED`; `EXPECTED_NAMES` может использовать её через `*INN_LAYOUT_FILES`.) + +тест раскладок и правка `s18`: + +```python +INN_LAYOUT_COUNTERPARTIES = { + ALFA: "7701234560", + BETA: "7707654321", + "Иванов Иван": None, + GAMMA: "5001234561", + "ИП Сидоров": "500100732259", +} + + +@pytest.mark.parametrize("name", INN_LAYOUT_FILES) +def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: str) -> None: + """#35: в каждой из восьми раскладок строки ИНН у каждого контрагента — ИНН его строки, строк ИНН в `rows` нет.""" + outcome = parse_named(fixtures_dir, name) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE, warnings_of(outcome) + counterparties = { + row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" + } + assert counterparties == INN_LAYOUT_COUNTERPARTIES + assert not [row for row in doc.rows if row.group_type == "OTHER"] + inns = {inn for inn in INN_LAYOUT_COUNTERPARTIES.values() if inn} + assert not [row for row in doc.rows if row.row_value in inns] +``` + +в `test_s18_inn_row_above_is_glued_and_not_other`: `{"7701234567", "5001234567"}` → `{"7701234560", "5001234561"}`, ожидание `{ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"}`, предупреждение `"ИНН 7701234560"`; снять `xfail`, если Task 4 его ставил. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q` +Expected: FAIL — файлов `s20`…`s27` нет («запустите генератор»), `s18` с прежними ИНН. + +- [ ] **Step 3: Generator** — в `api/tests/synthetic/generate.py`: + +у `Node` (после `closing_debit_delta`): + +```python + blank: bool = False # строка без сумм (строка ИНН без сумм, #35) + twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит +``` + +`children()`: + +```python + def children(self) -> list["Node"]: + return [kid for kid in self.kids if not kid.indicator and kid.twin is None] +``` + +в `build_osv` строку `_row_cells(sheet, spec, next_row, (node, depth), node.six())` заменить на: + +```python + _row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank) +``` + +после `_s19` — раскладки: + +```python +InnLayout = Literal["above_sibling", "above_parent", "below_child", "below_sibling"] + + +def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node: + """То же дерево, где ИНН каждого контрагента выведен отдельной строкой в раскладке `layout` (#35): над / под + наименованием, в дереве (родитель / ребёнок) или соседом; `sums` — у строки ИНН те же суммы, иначе строка пустая. + Колонки ИНН нет, в названии ИНН не пишется.""" + kids: list[Node] = [] + for kid in node.kids: + kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией с строками ИНН + if kid.inn is None or kid.indicator: + kids.append(kid) + continue + inn, kid.inn = kid.inn, None + if layout == "above_sibling": + kids += [Node(inn, twin=kid, blank=not sums), kid] + elif layout == "below_sibling": + kids += [kid, Node(inn, twin=kid, blank=not sums)] + elif layout == "above_parent": + kids.append(Node(inn, [kid], blank=not sums)) + else: + kid.kids = [Node(inn, kid.kids, opening=kid.opening, turnover=kid.turnover, blank=not sums)] + kids.append(kid) + node.kids = kids + return node + + +def _inn_layout_tree() -> Node: + """Счёт 60 для раскладок строк ИНН (#35): ИНН с верной контрольной суммой (10 и 12 цифр), физлицо без ИНН.""" + first = alfa() + first.inn = "7701234560" + third = gamma() + third.inn = "5001234561" + return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) + + +def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]: + def build() -> list[SheetSpec]: + root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums) + return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] + + return build +``` + +`_s18` — ИНН с верной контрольной суммой: + +```python +def _s18() -> list[SheetSpec]: + """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет. + ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35).""" + first = alfa(inn_style="above") + first.inn = "7701234560" + third = gamma(inn_style="above") + third.inn = "5001234561" + root = Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third])]) + return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] +``` + +в `FILES` после `s19`: + +```python + "s20_inn_above_sibling_blank.xlsx": (_inn_layout("above_sibling", sums=False), to_xlsx), + "s21_inn_above_sibling_sums.xlsx": (_inn_layout("above_sibling", sums=True), to_xlsx), + "s22_inn_above_parent_blank.xlsx": (_inn_layout("above_parent", sums=False), to_xlsx), + "s23_inn_above_parent_sums.xlsx": (_inn_layout("above_parent", sums=True), to_xlsx), + "s24_inn_below_child_blank.xlsx": (_inn_layout("below_child", sums=False), to_xlsx), + "s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx), + "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), + "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), +``` + +докстринг модуля и `description` в `main`: «s01…s27»; строка про s20…s27 — «восемь раскладок строки ИНН (#35)». `InnStyle` и `inn_style="above"` (s18) не трогать. + +- [ ] **Step 4: Сгенерировать файлы и проверить** + +```bash +cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m tests.synthetic.generate ../../fixtures/osv/synthetic +git -C $WT/fixtures status --short +``` + +Expected: изменён только `osv/synthetic/s18_inn_row_above.xlsx`, добавлены восемь `s20`…`s27`; остальные файлы побайтно те же (генератор детерминирован). Если изменился любой другой `sNN` — остановиться и разобраться (вероятно, задеты общие узлы `alfa()`/`gamma()` или `children()`). + +Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q` +Expected: PASS. Падение раскладки — разобрать по файлу CLI: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli ../../fixtures/osv/synthetic/<файл>`; чинить парсер (это и есть проверка дизайна), а не генератор, если только генератор не нарушает раскладку. Исправление парсера — в отчёт задачи и в design.md, если это решение. + +- [ ] **Step 5: README fixtures** — в `$WT/fixtures/osv/synthetic/README.md`: строку `s18` поправить («у «ООО Альфа» и «ООО Гамма» ИНН с верной контрольной суммой (`7701234560`, `5001234561`) стоит отдельной строкой над названием…; без подсказки в шапке строка с неверной контрольной суммой не склеивается (#35)»); добавить восемь строк таблицы: + +```markdown +| `s20_inn_above_sibling_blank.xlsx` | ИНН строкой **над** наименованием, сосед того же уровня, **без сумм** (#35) | ИНН — у своего контрагента, строк ИНН в `rows` нет, `done` | +| `s21_inn_above_sibling_sums.xlsx` | ИНН строкой над наименованием, сосед, **с теми же суммами** | то же; пара — по равенству сумм | +| `s22_inn_above_parent_blank.xlsx` | строка ИНН — **родитель** наименования (уровень выше), без сумм | наименование поднимается на её место, ИНН — ему, `done` | +| `s23_inn_above_parent_sums.xlsx` | строка ИНН — родитель наименования, с суммами | то же | +| `s24_inn_below_child_blank.xlsx` | строка ИНН — **ребёнок** наименования (уровень ниже), без сумм | ИНН — родителю, строка из `rows` уходит, `done` | +| `s25_inn_below_child_sums.xlsx` | строка ИНН — ребёнок наименования, с суммами (как «Контрагенты.ИНН» в 1С, но без подсказки в шапке) | то же | +| `s26_inn_below_sibling_sums.xlsx` | ИНН строкой **под** наименованием, сосед, с суммами | ИНН — своему контрагенту, `done` | +| `s27_inn_below_sibling_blank.xlsx` | ИНН строкой под наименованием, сосед, без сумм (дефект main: ИНН уходил следующему контрагенту) | ИНН — своему контрагенту; направление — по блоку 60.02, кончающемуся строкой ИНН; `done` | +``` + +и фразу под таблицей: «`s20`–`s27` — восемь раскладок строки ИНН рядом с контрагентом (issue tabular-parser#35); ИНН с верной контрольной суммой — без подсказки в шапке другие не склеиваются». + +- [ ] **Step 6: `make check` и коммиты** + +```bash +cd $WT/tabular-parser && make check +git -C $WT/tabular-parser add api/tests/synthetic +git -C $WT/tabular-parser commit -m "Синтетика: восемь раскладок строки ИНН s20–s27, s18 с верными ИНН (#35) + +Co-Authored-By: Claude Opus 5.5 " +git -C $WT/fixtures add osv/synthetic +git -C $WT/fixtures commit -m "Синтетика ОСВ: восемь раскладок строки ИНН s20–s27, s18 с верными ИНН (tabular-parser#35) + +Co-Authored-By: Claude Opus 5.5 " +``` + +`make check` может падать только на golden (снимков `s20`…`s27` нет, `s18` изменился) — перечислить в отчёте, golden — Task 6. + +--- + +### Task 6: Сравнение main и ветки на всех fixtures, golden (ворота владельца) + +**Files:** +- Create (вне git): `$WT/tabular-parser/.superpowers/sdd/osv-inn-rows-layouts/compare/` — `dump.py`, `diff.py`, `main-src/`, `out-main.json`, `out-branch.json`, `report.md` +- Modify (после одобрения владельца): `$WT/fixtures/golden/expected_osv_parse/**` (`make golden-update`), при необходимости `api/tests/osv_dkb_expectations.yaml` + +**Interfaces:** +- Consumes: ветка после Task 5. +- Produces: `report.md` — список отличий для владельца; после одобрения — golden. + +- [ ] **Step 1: Снимок main** + +```bash +C=$WT/tabular-parser/.superpowers/sdd/osv-inn-rows-layouts/compare +mkdir -p $C/main-src +git -C $WT/tabular-parser archive main api | tar -x -C $C/main-src +``` + +- [ ] **Step 2: Скрипты** — `$C/dump.py`: + +```python +"""Разбор всех файлов fixtures кодом из cwd (каталог api main или ветки) → JSON: файл -> результат.""" + +import json +import pathlib +import sys + +from src.core.config import get_settings +from src.parser import get_parser + +fixtures, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +parser = get_parser(get_settings()) +result = {} +for path in sorted(p for p in fixtures.rglob("*") if p.suffix.lower() in {".xls", ".xlsx", ".xlsb"} and "golden" not in p.parts): + try: + outcome = parser.parse(path.read_bytes(), path.suffix.lower().lstrip(".")) + data = { + "status": str(outcome.status.value), + "documents": [doc.model_dump(mode="json") for doc in outcome.documents], + "warnings": list(outcome.metrics.warnings) if outcome.metrics else None, + "error": outcome.error.model_dump(mode="json") if outcome.error else None, + } + except Exception as exc: # noqa: BLE001 - сравнение, не прод + data = {"exception": repr(exc)} + result[str(path.relative_to(fixtures))] = data +out.write_text(json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)) +print(len(result)) +``` + +`$C/diff.py`: + +```python +"""Отличия разбора main и ветки по файлам: статус, контрагенты (имя, ИНН), строки, предупреждения.""" + +import json +import pathlib +import sys + +main, branch = (json.loads(pathlib.Path(name).read_text()) for name in sys.argv[1:3]) + + +def counterparties(data: dict) -> list[tuple]: + return [ + (row["row_number"], row.get("counterparty_name"), row.get("counterparty_inn"), row["group_type"]) + for doc in data.get("documents", []) + for row in doc.get("rows", []) + if row["group_type"] in {"COUNTERPARTY", "OTHER"} + ] + + +for name in sorted(set(main) | set(branch)): + left, right = main.get(name), branch.get(name) + if left == right: + continue + print(f"## {name}") + if left is None or right is None: + print("- только в", "ветке" if left is None else "main") + continue + print(f"- статус: {left.get('status')} → {right.get('status')}") + gone = set(counterparties(left)) - set(counterparties(right)) + new = set(counterparties(right)) - set(counterparties(left)) + for row in sorted(gone, key=str)[:15]: + print(f" - main: {row}") + for row in sorted(new, key=str)[:15]: + print(f" + ветка: {row}") + if len(gone) + len(new) > 30: + print(f" … всего −{len(gone)} / +{len(new)}") + warnings_left, warnings_right = set(left.get("warnings") or []), set(right.get("warnings") or []) + for warning in sorted(warnings_right - warnings_left)[:10]: + print(f" ! новое предупреждение: {warning}") + for warning in sorted(warnings_left - warnings_right)[:10]: + print(f" ~ ушло предупреждение: {warning}") +``` + +- [ ] **Step 3: Прогон** + +```bash +cd $C/main-src/api && ENV_FILE=$WT/tabular-parser/api/.env.test $WT/tabular-parser/api/.venv/bin/python $C/dump.py $WT/fixtures $C/out-main.json +cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python $C/dump.py $WT/fixtures $C/out-branch.json +$WT/tabular-parser/api/.venv/bin/python $C/diff.py $C/out-main.json $C/out-branch.json > $C/report.md +``` + +(`dump.py` запускается из каталога `api` нужной версии — `src` берётся из cwd; если `ENV_FILE` с абсолютным путём не читается настройками main — скопировать `.env.test` в `$C/main-src/api/`.) + +- [ ] **Step 4: Разобрать отчёт** + +Ожидаемые отличия: `osv/synthetic/s18_inn_row_above.xlsx` (ИНН), `osv/synthetic/s20`…`s27` (только в ветке), файлы со строкой «похожей на ИНН» рядом с наименованием (`done` → `needs_check`), файлы, где раньше ИНН терялся или уходил не тому контрагенту. **Неизменны:** `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` — должно отсутствовать в отчёте; иначе — это дефект, чинить до отчёта владельцу. + +Для каждого прочего отличия дописать в `report.md` под заголовком файла одну строку вывода: «ожидаемо: <почему>» или «НЕОЖИДАННО: <что>». Неожиданное — разобрать по `systematic-debugging`, исправить в коде (отдельным коммитом с тестом), повторить Steps 3–4. + +- [ ] **Step 5: СТОП — ворота владельца** + +Контроллер завершает работу отчётом владельцу: содержимое `report.md` (файл, статус до/после, суть, вывод). Golden **не обновлять** до явного «да» владельца. После одобрения (контроллер продолжается по SendMessage): + +- [ ] **Step 6: Golden и манифест** + +```bash +cd $WT/tabular-parser && make golden-update +git -C $WT/fixtures diff --stat +``` + +Diff golden должен совпадать с одобренным `report.md` (те же файлы). Если `needs_check` получили файлы пакета ДКБ — обновить `api/tests/osv_dkb_expectations.yaml` (ожидания — данные) с комментарием «#35: строка, похожая на ИНН, без пары» и прогнать `make check` (зелёный целиком). + +```bash +git -C $WT/fixtures add golden +git -C $WT/fixtures commit -m "Golden ОСВ: строки ИНН — восемь раскладок, s18 с верными ИНН (tabular-parser#35) + +Co-Authored-By: Claude Opus 5.5 " +git -C $WT/tabular-parser add api/tests/osv_dkb_expectations.yaml # если менялся +git -C $WT/tabular-parser commit -m "Тесты пакета ДКБ: ожидания после строк ИНН (#35) + +Co-Authored-By: Claude Opus 5.5 " # если менялся +cd $WT/tabular-parser && make check-full +``` + +Expected: `make check-full` зелёный (интеграционный тест при поднятом стенде может флейкать — O-1 из #36: перезапустить с остановленным контейнером `arm-ka-tabular-parser-1` или отметить в отчёте как известный флейк). + +--- + +### Task 7: Документация — docs (§1.1, §1.3, OQ-163), README, CLAUDE.md, OpenSpec tasks + +**Files:** +- Modify: `$WT/docs/context/05-domain-rules.md` — §1.1 (абзац «Реквизит субконто в списке группировок (OQ-162…)»), §1.3 (ИНН контрагента: строки 116–132 по текущему тексту — найти по «ИНН контрагента — в порядке приоритета» и «Отдельно — строка реквизита»). +- Modify: `$WT/docs/context/07-open-questions.md` — новая строка OQ-163; в статусах OQ-130 и OQ-162 — ссылка «правило строки ИНН заменено OQ-163». +- Modify: `$WT/tabular-parser/README.md` (раздел «Парсер ОСВ» — модуль `inn_rows.py` в конвейере), `$WT/tabular-parser/CLAUDE.md` (список модулей Среза 2a: `inn_rows.py`; число golden-снимков — пересчитать `find $WT/fixtures/golden/expected_osv_parse -name '*.json' | wc -l` и разложить: реальные / пакет ДКБ / синтетика). +- Create: `$WT/tabular-parser/openspec/changes/osv-inn-rows-layouts/tasks.md`. + +**Interfaces:** — + +- [ ] **Step 1: 05-domain-rules §1.1** — абзац OQ-162 дополнить: «Элемент может стоять и **перед** владельцем («субконто: Контрагенты.ИНН, Контрагенты» / строка `Контрагенты.ИНН` выше строки `Контрагенты` в варианте 1): тогда 1С выводит ИНН **над** наименованием. Элемент — подсказка для строк ИНН (§1.3, OQ-163): задаёт направление и снимает проверку контрольной суммы ИНН.» Фразу «строки этого уровня склеиваются с родителем по §1.3» заменить на «строки ИНН склеиваются с наименованием по §1.3». + +- [ ] **Step 2: 05-domain-rules §1.3** — в описании `counterparty_inn` второй источник заменить (вместо «строка без сумм **прямо над** названием…» и абзаца «Отдельно — строка реквизита…») на правило строк ИНН: + +```markdown +затем **строка ИНН** рядом с наименованием (OQ-163, решение владельца 13.10.2026; заменяет правила OQ-130 и OQ-162): +строка, подпись которой целиком — ИНН (`inn_only_row`: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП»; число из 9/11 +цифр дополняется нулём слева — R-24). Без подсказки `<группировка>.ИНН` в шапке ИНН строки **должен пройти контрольную +сумму ИНН** (алгоритм ФНС); строка из 10/12 цифр с неверной суммой рядом с наименованием остаётся как есть, +предупреждение «похожа на ИНН, контрольная сумма не сходится», документ — `needs_check`. С подсказкой сумма не +проверяется, пустая строка-ребёнок наименования — «ИНН не указан» (рулинг R-1 #32), непустая не-ИНН на этом месте — +«ИНН не распознан». Восемь раскладок — ИНН над / под наименованием × в дереве / не в дереве × с суммами / без: + +| Положение строки ИНН | Пара | +|---|---| +| у неё есть дети (над, в дереве) | единственный ребёнок-наименование, он поднимается на её место; несколько детей — без пары. Если в `group_schema` больше одной группировки и в листе нет строк глубже схемы, строка — наименование по схеме (контрагент, названный ИНН) | +| ребёнок наименования (под, в дереве) | родитель; её дети — к нему; несколько строк ИНН у одного наименования — без пары | +| сосед того же родителя (не в дереве) | с суммами — сосед с теми же суммами; без сумм (или совпали оба) — сосед по направлению листа | + +Суммы строки ИНН, если есть, должны совпасть с суммами пары по всем шести колонкам (пусто = 0). Направление листа — +первое определённое: порядок элемента `<группировка>.ИНН` в шапке; пары соседей по суммам; блоки детей одного родителя +(начинается строкой ИНН — над, кончается — под, начинается и кончается — блок сломан). Противоречие или нет сведений — +строки без сумм без пары. Две строки ИНН на одно наименование — обе без пары. ИНН колонки, отличный от ИНН строки, +остаётся, документ — `needs_check`. Склеенная строка в `rows` не попадает, предупреждение «строка N: ИНН … отнесён к +контрагенту в строке M»; **без пары** — строка в `rows` с `group_type = OTHER` (в свод не попадает), предупреждение с +причиной, документ — `needs_check`. **Реализовано** (tabular-parser #35, модуль `inn_rows.py`; синтетика `s18`, `s20`–`s27`). +``` + +Хвост названия (OQ-100) остаётся третьим источником. Абзац «Из текста ИНН отрезается всегда…» — `inn_only_row` теперь «строка ИНН (OQ-163)»; ссылку «OQ-130» там заменить на «OQ-163». + +- [ ] **Step 3: 07-open-questions** — строка после OQ-162: + +```markdown +| OQ-163 | ОСВ, где ИНН контрагента выведен **отдельной строкой** рядом с наименованием, в любой раскладке: над / под × в дереве / не в дереве × с суммами / без (требование владельца 13.10.2026). До решения парсер склеивал только «над, не в дереве, без сумм» (OQ-130) и «под, в дереве» при подсказке «Контрагенты.ИНН» (OQ-162); «под, не в дереве, без сумм» отдавал ИНН следующему контрагенту | владелец, 13.10.2026; RSHB/arm-tabular-parser#35, #34 | tabular-parser §1.1, §1.3 | **решено владельцем 13.10.2026:** без подсказки в шапке строка ИНН — только с верной контрольной суммой ИНН (во всех раскладках, включая OQ-130); с подсказкой — без проверки, порядок элементов задаёт направление; «над, в дереве» — только единственный ребёнок; соседи — по суммам, без сумм — по направлению листа (шапка → пары по суммам → однозначные блоки); неоднозначное — строка `OTHER`, `needs_check`; ведущий ноль числовой строки ИНН (#34). `05-domain-rules.md` §1.3; **реализовано** RSHB/arm-tabular-parser#35 | +``` + +В колонке статуса OQ-130 и OQ-162 дописать в конец: «Правило строки ИНН заменено OQ-163 (13.10.2026).» + +- [ ] **Step 4: README и CLAUDE.md tabular-parser** — README, раздел «Парсер ОСВ»: в цепочке модулей между `hierarchy.py` и `classify.py` — «`inn_rows.py` — строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, OQ-163)»; убрать упоминания `glue_inn_attributes`/`_glue_inn_rows`, если есть (`grep -n "glue_inn\|inn_above\|InnAttribute" README.md CLAUDE.md`). CLAUDE.md: в списке модулей Среза 2a добавить `inn_rows.py`; строку о golden (`113 файлов: …`) — пересчитанные числа. + +- [ ] **Step 5: OpenSpec tasks.md** — `$WT/tabular-parser/openspec/changes/osv-inn-rows-layouts/tasks.md`: + +```markdown +## 1. Код (tabular-parser) + +- [x] 1.1 Контрольная сумма ИНН и ИНН строки (`numbers.py`) +- [x] 1.2 Модуль `inn_rows.py`: склейка в дереве, контрольная сумма, защита «над, в дереве» +- [x] 1.3 Модуль `inn_rows.py`: соседи по суммам и направление листа +- [x] 1.4 Подключение в `osv.py`, подсказка шапки в обоих порядках, удаление `_glue_inn_rows` и `glue_inn_attributes` + +## 2. Данные (fixtures) + +- [x] 2.1 Синтетика `s20`–`s27`, `s18` с верными ИНН +- [x] 2.2 Сравнение main и ветки на всех fixtures, golden после согласования с владельцем + +## 3. Документация + +- [x] 3.1 docs: `05-domain-rules.md` §1.1, §1.3, OQ-163 +- [x] 3.2 README и CLAUDE.md tabular-parser +``` + +Проверка: `cd $WT/tabular-parser && openspec validate osv-inn-rows-layouts --strict`. + +- [ ] **Step 6: Коммиты** + +```bash +git -C $WT/docs add context/05-domain-rules.md context/07-open-questions.md +git -C $WT/docs commit -m "Правила: строки ИНН рядом с контрагентом — восемь раскладок, OQ-163 (tabular-parser#35) + +Co-Authored-By: Claude Opus 5.5 " +cd $WT/tabular-parser && make check +git -C $WT/tabular-parser add README.md CLAUDE.md openspec/changes/osv-inn-rows-layouts +git -C $WT/tabular-parser commit -m "Документация: модуль строк ИНН, tasks change (#35) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## После плана (контроллер) + +Финальное ревью всей ветки (opus) по SDD; таблица находок с маркерами и статусами; решения без обсуждения — в `design.md`. PR и merge — не в плане: их делает сессия по `finishing-a-development-branch` после отчёта контроллера (три PR: tabular-parser, docs, fixtures; мерджить вместе). diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index b4a7d39..f24ec4e 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -51,7 +51,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs ### Алгоритм `inn_rows.py` Вход: строки `BodyRow`, `Levels` (уровень и родитель), подсказка шапки (`InnHint`: есть ли `<группировка>.ИНН` и -направление по порядку элементов), позиции схемы группировок (для защиты, п. «Защита» ниже), словари. Выход: строки и +направление по порядку элементов), схема группировок `group_schema` (для защиты, п. «Защита» ниже), словари. Выход: строки и `Levels` без склеенных строк ИНН, ИНН для строк-наименований (`row → inn`), множество строк ИНН без пары (классификатор ставит им `OTHER`), предупреждения, признак «всё склеено». @@ -88,8 +88,14 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`. **Защита «над, в дереве».** Контрагент, названный ИНН, с одним договором неотличим по форме от строки ИНН над -контрагентом. Если шапка задаёт группировки (позиции схемы) и глубина поддерева кандидата не превышает схему (лишнего -уровня нет), кандидат не склеивается и классифицируется по схеме; без схемы — по пп. 1–10. +контрагентом. Проверка — по листу: если `group_schema` содержит больше одной группировки и ни одна строка листа (кроме +Вал./Кол.) не стоит глубже схемы (позиция — число не-счётных предков после последнего счёта + 1 — не больше +`len(group_schema) − 1`), лишнего уровня в листе нет: кандидаты «над, в дереве» не склеиваются, без предупреждения +классифицируются по схеме. Схема из одного ACCOUNT — проверки нет, по пп. 1–10. + +**Нераспознанный реквизит при подсказке «под».** С подсказкой «под» (#32) единственный ребёнок наименования с теми же +суммами, подпись которого не ИНН и не пуста («нерез.»), — строка реквизита с нераспознанным ИНН: без пары, `OTHER`, +«ИНН не распознан», `needs_check` (поведение #32 сохраняется). ### Изменения в коде @@ -119,7 +125,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | # | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| -| 1 | 13.10.2026 | Защита «над, в дереве»: при известной схеме группировок склейка только при лишнем уровне в дереве | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | Файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия | +| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | Файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия | | 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка | | 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке | | 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки | @@ -127,3 +133,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 6 | 13.10.2026 | Суммы сравниваются с `None` = 0 (в #32 — точное равенство кортежей) | Пустая ячейка и 0 в ОСВ 1С значат одно и то же | Пара по суммам у строк, различающихся только пустотой ячейки; на Новопетровской не влияет (проверяется golden) | | 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` | | 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста | +| 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` | +| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index 4db1d3c..854ab9d 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -104,13 +104,16 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST она классифицируется как обычно, в `warnings` — «строка N похожа на ИНН …, контрольная сумма не сходится», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН «ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол., -не строка ИНН. +не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не +ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»). **Пара строки ИНН** (связь в дереве важнее соседства): -- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если шапка задаёт - группировки и поддерево строки ИНН не глубже схемы (лишнего уровня нет), строка ИНН не склеивается и классифицируется - по схеме; -- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет; +- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в + `group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не + склеивается и без предупреждения классифицируется по схеме; +- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Если у + такой строки ИНН есть дети, пара — родитель только при подсказке «под» или (без подсказки) если родитель по схеме — + COUNTERPARTY; иначе действует правило «есть дети»; - иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа (над → следующий, под → предыдущий). -- 2.47.2 From 1e5f28bd50422ab24b89d073c87d3ce13a39dd20 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:33:22 +0400 Subject: [PATCH 03/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D1=81=D1=83?= =?UTF-8?q?=D0=BC=D0=BC=D0=B0=20=D0=98=D0=9D=D0=9D=20=D0=B8=20=D0=98=D0=9D?= =?UTF-8?q?=D0=9D=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/parser/numbers.py | 52 ++++++++++++++++++- api/tests/unit/parser/test_numbers.py | 50 +++++++++++++++++- .../changes/osv-inn-rows-layouts/design.md | 2 + 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 0427cf0..22f6c7e 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -17,7 +17,17 @@ from src.parser.detect import INN_LENGTHS, inn_from_cell from src.parser.grid import Cell from src.parser.rules import Rules, source_offset -__all__ = ["MONEY_Q", "MoneyParse", "column_inn", "inn_digits", "inn_only", "split_inn_suffix", "to_money"] +__all__ = [ + "MONEY_Q", + "MoneyParse", + "column_inn", + "inn_checksum_ok", + "inn_digits", + "inn_only", + "row_inn", + "split_inn_suffix", + "to_money", +] MONEY_Q = Decimal("0.01") # ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при @@ -46,6 +56,15 @@ _MINUS_SIGN = "−" _SINGLE_DIGIT_RUN = re.compile(r"^\D*([0-9]+(?:[\s\u00a0\u202f][0-9]+)*)\D*$") _DIGIT_GROUP_SEPARATOR = re.compile(r"[\s\u00a0\u202f]") +# Контрольная сумма ИНН (алгоритм ФНС): весовые коэффициенты контрольных разрядов; остаток от деления на 11, затем на 10 +_INN10_WEIGHTS = (2, 4, 10, 3, 5, 9, 4, 6, 8) +_INN12_WEIGHTS_FIRST = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_INN12_WEIGHTS_SECOND = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) +_CHECKSUM_MODULUS = 11 +_CHECK_DIGIT_MODULUS = 10 +_INN_LEGAL_LENGTH = 10 +_INN_PERSON_LENGTH = 12 + class MoneyParse(NamedTuple): value: Decimal | None @@ -177,3 +196,34 @@ def inn_only(text: str, rules: Rules) -> str | None: if match is not None: return match.group("inn") return None + + +def _check_digit(digits: str, weights: tuple[int, ...]) -> int: + total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False)) + return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS + + +def inn_checksum_ok(inn: str) -> bool: + """Контрольная сумма ИНН по алгоритму ФНС: у 10 цифр — последний разряд, у 12 — два последних (#35). + + Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки. + """ + if not inn.isdecimal(): # isdecimal: int() по каждому символу гарантированно сработает + return False + if len(inn) == _INN_LEGAL_LENGTH: + return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1]) + if len(inn) == _INN_PERSON_LENGTH: + first_ok = _check_digit(inn, _INN12_WEIGHTS_FIRST) == int(inn[-2]) + second_ok = _check_digit(inn, _INN12_WEIGHTS_SECOND) == int(inn[-1]) + return first_ok and second_ok + return False + + +def row_inn(cell: Cell, rules: Rules) -> str | None: + """ИНН строки, где написан только он (строка ИНН, `inn_rows`): число — 10/12 цифр или 9/11 с потерянным ведущим + нулём (R-24, #34, как у колонки ИНН); текст — по `inn_only_row` («7701234560», «ИНН 7701234560 / 770101001»).""" + if cell.kind == "number": + return inn_digits(cell) + if cell.kind != "text": + return None + return inn_only(cell.text, rules) diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index 0440c7f..43179db 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -7,7 +7,8 @@ import pytest from src.parser import detect from src.parser.grid import EMPTY_CELL, Cell -from src.parser.numbers import MONEY_Q, MoneyParse, inn_digits, to_money +from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money +from src.parser.rules import Rules, load_rules MAX_WARNING_LENGTH = 100 @@ -237,3 +238,50 @@ def test_only_extraction_restores_lost_leading_zero() -> None: def test_detector_rule_stays_strict_for_prefixed_text() -> None: assert detect.inn_from_cell(text("ИНН 352819549855")) is None + + +# --- Контрольная сумма ИНН и ИНН строки (#35) --- + + +@pytest.mark.parametrize( + ("inn", "ok"), + [ + ("7701234560", True), + ("7707654321", True), + ("2370012036", True), + ("0123456788", True), + ("500100732259", True), + ("7701234567", False), + ("5001234567", False), + ("500100732258", False), + ("770123456", False), + ("77012345601", False), + ("77012345a0", False), + ], +) +def test_inn_checksum_ok(inn: str, ok: bool) -> None: + assert inn_checksum_ok(inn) is ok + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +@pytest.mark.parametrize( + ("cell", "expected"), + [ + (num(7701234560), "7701234560"), + (num(123456788), "0123456788"), # 9 цифр числом — потерянный ведущий ноль (R-24, #34) + (num(50010073225), "050010073225"), # 11 цифр числом + (text("7701234560"), "7701234560"), + (text("ИНН 7701234560"), "7701234560"), + (text("ИНН: 7701234560 / 770101001"), "7701234560"), + (text("123456788"), None), # текст из 9 цифр нулём не дополняется + (text("ООО Альфа 7701234560"), None), # не только ИНН + (num(Decimal("7701234560.5")), None), + (EMPTY_CELL, None), + ], +) +def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None: + assert row_inn(cell, rules) == expected diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index f24ec4e..f7af876 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -135,3 +135,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста | | 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` | | 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится | +| 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда | +| 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isdecimal()`, не `isdigit()` | `isdigit()` пропускает «²» и подобные, на которых `int()` падает с ValueError | Нет: вход — строки ИНН из `[0-9]`-выражений, на практике не встречается | -- 2.47.2 From 816719ca92107c5678ab21ff55c35db58344adc8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:41:14 +0400 Subject: [PATCH 04/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=98?= =?UTF-8?q?=D0=9D=D0=9D=20=E2=80=94=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D1=83=D0=BC=D0=BC=D0=B0=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модуль inn_rows: строка с одним ИНН сопоставляется с наименованием в дереве (родитель или ребёнок), без пары остаётся с причиной; подсказка шапки InnHint отменяет контрольную сумму. row_data_type стал публичным. ASCII-проверка ИНН (ревью T1.2), комментарий про zip(strict=False) (T1.3), тест «770123456²» (T1.1). Решения 12–13 в design.md. Co-Authored-By: Claude Opus 5.5 --- api/src/parser/classify.py | 9 +- api/src/parser/header.py | 12 + api/src/parser/inn_rows.py | 344 ++++++++++++++++++ api/src/parser/numbers.py | 4 +- api/tests/unit/parser/test_inn_rows.py | 280 ++++++++++++++ api/tests/unit/parser/test_numbers.py | 1 + .../changes/osv-inn-rows-layouts/design.md | 3 +- 7 files changed, 647 insertions(+), 6 deletions(-) create mode 100644 api/src/parser/inn_rows.py create mode 100644 api/tests/unit/parser/test_inn_rows.py diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 4ce1629..048b7c0 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -42,6 +42,7 @@ __all__ = [ "classify_rows", "collect_body_rows", "glue_inn_attributes", + "row_data_type", ] # Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или @@ -339,7 +340,7 @@ def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None return values if any(value is not None for value in values.values()) else None -def _row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None: +def row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None: """CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`.""" found = first_match(rules.data_types.row, text) if found is not None: @@ -401,9 +402,9 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple _, name = _schema_group(context.schema, _ACCOUNT_POSITION) return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0) code = inherited.account_code_raw - row_data_type = _row_data_type(item.text, context.rules, context.currency_codes) - if row_data_type is not None: - return _Kind("OTHER", None, code, row_data_type), inherited + data_type = row_data_type(item.text, context.rules, context.currency_codes) + if data_type is not None: + return _Kind("OTHER", None, code, data_type), inherited position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index c662c16..52ca6c2 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -24,6 +24,7 @@ __all__ = [ "DocHeader", "GroupSchema", "InnAttribute", + "InnHint", "build_group_levels", "build_group_schema", "parse_doc_header", @@ -489,6 +490,17 @@ class InnAttribute: name: str +@dataclass(frozen=True) +class InnHint: + """Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку + элементов — после группировки-владельца ИНН стоит под наименованием (`below`), перед ней — над (`above`). + + С подсказкой контрольная сумма ИНН строки не проверяется (`inn_rows`).""" + + name: str + direction: Literal["above", "below"] + + @dataclass(frozen=True) class GroupSchema: """Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`). diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py new file mode 100644 index 0000000..09ea3f3 --- /dev/null +++ b/api/src/parser/inn_rows.py @@ -0,0 +1,344 @@ +"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`; +`05-domain-rules.md` §1.3; OQ-130, OQ-162, #35). + +Один проход между каскадом иерархии и классификацией. Строка, где написан только ИНН, сопоставляется с наименованием +в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве +(сосед того же родителя) × с суммами / без сумм. Связь в дереве важнее соседства. Склеенная строка уходит из строк и +дерева (её дети — к наименованию), ИНН — наименованию; строка без пары остаётся (классификатор ставит ей OTHER), а +документ уходит на проверку. Классификатор получает чистое дерево. + +Без подсказки шапки (`InnHint`) строка — строка ИНН только при верной контрольной сумме; строка из 10/12 цифр с +неверной суммой рядом с наименованием — «похожа на ИНН»: остаётся как есть, предупреждение, `needs_check`. +""" + +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Literal + +from src.contract.result import OsvGroup +from src.parser.classify import row_data_type +from src.parser.grid import Cell, Sheet +from src.parser.header import InnHint +from src.parser.hierarchy import BodyRow, Levels, parents +from src.parser.numbers import column_inn, inn_checksum_ok, row_inn +from src.parser.rules import Rules + +__all__ = ["InnGlue", "glue_inn_rows"] + +Kind = Literal["total", "account", "indicator", "candidate", "name"] +_ZERO = Decimal(0) +_UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено" + + +@dataclass(frozen=True) +class InnGlue: + """Результат склейки: строки и дерево без склеенных строк ИНН, ИНН по строкам наименований, строки ИНН без пары + (классификатор ставит им OTHER), предупреждения и признак «всё склеено, похожих на ИНН строк и расхождений нет».""" + + rows: list[BodyRow] + levels: Levels + inns: dict[int, str] + unpaired: frozenset[int] + warnings: list[str] + ok: bool + + +@dataclass +class _Tree: + """Строки листа, их дерево и вид: счёт, «Итого», Вал./Кол., строка ИНН или наименование.""" + + rows: Sequence[BodyRow] + by_row: dict[int, BodyRow] + parent: dict[int, int | None] + children: dict[int | None, list[int]] + kind: dict[int, Kind] + inn: dict[int, str | None] = field(default_factory=dict) # ИНН строки ИНН; None — пустой или не распознан + lookalike: dict[int, str] = field(default_factory=dict) # 10/12 цифр с неверной контрольной суммой + + def kids(self, row: int) -> list[int]: + """Дети строки, кроме Вал./Кол. (они не уровень аналитики).""" + return [kid for kid in self.children.get(row, []) if self.kind[kid] != "indicator"] + + def candidates(self, done: "_Pairs") -> list[int]: + return [item.row for item in self.rows if self.kind[item.row] == "candidate" and not done.decided(item.row)] + + +@dataclass +class _Pairs: + partner: dict[int, int] = field(default_factory=dict) # строка ИНН -> строка наименования + reason: dict[int, str] = field(default_factory=dict) # строка ИНН без пары -> причина + flat: list[int] = field(default_factory=list) # строки ИНН не в дереве (соседи), по порядку строк + + def decided(self, row: int) -> bool: + return row in self.partner or row in self.reason + + +def _label_cell(sheet: Sheet, item: BodyRow) -> Cell: + if item.label_col is None: + return Cell(item.text or None, "text" if item.text else "empty") + return sheet.cell(*sheet.merged_origin(item.row, item.label_col)) + + +def _has_sums(item: BodyRow) -> bool: + return any(value is not None for value in item.sums) + + +def _same_sums(left: BodyRow, right: BodyRow) -> bool: + """Шесть сумм равны; пустая ячейка = 0 (в ОСВ 1С это одно и то же).""" + return [value or _ZERO for value in left.sums] == [value or _ZERO for value in right.sums] + + +def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида строки приходит явно + rows: Sequence[BodyRow], + levels: Levels, + total_row: int | None, + sheet: Sheet, + hint: InnHint | None, + rules: Rules, +) -> _Tree: + currency = frozenset(rules.data_types.currency_codes) + children: dict[int | None, list[int]] = {} + for item in rows: + children.setdefault(levels.parent[item.row], []).append(item.row) + tree = _Tree(rows, {item.row: item for item in rows}, dict(levels.parent), children, {}) + for item in rows: + tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree) + if hint is not None and hint.direction == "below": + # «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения + for item in rows: + parent = tree.parent[item.row] + if tree.kind[item.row] == "name" and not item.text and parent is not None and tree.kind[parent] == "name": + tree.kind[item.row] = "candidate" + tree.inn[item.row] = None + return tree + + +def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от всех этих данных + item: BodyRow, + total_row: int | None, + sheet: Sheet, + hint: InnHint | None, + rules: Rules, + currency: frozenset[str], + tree: _Tree, +) -> Kind: + if total_row is not None and item.row >= total_row: + return "total" + if item.is_account: + return "account" + if row_data_type(item.text, rules, currency) is not None: + return "indicator" + found = row_inn(_label_cell(sheet, item), rules) + if found is None: + return "name" + if hint is None and not inn_checksum_ok(found): + tree.lookalike[item.row] = found + return "name" + tree.inn[item.row] = found + return "candidate" + + +def _depths(tree: _Tree) -> dict[int, int]: + """Позиция строки в схеме группировок, как у классификатора: счёт и «Итого» — 0, Вал./Кол. — позиция родителя, + прочие — позиция родителя + 1 (строки ИНН считаются уровнем: склейки ещё не было).""" + depth: dict[int, int] = {} + for item in tree.rows: + parent = tree.parent[item.row] + base = 0 if parent is None else depth.get(parent, 0) + kind = tree.kind[item.row] + if kind in {"account", "total"}: + depth[item.row] = 0 + elif kind == "indicator": + depth[item.row] = base + else: + depth[item.row] = base + 1 + return depth + + +def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool: + """Защита «над, в дереве»: схема задаёт группировки и ни одна строка листа не стоит глубже схемы — лишнего уровня + нет, строка ИНН с детьми — наименование по схеме (контрагент, названный ИНН, с одним договором).""" + deepest = len(schema) - 1 + return deepest >= 1 and all(value <= deepest for value in depth.values()) + + +def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool: + """Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка + шапки решает; без неё — «под», если родитель по схеме — контрагент (design, решение без обсуждения №10).""" + if hint is not None: + return hint.direction == "below" + position = depth[parent] + return position < len(schema) and schema[position].type == "COUNTERPARTY" + + +def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: + """С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка + реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9).""" + if hint is None or hint.direction != "below": + return + for item in tree.rows: + if tree.kind[item.row] != "name": + continue + kids = tree.kids(item.row) + if len(kids) != 1: + continue + kid = tree.by_row[kids[0]] + if tree.kind[kid.row] == "name" and kid.text and _same_sums(kid, item): + tree.kind[kid.row] = "candidate" + tree.inn[kid.row] = None + pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)" + + +def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None: + """Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми — + над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`.""" + depth = _depths(tree) + no_extra_level = _no_extra_level(depth, schema) + for row in tree.candidates(pairs): + kids = tree.kids(row) + parent = tree.parent[row] + below = parent is not None and tree.kind[parent] == "name" + if below and kids and parent is not None: + below = _owned_by_parent(parent, depth, schema, hint) + if below and parent is not None: + rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"] + if len(rivals) > 1: + pairs.reason[row] = f"у контрагента в строке {parent} несколько строк ИНН" + else: + pairs.partner[row] = parent + elif kids: + if no_extra_level: + tree.kind[row] = "name" + elif len(kids) > 1: + pairs.reason[row] = f"под ней несколько строк ({len(kids)}), а не одно наименование" + elif tree.kind[kids[0]] != "name": + pairs.reason[row] = "под ней не наименование" + else: + pairs.partner[row] = kids[0] + else: + pairs.flat.append(row) + + +def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: # noqa: ARG001 - hint нужен в Task 3 + """Строки ИНН не в дереве (соседи наименования) — Task 3; пока без пары.""" + for row in pairs.flat: + pairs.reason[row] = _UNKNOWN_DIRECTION + + +def _check_sums(tree: _Tree, pairs: _Pairs) -> None: + for row, partner in list(pairs.partner.items()): + if _has_sums(tree.by_row[row]) and not _same_sums(tree.by_row[row], tree.by_row[partner]): + del pairs.partner[row] + pairs.reason[row] = f"суммы не равны суммам строки {partner}" + + +def _drop_conflicts(pairs: _Pairs) -> None: + claimed = Counter(pairs.partner.values()) + for row, partner in list(pairs.partner.items()): + if claimed[partner] > 1: + del pairs.partner[row] + pairs.reason[row] = f"на строку {partner} претендуют несколько строк ИНН" + + +def _near_name(tree: _Tree, row: int) -> bool: + """Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование.""" + parent = tree.parent[row] + siblings = tree.children.get(parent, []) + index = siblings.index(row) + near = [*siblings[max(index - 1, 0) : index], *siblings[index + 1 : index + 2], *tree.children.get(row, [])] + if parent is not None: + near.append(parent) + return any(tree.kind[other] == "name" for other in near) + + +def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]: + """Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень, родители пересчитываются.""" + if not removed: + return list(rows), levels + lifted: dict[int, int] = {} + by_row: dict[int, int] = {} + kept: list[BodyRow] = [] + for item in rows: + parent = levels.parent[item.row] + lift = 0 if parent is None else lifted[parent] + (1 if parent in removed else 0) + lifted[item.row] = lift + if item.row in removed: + continue + by_row[item.row] = levels.by_row[item.row] - lift + kept.append(item) + return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy) + + +def _result( # noqa: PLR0913, PLR0917 - сборка результата из всех частей прохода + rows: Sequence[BodyRow], + levels: Levels, + tree: _Tree, + pairs: _Pairs, + sheet: Sheet, + inn_col: int | None, + hint: InnHint | None, + rules: Rules, +) -> InnGlue: + label = "" if hint is None else f" («{hint.name}»)" + warnings: list[str] = [] + inns: dict[int, str] = {} + ok = not pairs.reason + for item in rows: + row = item.row + inn = tree.inn.get(row) + if row in pairs.partner: + partner = pairs.partner[row] + if inn is None: + warnings.append( + f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН" + ) + continue + column = None if inn_col is None else column_inn(sheet.cell(partner, inn_col), rules) + if column is not None and column != inn: + ok = False + warnings.append( + f"строка {row}: ИНН {inn}{label} расходится с ИНН {column} колонки ИНН контрагента в строке " + f"{partner} — оставлен ИНН колонки" + ) + continue + inns[partner] = inn + warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}") + elif row in pairs.reason: + shown = "" if inn is None else f" {inn}" + warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}") + elif row in tree.lookalike and tree.kind[row] == "name" and _near_name(tree, row): + ok = False + warnings.append( + f"строка {row}: похожа на ИНН {tree.lookalike[row]}, но контрольная сумма не сходится — строка " + "оставлена как есть" + ) + kept, kept_levels = _without(rows, levels, set(pairs.partner)) + return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok) + + +def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно + rows: Sequence[BodyRow], + levels: Levels, + total_row: int | None, + sheet: Sheet, + *, + inn_col: int | None, + schema: Sequence[OsvGroup], + hint: InnHint | None, + rules: Rules, +) -> InnGlue: + """Строки ИНН рядом с наименованием → ИНН наименованию (spec «Строки ИНН рядом с контрагентом»). + + `levels` — дерево каскада иерархии со строками ИНН; `schema` — группировки шапки (`group_schema`) для защиты «над, + в дереве»; `hint` — подсказка `<группировка>.ИНН` (без неё ИНН строки должен пройти контрольную сумму). + """ + tree = _build(rows, levels, total_row, sheet, hint, rules) + pairs = _Pairs() + _unrecognised_attributes(tree, hint, pairs) + _pair_in_tree(tree, schema, hint, pairs) + _pair_flat(tree, hint, pairs) + _check_sums(tree, pairs) + _drop_conflicts(pairs) + return _result(rows, levels, tree, pairs, sheet, inn_col, hint, rules) diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 22f6c7e..1162b06 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -199,6 +199,8 @@ def inn_only(text: str, rules: Rules) -> str | None: def _check_digit(digits: str, weights: tuple[int, ...]) -> int: + # strict=False намеренно: zip обрезает цифры по длине весов (ИНН длиннее весов на 1–2 символа); + # последние разряды — контрольные, в сумму не входят total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False)) return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS @@ -208,7 +210,7 @@ def inn_checksum_ok(inn: str) -> bool: Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки. """ - if not inn.isdecimal(): # isdecimal: int() по каждому символу гарантированно сработает + if not (inn.isascii() and inn.isdigit()): # ASCII-цифры: int() по каждому символу гарантированно сработает return False if len(inn) == _INN_LEGAL_LENGTH: return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1]) diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py new file mode 100644 index 0000000..b63e71f --- /dev/null +++ b/api/tests/unit/parser/test_inn_rows.py @@ -0,0 +1,280 @@ +"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`, #35). + +Тесты модуля `inn_rows` на деревьях, собранных вручную: строка листа — (уровень, подпись, шесть сумм, ИНН колонки). +Подпись — колонка 1, колонка ИНН — 2, суммы — 3…8; уровни — как у стратегии outline, родители — `hierarchy.parents`. +""" + +from decimal import Decimal + +import pytest +from gridbuilder import sheet_from_rows + +from src.contract.result import OsvGroup +from src.parser.classify import ACCOUNT_RE +from src.parser.header import InnHint +from src.parser.hierarchy import BodyRow, Levels, parents +from src.parser.inn_rows import InnGlue, glue_inn_rows +from src.parser.rules import Rules, load_rules + +Sums = tuple[object, object, object, object, object, object] +NO: Sums = (None, None, None, None, None, None) +A: Sums = (None, None, 100, 40, 60, None) +B: Sums = (10, None, None, 30, None, 20) +INN_A = "7701234560" +INN_B = "7707654321" +INN_12 = "500100732259" +BAD = "7701234567" # контрольная сумма не сходится +LABEL_COL = 1 +INN_COL = 2 +SCHEMA = [OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="COUNTERPARTY", name="Контрагенты")] +SCHEMA_WITH_CONTRACTS = [*SCHEMA, OsvGroup(type="CONTRACT", name="Договоры")] +BELOW = InnHint("Контрагенты.ИНН", "below") +ABOVE = InnHint("Контрагенты.ИНН", "above") + +Row = tuple[int, object, Sums, object] + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def r(level: int, label: object, sums: Sums = NO, inn: object = None) -> Row: + return level, label, sums, inn + + +def run(rules: Rules, *rows: Row, hint: InnHint | None = None, schema: list[OsvGroup] | None = None) -> InnGlue: + sheet = sheet_from_rows([[label, inn, *sums] for _, label, sums, inn in rows]) + body: list[BodyRow] = [] + for number, (level, _, sums, _) in enumerate(rows, start=1): + text = sheet.text_at(number, LABEL_COL) + account = ACCOUNT_RE.match(text) + body.append( + BodyRow( + row=number, + text=text, + is_account=account is not None, + account_rank=None if account is None else account.group(1).count(".") + 1, + bold=False, + indent=0, + outline=level, + sums=tuple(None if value is None else Decimal(str(value)) for value in sums), + label_col=LABEL_COL, + ) + ) + by_row = {item.row: level for item, (level, *_) in zip(body, rows, strict=True)} + levels = Levels(by_row=by_row, parent=parents(body, by_row), strategy="outline") + return glue_inn_rows(body, levels, None, sheet, inn_col=INN_COL, schema=schema or SCHEMA, hint=hint, rules=rules) + + +def texts(glue: InnGlue) -> list[str]: + return [item.text for item in glue.rows] + + +def has_warning(glue: InnGlue, *parts: str) -> bool: + return any(all(part in warning for part in parts) for warning in glue.warnings) + + +# --- ИНН под наименованием, в дереве --- + + +def test_inn_below_in_tree_with_sums_is_glued(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A)) + + assert texts(glue) == ["60", "ООО Альфа"] + assert glue.inns == {2: INN_A} + assert (glue.unpaired, glue.ok) == (frozenset(), True) + assert has_warning(glue, "строка 3", INN_A, "отнесён к контрагенту в строке 2") + + +def test_inn_below_in_tree_without_sums_is_glued(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_12)) + + assert glue.inns == {2: INN_12} + assert glue.ok + + +def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(2, INN_A, A), + r(3, "Договор № 1", A), + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"] + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2) + + +def test_inn_with_contracts_under_counterparty_belongs_to_parent(rules: Rules) -> None: + # Без подсказки: родитель по схеме — контрагент, значит ИНН его, а дети строки ИНН — договоры + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(2, INN_A, A), + r(3, "Договор № 1", A), + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert glue.inns == {2: INN_A} + assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"] + + +def test_inn_between_department_and_name_is_above(rules: Rules) -> None: + # Родитель по схеме — подразделение: строка ИНН стоит над наименованием + schema = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), + ] + glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A, A), r(3, "ООО Альфа", A), schema=schema) + + assert glue.inns == {4: INN_A} + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2) + + +def test_two_inn_rows_under_one_name_stay_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A), r(2, INN_B)) + + assert glue.unpaired == frozenset({3, 4}) + assert not glue.ok + assert has_warning(glue, "строка 3", "несколько строк ИНН") + + +def test_inn_row_with_other_sums_stays_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, B)) + + assert glue.unpaired == frozenset({3}) + assert glue.inns == {} + assert has_warning(glue, "строка 3", "суммы не равны") + + +# --- ИНН над наименованием, в дереве --- + + +def test_inn_above_in_tree_with_sums_is_glued_and_name_moves_up(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A)) + + assert texts(glue) == ["60", "ООО Альфа"] + assert glue.inns == {3: INN_A} + assert (glue.levels.by_row[3], glue.levels.parent[3]) == (1, 1) + + +def test_inn_above_in_tree_without_sums_is_glued(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A)) + + assert glue.inns == {3: INN_A} + assert glue.ok + + +def test_inn_above_with_two_children_stays_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A), r(2, "ООО Бета", B)) + + assert glue.unpaired == frozenset({2}) + assert texts(glue) == ["60", INN_A, "ООО Альфа", "ООО Бета"] + assert has_warning(glue, "строка 2", "несколько") + + +def test_no_extra_level_keeps_inn_named_counterparty(rules: Rules) -> None: + # Схема «Контрагенты, Договоры», глубже схемы строк нет: «7701234560» — контрагент с одним договором, не строка ИНН + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "Договор № 1", A), schema=SCHEMA_WITH_CONTRACTS) + + assert texts(glue) == ["60", INN_A, "Договор № 1"] + assert (glue.inns, glue.unpaired, glue.warnings, glue.ok) == ({}, frozenset(), [], True) + + +def test_extra_level_allows_inn_above_with_contract_schema(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, INN_A, A), + r(2, "ООО Альфа", A), + r(3, "Договор № 1", A), + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert glue.inns == {3: INN_A} + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 3) + + +# --- Распознавание и контрольная сумма --- + + +@pytest.mark.parametrize( + ("label", "inn"), + [ + (Decimal(7701234560), INN_A), + (123456788, "0123456788"), # 9 цифр числом (#34) + ("ИНН 7701234560", INN_A), + ("ИНН: 7701234560 / 770101001", INN_A), + (INN_12, INN_12), + ], +) +def test_inn_row_forms(rules: Rules, label: object, inn: str) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, label, A)) + + assert glue.inns == {2: inn} + + +def test_number_with_lost_leading_zero_is_restored(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), hint=BELOW) + + assert glue.inns == {2: "0105012345"} + + +def test_bad_checksum_without_hint_is_lookalike(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A)) + + assert texts(glue) == ["60", "ООО Альфа", BAD] + assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), False) + assert has_warning(glue, "строка 3", "похожа на ИНН", "контрольная сумма не сходится") + + +def test_lone_number_named_row_without_names_around_is_silent(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, BAD, A)) + + assert (glue.warnings, glue.ok) == ([], True) + + +def test_hint_skips_checksum(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A), hint=BELOW) + + assert glue.inns == {2: BAD} + assert has_warning(glue, f"ИНН {BAD} («Контрагенты.ИНН») отнесён к контрагенту в строке 2") + + +def test_hint_below_empty_label_is_glued_without_inn(rules: Rules) -> None: + # R-1 #32: физлицо — 1С выводит строку реквизита с пустой ячейкой + glue = run(rules, r(0, "60", A), r(1, "Иванов Иван", A), r(2, None, A), hint=BELOW) + + assert texts(glue) == ["60", "Иванов Иван"] + assert (glue.inns, glue.ok) == ({}, True) + assert has_warning( + glue, "строка 3: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке 2 без ИНН" + ) + + +def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), hint=BELOW) + + assert glue.unpaired == frozenset({3}) + assert not glue.ok + assert has_warning(glue, "строка 3", "не распознан") + + +def test_column_inn_wins_over_inn_row(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A)) + + assert texts(glue) == ["60", "ООО Альфа"] + assert glue.inns == {} + assert not glue.ok + assert has_warning(glue, "строка 3", "расходится", INN_B) + + +def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A)) + + assert (glue.inns, glue.ok) == ({2: INN_A}, True) diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index 43179db..13ac112 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -257,6 +257,7 @@ def test_detector_rule_stays_strict_for_prefixed_text() -> None: ("770123456", False), ("77012345601", False), ("77012345a0", False), + ("770123456²", False), ], ) def test_inn_checksum_ok(inn: str, ok: bool) -> None: diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index f7af876..6db60ec 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -136,4 +136,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` | | 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится | | 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда | -| 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isdecimal()`, не `isdigit()` | `isdigit()` пропускает «²» и подобные, на которых `int()` падает с ValueError | Нет: вход — строки ИНН из `[0-9]`-выражений, на практике не встречается | +| 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isascii() and isdigit()`, не `isdecimal()` (ревью T1.2) | `isdecimal()` пропускает не-ASCII десятичные цифры, а ИНН — только `[0-9]`; `isdigit()` без `isascii()` пропускает «²» | Нет: ИНН с не-ASCII цифрами не встречаются; такая строка даёт `False` и не становится ИНН | +| 13 | 13.10.2026 | В `classify.py` локальная переменная `row_data_type` в `_classify_row` переименована в `data_type` (при публичной функции `row_data_type` иначе имя стало бы локальным и вызов упал с `UnboundLocalError`) | Переименование локальной переменной, поведение не меняется | Нет | -- 2.47.2 From 8bfaa4ec193e311e8c8e50dec112ed530b192e2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:48:06 +0400 Subject: [PATCH 05/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20fix-=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D0=B4=201=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=98=D0=9D=D0=9D=20=E2=80=94=20=D1=80=D0=B5=D0=BA=D0=B2=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=82=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=82,=20=D0=BF=D0=B0=D1=80=D0=B0=20=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=81=D1=85=D0=B5=D0=BC=D0=B5,=20=D0=B7=D0=B0=D1=89=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/parser/inn_rows.py | 22 +++++++++-- api/tests/unit/parser/test_inn_rows.py | 37 +++++++++++++++++++ .../changes/osv-inn-rows-layouts/design.md | 3 ++ .../specs/osv-parsing/spec.md | 6 +-- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 09ea3f3..269eb0a 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -173,6 +173,16 @@ def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGro return position < len(schema) and schema[position].type == "COUNTERPARTY" +def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool: + """Строка ИНН без детей под наименованием-родителем: родитель — пара, если подсказка «под», или родитель по схеме — + контрагент, или схема не позволяет сказать (design, design #15). Иначе строка — сосед наименования + контрагента под подразделением (R-2, design #15).""" + if hint is not None and hint.direction == "below": + return True + position = depth[parent] + return position >= len(schema) or schema[position].type == "COUNTERPARTY" + + def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: """С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9).""" @@ -185,7 +195,8 @@ def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) - if len(kids) != 1: continue kid = tree.by_row[kids[0]] - if tree.kind[kid.row] == "name" and kid.text and _same_sums(kid, item): + # ребёнок со своими детьми — подлинное наименование (у него своя строка ИНН), не реквизит (R-1, design #14) + if tree.kind[kid.row] == "name" and not tree.kids(kid.row) and kid.text and _same_sums(kid, item): tree.kind[kid.row] = "candidate" tree.inn[kid.row] = None pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)" @@ -195,13 +206,16 @@ def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, """Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми — над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`.""" depth = _depths(tree) - no_extra_level = _no_extra_level(depth, schema) + # подсказка в шапке — прямое свидетельство лишнего уровня: защита только без неё (R-3, design #16) + no_extra_level = hint is None and _no_extra_level(depth, schema) for row in tree.candidates(pairs): kids = tree.kids(row) parent = tree.parent[row] below = parent is not None and tree.kind[parent] == "name" - if below and kids and parent is not None: - below = _owned_by_parent(parent, depth, schema, hint) + if below and parent is not None: + below = ( + _owned_by_parent(parent, depth, schema, hint) if kids else _parent_can_own(parent, depth, schema, hint) + ) if below and parent is not None: rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"] if len(rivals) > 1: diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index b63e71f..1315571 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -265,6 +265,43 @@ def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None: assert has_warning(glue, "строка 3", "не распознан") +def test_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules) -> None: + # R-1 T2.1: единственный ребёнок подразделения с теми же суммами — наименование, у него своя строка ИНН + schema = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), + ] + glue = run( + rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), r(3, INN_A, A), hint=BELOW, schema=schema + ) + + assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"] + assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), True) + + +def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None: + # R-2 T2.2: родитель-подразделение — не пара; строка ИНН идёт в соседи (направление без подсказки не определено) + schema = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), + ] + glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A), r(2, "ООО Альфа", A), schema=schema) + + assert glue.inns == {} + assert glue.unpaired == frozenset({3}) + assert has_warning(glue, "строка 3", "направление") + + +def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None: + # R-3 T2.3: подсказка «над» — свидетельство лишнего уровня; защита работает только без подсказки + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE, schema=SCHEMA_WITH_CONTRACTS) + + assert texts(glue) == ["60", "ООО Альфа"] + assert (glue.inns, glue.unpaired) == ({3: INN_A}, frozenset()) + + def test_column_inn_wins_over_inn_row(rules: Rules) -> None: glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A)) diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index 6db60ec..bd41b86 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -138,3 +138,6 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда | | 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isascii() and isdigit()`, не `isdecimal()` (ревью T1.2) | `isdecimal()` пропускает не-ASCII десятичные цифры, а ИНН — только `[0-9]`; `isdigit()` без `isascii()` пропускает «²» | Нет: ИНН с не-ASCII цифрами не встречаются; такая строка даёт `False` и не становится ИНН | | 13 | 13.10.2026 | В `classify.py` локальная переменная `row_data_type` в `_classify_row` переименована в `data_type` (при публичной функции `row_data_type` иначе имя стало бы локальным и вызов упал с `UnboundLocalError`) | Переименование локальной переменной, поведение не меняется | Нет | +| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` | +| 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` | +| 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index 854ab9d..1fe5742 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -111,9 +111,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST - у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в `group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не склеивается и без предупреждения классифицируется по схеме; -- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Если у - такой строки ИНН есть дети, пара — родитель только при подсказке «под» или (без подсказки) если родитель по схеме — - COUNTERPARTY; иначе действует правило «есть дети»; +- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара — + родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки + ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт); - иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа (над → следующий, под → предыдущий). -- 2.47.2 From 71d56bd5ed715cd89412bf4c68234bf12bf2f190 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:53:58 +0400 Subject: [PATCH 06/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D0=B5=D0=B4=D0=B8=20=D0=BF=D0=BE=20=D1=81=D1=83?= =?UTF-8?q?=D0=BC=D0=BC=D0=B0=D0=BC=20=D0=B8=20=D0=BD=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B0=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строки ИНН не в дереве: с суммами — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и однозначных блоков детей. Противоречие или отсутствие сведений — строка без пары. Хвосты ревью Task 2: тест подразделения переписан на итоговый результат (T2.9); в спеке уточнены реквизит «нерез.» (только лист, design #14) и защита «над, в дереве» с подсказкой шапки (design #16) (T2.10); опечатка в докстринге _parent_can_own (T2.11). Co-Authored-By: Claude Opus 5.5 --- api/src/parser/inn_rows.py | 91 +++++++++++- api/tests/unit/parser/test_inn_rows.py | 133 +++++++++++++++++- .../changes/osv-inn-rows-layouts/design.md | 1 + .../specs/osv-parsing/spec.md | 7 +- 4 files changed, 221 insertions(+), 11 deletions(-) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 269eb0a..6bc27ba 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -28,6 +28,7 @@ from src.parser.rules import Rules __all__ = ["InnGlue", "glue_inn_rows"] Kind = Literal["total", "account", "indicator", "candidate", "name"] +Direction = Literal["above", "below"] # направление листа: строка ИНН над наименованием или под ним _ZERO = Decimal(0) _UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено" @@ -175,7 +176,7 @@ def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGro def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool: """Строка ИНН без детей под наименованием-родителем: родитель — пара, если подсказка «под», или родитель по схеме — - контрагент, или схема не позволяет сказать (design, design #15). Иначе строка — сосед наименования + контрагент, или схема не позволяет сказать (design #15). Иначе строка — сосед наименования контрагента под подразделением (R-2, design #15).""" if hint is not None and hint.direction == "below": return True @@ -235,10 +236,92 @@ def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs.flat.append(row) -def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: # noqa: ARG001 - hint нужен в Task 3 - """Строки ИНН не в дереве (соседи наименования) — Task 3; пока без пары.""" +def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]: + """Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование).""" + siblings = tree.children.get(tree.parent[row], []) + index = siblings.index(row) + before = siblings[index - 1] if index > 0 else None + after = siblings[index + 1] if index + 1 < len(siblings) else None + return ( + before if before is not None and tree.kind[before] == "name" else None, + after if after is not None and tree.kind[after] == "name" else None, + ) + + +def _block_direction(tree: _Tree, pending: list[int], pairs: _Pairs) -> set[Direction]: + """Сведения о направлении из блоков: дети одного родителя — несопоставленные строки ИНН и наименования. + + Начинается строкой ИНН — над (иначе первой строке ИНН не досталось бы владельца), кончается — под; начинается и + кончается — блок сломан, его строки ИНН без пары; ни то ни другое — сведений нет. + """ + waiting = set(pending) + claimed = set(pairs.partner.values()) + found: set[Direction] = set() + for parent in dict.fromkeys(tree.parent[row] for row in pending): + block = [ + row + for row in tree.children.get(parent, []) + if row in waiting or (tree.kind[row] == "name" and row not in claimed) + ] + starts, ends = block[0] in waiting, block[-1] in waiting + if starts and ends: + for row in block: + if row in waiting: + pairs.reason[row] = "блок строк начинается и заканчивается строкой ИНН — направление не определить" + elif starts: + found.add("above") + elif ends: + found.add("below") + return found + + +def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: + """Строки ИНН не в дереве: с суммами — сосед с теми же суммами; без сумм (или совпали оба соседа) — сосед по + направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и блоков.""" + pending: list[int] = [] + evidence: set[Direction] = set() for row in pairs.flat: - pairs.reason[row] = _UNKNOWN_DIRECTION + item = tree.by_row[row] + if not _has_sums(item): + pending.append(row) + continue + before, after = _neighbours(tree, row) + sides: list[tuple[int | None, Direction]] = [(before, "below"), (after, "above")] + matches = [(other, side) for other, side in sides if other is not None and _same_sums(tree.by_row[other], item)] + if len(matches) == 1: + other, side = matches[0] + pairs.partner[row] = other + evidence.add(side) + elif matches: + pending.append(row) + else: + pairs.reason[row] = "суммы не совпали ни с одним соседним наименованием" + if pending: + _pair_by_direction(tree, pending, hint, evidence, pairs) + + +def _pair_by_direction( + tree: _Tree, pending: list[int], hint: InnHint | None, evidence: set[Direction], pairs: _Pairs +) -> None: + """Строки ИНН без сумм (и с неоднозначными соседями по суммам) — сосед по направлению листа.""" + if hint is not None: + direction: Direction | None = hint.direction + else: + evidence |= _block_direction(tree, pending, pairs) + direction = next(iter(evidence)) if len(evidence) == 1 else None + for row in pending: + if row in pairs.reason: + continue + if direction is None: + pairs.reason[row] = _UNKNOWN_DIRECTION + continue + before, after = _neighbours(tree, row) + other = after if direction == "above" else before + if other is None: + where = "под" if direction == "above" else "над" + pairs.reason[row] = f"{where} строкой ИНН нет наименования" + else: + pairs.partner[row] = other def _check_sums(tree: _Tree, pairs: _Pairs) -> None: diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index 1315571..1a03d15 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -281,7 +281,7 @@ def test_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules) def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None: - # R-2 T2.2: родитель-подразделение — не пара; строка ИНН идёт в соседи (направление без подсказки не определено) + # R-2 T2.2 / T2.9: родитель-подразделение — не пара; ИНН без детей уходит соседу-контрагенту (блок «над») schema = [ OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="DEPARTMENT", name="Подразделения"), @@ -289,9 +289,9 @@ def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_ ] glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A), r(2, "ООО Альфа", A), schema=schema) - assert glue.inns == {} - assert glue.unpaired == frozenset({3}) - assert has_warning(glue, "строка 3", "направление") + assert glue.inns == {4: INN_A} + assert glue.unpaired == frozenset() + assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"] def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None: @@ -315,3 +315,128 @@ def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None: glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A)) assert (glue.inns, glue.ok) == ({2: INN_A}, True) + + +# --- Не в дереве: соседи того же родителя --- + + +def test_inn_above_sibling_without_sums(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B)) + + assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"] + assert glue.inns == {3: INN_A, 5: INN_B} + assert glue.ok + + +def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None: + # Дефект main: ИНН первого контрагента доставался второму + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B)) + + assert glue.inns == {2: INN_A, 4: INN_B} + assert glue.ok + + +def test_inn_above_sibling_with_sums(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B)) + + assert glue.inns == {3: INN_A, 5: INN_B} + + +def test_inn_below_sibling_with_sums(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B)) + + assert glue.inns == {2: INN_A, 4: INN_B} + + +def test_sums_match_no_neighbour(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A)) + + assert glue.unpaired == frozenset({3}) + assert has_warning(glue, "строка 3", "суммы не совпали") + + +def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None: + rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A)) + + assert run(rules, *rows).unpaired == frozenset({3}) + assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A} + + +def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B)) + + assert glue.unpaired == frozenset({3}) + assert not glue.ok + assert has_warning(glue, "строка 3", "направление") + + +def test_direction_from_other_block(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "60.01", A), + r(2, "ООО Альфа", A), + r(2, INN_A), + r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт + r(1, "60.02", B), + r(2, "ООО Гамма", B), + r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием + ) + + assert glue.inns == {3: INN_A, 7: INN_B} + + +def test_direction_from_sum_pair(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(1, INN_A, A), + r(1, "ООО Бета", B), + r(1, INN_B), + r(1, "Иванов Иван", B), + ) + + assert glue.inns == {2: INN_A, 4: INN_B} + + +def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None: + glue = run( + rules, + r(0, "60", A), + r(1, "60.01", A), + r(2, INN_A), + r(2, "ООО Альфа", A), # начинается строкой ИНН — над + r(1, "60.02", B), + r(2, "ООО Бета", B), + r(2, INN_B), # кончается строкой ИНН — под + ) + + assert glue.unpaired == frozenset({3, 7}) + + +def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B)) + + assert glue.unpaired == frozenset({2, 4}) + assert has_warning(glue, "строка 2", "начинается и заканчивается") + + +def test_hint_direction_wins_over_blocks(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE) + + assert glue.inns == {4: BAD} + + +def test_two_inn_rows_claim_one_name(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A)) + + assert glue.unpaired == frozenset({2, 4}) + assert has_warning(glue, "претендуют") + + +def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE) + + assert glue.unpaired == frozenset({3}) + assert has_warning(glue, "строка 3", "нет наименования") diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index bd41b86..5026556 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -141,3 +141,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` | | 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` | | 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает | +| 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index 1fe5742..c7adcae 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -105,12 +105,13 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН «ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол., не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не -ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»). +ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); таким ребёнком MUST быть только строка без детей +(лист), у строки со своими детьми — подлинное наименование (design #14). **Пара строки ИНН** (связь в дереве важнее соседства): - у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в - `group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не - склеивается и без предупреждения классифицируется по схеме; + `group_schema` больше одной группировки, ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы и в шапке нет + подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16); - родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара — родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт); -- 2.47.2 From 26ed11ad289a4ea104ca7526fe4cecf7c8daa58f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 15:03:40 +0400 Subject: [PATCH 07/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D0=B0,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=D1=88=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=20=D0=BE=D0=B1=D0=BE=D0=B8=D1=85=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=8F=D0=B4=D0=BA=D0=B0=D1=85,=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=8B=D0=B5=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D1=8B=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хвосты ревью Task 3: формулировка направления листа в spec/design (T3.1), тесты противоречия сведений и приоритета подсказки (T3.2), assert на glue.ok (T3.4). Co-Authored-By: Claude Opus 5.5 --- api/src/parser/classify.py | 164 +++--------------- api/src/parser/header.py | 82 ++++----- api/src/parser/hierarchy.py | 1 - api/src/parser/osv.py | 34 +++- api/tests/fixtures/real_osv.py | 25 ++- api/tests/synthetic/test_synthetic_files.py | 1 + api/tests/unit/parser/test_classify.py | 68 ++++++-- api/tests/unit/parser/test_inn_rows.py | 35 ++++ api/tests/unit/parser/test_inn_subkonto.py | 81 +++++++++ .../changes/osv-inn-rows-layouts/design.md | 3 +- .../specs/osv-parsing/spec.md | 8 +- 11 files changed, 284 insertions(+), 218 deletions(-) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 048b7c0..f5dee97 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -7,9 +7,10 @@ счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка «Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0. 2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`, - нераспознанные колонки таблицы (`extra`); -3. `glue_inn_attributes` — строки реквизита «Контрагенты.ИНН» под контрагентом отдают ИНН ему и уходят из строк - (OQ-162). + нераспознанные колонки таблицы (`extra`). + +ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`): между шагами 1 и 2 +стоит проход склейки строк ИНН (#35). Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде — `ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе @@ -18,8 +19,9 @@ """ import re -from collections.abc import Sequence -from dataclasses import dataclass, replace +from collections.abc import Mapping, Sequence +from collections.abc import Set as AbstractSet +from dataclasses import dataclass from decimal import Decimal from typing import Literal @@ -28,9 +30,8 @@ from src.contract.result import OsvGroup from src.parser.deadline import Deadline from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat from src.parser.grid import Sheet, column_letter -from src.parser.header import InnAttribute from src.parser.hierarchy import BodyRow, Levels -from src.parser.numbers import column_inn, inn_only, split_inn_suffix, to_money +from src.parser.numbers import column_inn, split_inn_suffix, to_money from src.parser.rules import GroupType, Rules, first_match, normalize_text __all__ = [ @@ -41,7 +42,6 @@ __all__ = [ "RowGroupType", "classify_rows", "collect_body_rows", - "glue_inn_attributes", "row_data_type", ] @@ -82,9 +82,6 @@ class ClassifiedRow: counterparty_inn: str | None data_type: DataType | None extra: dict[str, str | None] | None - # Строка реквизита «Контрагенты.ИНН» (OQ-162) и ИНН из её ячейки; склеивает `glue_inn_attributes` - is_inn_attribute: bool = False - attribute_inn: str | None = None # --- Строки таблицы --- @@ -191,40 +188,6 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu return tuple(sums.values()) -def _has_sums(item: BodyRow) -> bool: - return any(value is not None for value in item.sums) - - -def _glue_inn_rows(body: list[BodyRow], rules: Rules, warnings: list[str]) -> list[BodyRow]: - """Строка с одним ИНН и без сумм над строкой с суммами того же уровня оформления (outline и отступ) — ИНН - контрагента под ней (OQ-130): из строк она убирается, ИНН переходит к следующей строке (`inn_above`). - - Ничего иного такая строка не значит, а оставшись в таблице, стала бы OTHER и потеряла ИНН. Другой уровень - оформления или отсутствие сумм у следующей строки — строка остаётся как есть. - """ - glued: list[BodyRow] = [] - pending: BodyRow | None = None - for item in body: - if pending is not None: - same_level = (pending.outline, pending.indent) == (item.outline, item.indent) - if same_level and _has_sums(item) and not item.is_account: - inn = inn_only(pending.text, rules) - warnings.append( - f"строка {pending.row}: ИНН {inn} над строкой {item.row} отнесён к ней как к контрагенту" - ) - item = replace(item, inn_above=inn) # noqa: PLW2901 - строка меняется, а не подменяется другой - else: - glued.append(pending) - pending = None - if not _has_sums(item) and not item.is_account and inn_only(item.text, rules) is not None: - pending = item - continue - glued.append(item) - if pending is not None: - glued.append(pending) - return glued - - def _first_sum_col(header: TableHeader) -> int: return min(header.columns.indexes().values()) @@ -312,7 +275,7 @@ def collect_body_rows( if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("суммы строк") body.append(_body_row(sheet, row, header, label_cols, warnings)) - return _glue_inn_rows(body, rules, warnings), total_row, warnings + return body, total_row, warnings # --- Классификация --- @@ -369,26 +332,24 @@ class _Kind: data_type: DataType | None counterparty_name: str | None = None counterparty_inn: str | None = None - is_inn_attribute: bool = False - attribute_inn: str | None = None @dataclass(frozen=True) class _Context: - schema: Sequence[OsvGroup | InnAttribute] + schema: Sequence[OsvGroup] header: TableHeader sheet: Sheet doc_data_type: DataType | None rules: Rules currency_codes: frozenset[str] + inns: Mapping[int, str] + unpaired: AbstractSet[int] -def _schema_group(schema: Sequence[OsvGroup | InnAttribute], position: int) -> tuple[GroupType, str | None]: +def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]: if position >= len(schema): return "OTHER", None group = schema[position] - if isinstance(group, InnAttribute): - return "OTHER", group.name return group.type, group.name @@ -405,33 +366,34 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple data_type = row_data_type(item.text, context.rules, context.currency_codes) if data_type is not None: return _Kind("OTHER", None, code, data_type), inherited + if item.row in context.unpaired: + # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте + return _Kind("OTHER", None, code, doc_data_type), inherited position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) - if position < len(context.schema) and isinstance(context.schema[position], InnAttribute): - # Реквизит ИНН строкой под контрагентом: ИНН — как из колонки ИНН (R-24), склейка — `glue_inn_attributes` - cell = context.sheet.cell(item.row, item.label_col or context.header.text_col) - inn = column_inn(cell, context.rules) - return _Kind("OTHER", name, code, doc_data_type, is_inn_attribute=True, attribute_inn=inn), branch if group_type != "COUNTERPARTY": return _Kind(group_type, name, code, doc_data_type), branch inn_col = context.header.inn_col column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) - # ИНН: колонка ИНН, затем строка без сумм над названием (OQ-130), затем хвост самого названия (OQ-100) + # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) counterparty, in_name = split_inn_suffix(item.text, context.rules) - inn = column or item.inn_above or in_name + inn = column or context.inns.get(item.row) or in_name return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно rows: Sequence[BodyRow], levels: Levels, - schema: Sequence[OsvGroup | InnAttribute], + schema: Sequence[OsvGroup], header: TableHeader, sheet: Sheet, doc_data_type: DataType | None, total_row: int | None, rules: Rules, + *, + inns: Mapping[int, str] | None = None, + unpaired: AbstractSet[int] = frozenset(), ) -> list[ClassifiedRow]: """Классификация строк по дереву `levels` и схеме группировок. @@ -442,9 +404,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta группировки, а вид сумм родителя; позицию в схеме она не занимает; - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой — OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); - - позиция реквизита ИНН (`InnAttribute`, `GroupSchema.positions`) — OTHER с `is_inn_attribute` и ИНН ячейки. + - строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`. """ - context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes)) + context = _Context( + schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired + ) extra_keys = _extra_keys(sheet, header, rows) branches: dict[int, _Branch] = {} result: list[ClassifiedRow] = [] @@ -472,82 +436,6 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta counterparty_inn=kind.counterparty_inn, data_type=kind.data_type, extra=_extra(sheet, item.row, extra_keys), - is_inn_attribute=kind.is_inn_attribute, - attribute_inn=kind.attribute_inn, ) ) return result - - -# --- Реквизит «Контрагенты.ИНН» (OQ-162) --- - - -def _not_glued_reason(item: ClassifiedRow, parent: ClassifiedRow | None, siblings: int) -> str | None: - """Почему строку реквизита ИНН нельзя отнести к родителю; `None` — можно.""" - if parent is None or parent.group_type != "COUNTERPARTY": - return "над ней нет строки контрагента" - if siblings > 1: - return f"у контрагента в строке {parent.body.row} несколько строк ИНН" - if item.body.sums != parent.body.sums: - return f"суммы не равны суммам контрагента в строке {parent.body.row}" - if item.attribute_inn is None and item.body.text: - return "ИНН не распознан (нужно 10 или 12 цифр)" - if item.attribute_inn is not None and parent.counterparty_inn not in {None, item.attribute_inn}: - return f"ИНН расходится с ИНН {parent.counterparty_inn} контрагента в строке {parent.body.row}" - return None - - -def glue_inn_attributes(rows: Sequence[ClassifiedRow]) -> tuple[list[ClassifiedRow], list[str], bool]: - """Строки реквизита «Контрагенты.ИНН» (OQ-162; `05-domain-rules.md` §1.3) → ИНН родительского контрагента. - - Склеенная строка из строк уходит, её потомки (договоры) переходят к контрагенту на уровень выше. Склейка — только - если строка реквизита у контрагента одна, её суммы равны суммам контрагента, ИНН распознан и не расходится с уже - найденным; иначе строка остаётся OTHER, предупреждение с причиной и признак `False` (документ — `needs_check`). - Пустая ячейка ИНН (физлицо без ИНН в 1С) — «ИНН не указан»: строка склеивается, ИНН контрагента не меняется (R-1). - Возвращает строки, предупреждения и признак «все строки реквизита склеены». - """ - by_row = {item.body.row: item for item in rows} - siblings: dict[int | None, int] = {} - for item in rows: - if item.is_inn_attribute: - siblings[item.parent] = siblings.get(item.parent, 0) + 1 - glued: dict[int, int] = {} # строка реквизита -> строка контрагента - inns: dict[int, str] = {} # строка контрагента -> ИНН - warnings: list[str] = [] - for item in rows: - if not item.is_inn_attribute: - continue - parent = None if item.parent is None else by_row.get(item.parent) - reason = _not_glued_reason(item, parent, siblings[item.parent]) - if reason is not None or parent is None: - warnings.append(f"строка {item.body.row}: «{item.group_name}» не отнесён к контрагенту — {reason}") - continue - glued[item.body.row] = parent.body.row - if item.attribute_inn is None: - warnings.append( - f"строка {item.body.row}: ИНН пуст («{item.group_name}») — строка отнесена к контрагенту " - f"в строке {parent.body.row} без ИНН" - ) - continue - inns[parent.body.row] = item.attribute_inn - warnings.append( - f"строка {item.body.row}: ИНН {item.attribute_inn} («{item.group_name}») отнесён к контрагенту " - f"в строке {parent.body.row}" - ) - lifted: dict[int, int] = {} # строка -> число склеенных предков - result: list[ClassifiedRow] = [] - for item in rows: - parent_row = item.parent - shift = 0 if parent_row is None else lifted.get(parent_row, 0) + (1 if parent_row in glued else 0) - lifted[item.body.row] = shift - if item.body.row in glued: - continue - result.append( - replace( - item, - level=item.level - shift, - parent=None if parent_row is None else glued.get(parent_row, parent_row), - counterparty_inn=inns.get(item.body.row, item.counterparty_inn), - ) - ) - return result, warnings, len(glued) == sum(siblings.values()) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 52ca6c2..ac7e8a9 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -9,7 +9,7 @@ import calendar import datetime import re -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from dataclasses import dataclass from typing import Any, Literal, get_args @@ -23,7 +23,6 @@ __all__ = [ "ACCOUNT_NOT_FOUND_WARNING", "DocHeader", "GroupSchema", - "InnAttribute", "InnHint", "build_group_levels", "build_group_schema", @@ -482,14 +481,6 @@ def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None: return sheet.text_at(header.group_label_rows[0], header.text_col) or None -@dataclass(frozen=True) -class InnAttribute: - """Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой - контрагента, на уровень глубже. Это не уровень аналитики: в `group_schema` контракта его нет.""" - - name: str - - @dataclass(frozen=True) class InnHint: """Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку @@ -503,78 +494,71 @@ class InnHint: @dataclass(frozen=True) class GroupSchema: - """Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`). - - `positions[i]` — группировка строки на глубине i (0 — ACCOUNT) или реквизит ИНН, который глубину занимает, - а группировкой не является. Без реквизитов `positions` совпадает с `groups`. - """ + """Группировки контракта (`groups`), вариант шапки и подсказка для строк ИНН (`<группировка>.ИНН`, OQ-162, #35).""" groups: list[OsvGroup] - positions: list[OsvGroup | InnAttribute] variant: HeaderVariant + inn_hint: InnHint | None = None -def _inn_owner(item: str, groups: list[OsvGroup], rules: Rules) -> int | None: - """Индекс группировки контрагентов, чей реквизит ИНН — `item` («Контрагенты.ИНН»); иначе `None`.""" +def _attribute_owner(item: str, rules: Rules) -> str | None: + """Имя группировки-владельца, если `item` — реквизит ИНН («Контрагенты.ИНН» → «контрагенты»); иначе `None`.""" owner, separator, attribute = item.rpartition(_ATTRIBUTE_SEPARATOR) if not separator or _find(rules.headers.service_columns["inn"], attribute) is None: return None - owner = owner.strip().casefold() - for index, group in enumerate(groups): - if group.type == "COUNTERPARTY" and (group.name or "").casefold() == owner: - return index + return owner.strip().casefold() + + +def _inn_hint(items: Sequence[str], rules: Rules) -> InnHint | None: + """Подсказка строк ИНН по перечню группировок шапки (без счёта): первый реквизит `<группировка>.ИНН`, чей владелец — + группировка контрагентов того же перечня. Стоит после владельца — ИНН под наименованием, перед — над.""" + for index, item in enumerate(items): + owner = _attribute_owner(item, rules) + if owner is None: + continue + for other_index, other in enumerate(items): + if other.strip().casefold() == owner and rules.groups.classify(other) == "COUNTERPARTY": + return InnHint(item, "below" if index > other_index else "above") return None -def _attach_inn_attribute(item: str, groups: list[OsvGroup], slots: list[int | InnAttribute], rules: Rules) -> bool: - """`item` — реквизит ИНН одной из предыдущих группировок контрагентов: владелец получает `has_inn`, позиция строк — - `InnAttribute`; `True`. Иначе ничего не меняется, `False`.""" - owner = _inn_owner(item, groups, rules) - if owner is None: - return False - groups[owner] = _group("COUNTERPARTY", groups[owner].name, has_inn=True) - slots.append(InnAttribute(item)) - return True - - -def _positions(groups: list[OsvGroup], slots: list[int | InnAttribute]) -> list[OsvGroup | InnAttribute]: - """Позиции строк: индекс группировки в `groups` или реквизит ИНН (OQ-162).""" - return [slot if isinstance(slot, InnAttribute) else groups[slot] for slot in slots] +def _owns_hint(item: str, hint: InnHint | None, rules: Rules) -> bool: + return hint is not None and _attribute_owner(hint.name, rules) == item.strip().casefold() def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema: + labels = [sheet.text_at(row, header.text_col) for row in header.group_label_rows] + hint = _inn_hint(labels[1:], rules) groups: list[OsvGroup] = [] - slots: list[int | InnAttribute] = [] - for index, row in enumerate(header.group_label_rows): - label = sheet.text_at(row, header.text_col) - if index > 0 and _attach_inn_attribute(label, groups, slots, rules): + for index, (row, label) in enumerate(zip(header.group_label_rows, labels, strict=True)): + if index > 0 and hint is not None and label == hint.name: continue # Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто») group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label) - groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules))) - slots.append(len(groups) - 1) + has_inn = _row_has_inn(sheet, row, rules) or (group_type == "COUNTERPARTY" and _owns_hint(label, hint, rules)) + groups.append(_group(group_type, label, has_inn=has_inn)) if not groups: - groups, slots = [_group("ACCOUNT")], [0] - return GroupSchema(groups, _positions(groups, slots), 1) + groups = [_group("ACCOUNT")] + return GroupSchema(groups, 1, hint) def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema: groups = [_group("ACCOUNT", _first_group_name(sheet, header))] - slots: list[int | InnAttribute] = [0] + hint = _inn_hint(items, rules) inn_pending = header.inn_col is not None for item in items: - if _attach_inn_attribute(item, groups, slots, rules): + if hint is not None and item == hint.name: continue group_type = rules.groups.classify(item) has_inn = inn_pending and group_type == "COUNTERPARTY" inn_pending = inn_pending and not has_inn + has_inn = has_inn or (group_type == "COUNTERPARTY" and _owns_hint(item, hint, rules)) groups.append(_group(group_type, item, has_inn=has_inn)) - slots.append(len(groups) - 1) - return GroupSchema(groups, _positions(groups, slots), 2) + return GroupSchema(groups, 2, hint) def build_group_levels(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema: - """Группировки (первая — ACCOUNT), позиции строк и вариант шапки (1 — строки блока, 2 — «субконто: …»).""" + """Группировки (первая — ACCOUNT), вариант шапки (1 — строки блока, 2 — «субконто: …») и подсказка строк ИНН.""" items = _subkonto_items(_head_cells(sheet, header), rules) if items is None: return _variant1_groups(sheet, header, rules) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index bf30d7f..e5866c1 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -59,7 +59,6 @@ class BodyRow: outline: int sums: tuple[Decimal | None, ...] label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) - inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130) @dataclass(frozen=True) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index c978de2..d50bf67 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -12,13 +12,14 @@ from dataclasses import replace from src.contract import OsvDocument from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow from src.parser.checks import run_checks -from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows, glue_inn_attributes +from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows from src.parser.deadline import Deadline from src.parser.detect import TableHeader, find_table_header from src.parser.errors import InvalidFormatError from src.parser.grid import Sheet, column_letter from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header from src.parser.hierarchy import resolve_levels +from src.parser.inn_rows import glue_inn_rows from src.parser.rules import Rules __all__ = ["OsvParser", "data_rows", "sheet_line", "unique"] @@ -147,9 +148,30 @@ class OsvParser: body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) step = "иерархия строк" levels = resolve_levels(body, total_row, deadline) + step = "строки ИНН" + glue = glue_inn_rows( + body, + levels, + total_row, + sheet, + inn_col=header.inn_col, + schema=schema.groups, + hint=schema.inn_hint, + rules=rules, + ) step = "классификация строк" - classified = classify_rows(body, levels, schema.positions, header, sheet, doc.data_type, total_row, rules) - classified, inn_warnings, inn_glued = glue_inn_attributes(classified) + classified = classify_rows( + glue.rows, + glue.levels, + schema.groups, + header, + sheet, + doc.data_type, + total_row, + rules, + inns=glue.inns, + unpaired=glue.unpaired, + ) except InvalidFormatError as exc: raise _invalid_format(sheet, step, exc) from exc doc = _with_table_account(doc, classified) @@ -158,12 +180,12 @@ class OsvParser: document = self._document( sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks ) - warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *inn_warnings] + warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *glue.warnings] if not any(item.group_type != "TOTAL" for item in classified): warnings.append(NO_DATA_ROWS_WARNING) warnings.extend(check_warnings) - # Несклеенный реквизит «Контрагенты.ИНН» (OQ-162) — строка, которую нужно проверить глазами: needs_check - return document, unique(warnings), _is_ok(checks) and inn_glued + # Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check + return document, unique(warnings), _is_ok(checks) and glue.ok # --- внутреннее --- diff --git a/api/tests/fixtures/real_osv.py b/api/tests/fixtures/real_osv.py index 9bbbdd7..336edd4 100644 --- a/api/tests/fixtures/real_osv.py +++ b/api/tests/fixtures/real_osv.py @@ -17,8 +17,9 @@ from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows from src.parser.deadline import Deadline from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header from src.parser.grid import Sheet -from src.parser.header import DocHeader, build_group_schema, parse_doc_header +from src.parser.header import DocHeader, build_group_levels, parse_doc_header from src.parser.hierarchy import BodyRow, Levels, resolve_levels +from src.parser.inn_rows import glue_inn_rows from src.parser.readers import read_book from src.parser.rules import load_rules @@ -61,9 +62,25 @@ def parse_real(path: Path) -> RealParse: deadline = Deadline(PARSE_TIMEOUT_SECONDS) header = find_table_header(sheet, rules) doc = parse_doc_header(sheet, header, rules) - schema, _ = build_group_schema(sheet, header, rules) + schema = build_group_levels(sheet, header, rules) body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline) levels = resolve_levels(body, total_row, deadline) - rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules) + glue = glue_inn_rows( + body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules + ) + rows = classify_rows( + glue.rows, + glue.levels, + schema.groups, + header, + sheet, + doc.data_type, + total_row, + rules, + inns=glue.inns, + unpaired=glue.unpaired, + ) seconds = time.monotonic() - started - return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds) + return RealParse( + sheet, header, doc, schema.groups, glue.rows, total_row, glue.levels, rows, [*warnings, *glue.warnings], seconds + ) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index d079ae1..9ddfc91 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -518,6 +518,7 @@ def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> N assert "inn" not in doc.columns +@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True) def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None: """OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента.""" outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx") diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index dc177ac..7ba8974 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -17,8 +17,9 @@ from src.parser.deadline import Deadline from src.parser.detect import find_table_header from src.parser.errors import ParseTimeoutError from src.parser.grid import Sheet -from src.parser.header import build_group_schema +from src.parser.header import build_group_levels from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels +from src.parser.inn_rows import glue_inn_rows from src.parser.rules import Rules, load_rules TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." @@ -60,11 +61,25 @@ def parse( ) -> Parsed: deadline = deadline or Deadline(TIMEOUT_SECONDS) header = find_table_header(sheet, rules) - schema, _ = build_group_schema(sheet, header, rules) + schema = build_group_levels(sheet, header, rules) body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline) levels = resolve_levels(body, total_row, deadline) - rows = classify_rows(body, levels, schema, header, sheet, doc_data_type, total_row, rules) - return Parsed(rows, warnings, levels, total_row) + glue = glue_inn_rows( + body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules + ) + rows = classify_rows( + glue.rows, + glue.levels, + schema.groups, + header, + sheet, + doc_data_type, + total_row, + rules, + inns=glue.inns, + unpaired=glue.unpaired, + ) + return Parsed(rows, [*warnings, *glue.warnings], glue.levels, total_row) # --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры --- @@ -614,7 +629,7 @@ def test_inn_with_kpp_in_one_column_cell_gives_inn(rules: Rules) -> None: def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None: sheet = osv( r(0, "60", S100), - r(1, "7701234567"), # ИНН строкой выше названия, без сумм + r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна) r(1, "ООО Поставщик", S100), ) @@ -624,44 +639,67 @@ def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None: supplier = parsed.rows[1] assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == ( "ООО Поставщик", - "7701234567", + "7701234560", ACCOUNT_ROW, ) - assert any("ИНН" in warning and "7701234567" in warning for warning in parsed.warnings), parsed.warnings + assert any("7701234560" in warning for warning in parsed.warnings), parsed.warnings def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None: sheet = osv( r(0, "60", S100), - r(1, "ИНН 7701234567 / 770101001"), + r(1, "ИНН 7701234560 / 770101001"), r(1, "ООО Поставщик", S100), ) - assert parse(sheet, rules).rows[1].counterparty_inn == "7701234567" + assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560" -def test_inn_row_above_on_other_level_is_not_glued(rules: Rules) -> None: +def test_inn_row_above_in_tree_without_extra_level_is_not_glued(rules: Rules) -> None: + # Схема «Контрагенты, Договоры», глубже схемы строк нет — «7701234560» контрагент, «ООО Поставщик» под ним (защита) sheet = osv( r(0, "60", S100), - r(1, "7701234567"), + r(1, "7701234560", S100), r(2, "ООО Поставщик", S100), ) parsed = parse(sheet, rules) - assert [row.body.text for row in parsed.rows] == ["60", "7701234567", "ООО Поставщик"] + assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"] assert parsed.one("ООО Поставщик").counterparty_inn is None -def test_inn_row_above_before_row_without_sums_is_not_glued(rules: Rules) -> None: +def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None: sheet = osv( r(0, "60", S100), - r(1, "7701234567"), + r(1, "7701234560"), r(1, "ООО Поставщик"), r(1, "ООО Другой", S100), ) - assert "7701234567" in [row.body.text for row in parse(sheet, rules).rows] + parsed = parse(sheet, rules) + + assert "7701234560" not in [row.body.text for row in parsed.rows] + assert parsed.one("ООО Поставщик").counterparty_inn == "7701234560" + + +def test_inn_row_with_bad_checksum_is_left_as_is(rules: Rules) -> None: + sheet = osv(r(0, "60", S100), r(1, "7701234567"), r(1, "ООО Поставщик", S100)) + + parsed = parse(sheet, rules) + + assert "7701234567" in [row.body.text for row in parsed.rows] + assert parsed.one("ООО Поставщик").counterparty_inn is None + assert any("контрольная сумма не сходится" in warning for warning in parsed.warnings), parsed.warnings + + +def test_unpaired_inn_row_is_other(rules: Rules) -> None: + sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100), r(1, "7701234560"), r(1, "ООО Другой", S100)) + + parsed = parse(sheet, rules) + + inn_row = parsed.one("7701234560") + assert (inn_row.group_type, inn_row.counterparty_name, inn_row.counterparty_inn) == ("OTHER", None, None) # --- TOTAL --- diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index 1a03d15..fa04484 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -291,6 +291,7 @@ def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_ assert glue.inns == {4: INN_A} assert glue.unpaired == frozenset() + assert glue.ok assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"] @@ -428,6 +429,40 @@ def test_hint_direction_wins_over_blocks(rules: Rules) -> None: assert glue.inns == {4: BAD} +def test_sum_pair_below_and_block_above_contradict_each_other(rules: Rules) -> None: + # T3.2: пара по суммам говорит «под», однозначный блок (начинается строкой ИНН) — «над»: направление неизвестно + glue = run( + rules, + r(0, "60", A), + r(1, "60.01", A), + r(2, "ООО Альфа", A), + r(2, INN_A, A), # суммы совпали только с предыдущим — «под» + r(2, "ООО Бета", B), + r(1, "60.02", B), + r(2, INN_B), # блок 60.02 начинается строкой ИНН — «над» + r(2, "ООО Гамма", B), + ) + + assert glue.unpaired == frozenset({7}) + assert not glue.ok + assert has_warning(glue, "строка 7", "направление") + + +def test_hint_overrides_opposite_sum_pair_direction(rules: Rules) -> None: + # T3.2: пара по суммам даёт «под»; подсказка шапки «над» перекрывает это сведение для строки ИНН без сумм + rows = ( + r(0, "60", A), + r(1, "ООО Альфа", A), + r(1, INN_A, A), # пара по суммам с предыдущим — «под» + r(1, "ООО Бета", B), + r(1, INN_B), + r(1, "ООО Гамма", B), + ) + + assert run(rules, *rows).inns == {2: INN_A, 4: INN_B} + assert run(rules, *rows, hint=ABOVE).inns == {2: INN_A, 6: INN_B} + + def test_two_inn_rows_claim_one_name(rules: Rules) -> None: glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A)) diff --git a/api/tests/unit/parser/test_inn_subkonto.py b/api/tests/unit/parser/test_inn_subkonto.py index 55ee51c..d529612 100644 --- a/api/tests/unit/parser/test_inn_subkonto.py +++ b/api/tests/unit/parser/test_inn_subkonto.py @@ -211,3 +211,84 @@ def test_variant1_inn_attribute_row_is_glued(rules: Rules) -> None: assert ok assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"] assert document.rows[1].counterparty_inn == "2370012036" + + +# --- Подсказка в обратном порядке: «Контрагенты.ИНН, Контрагенты» — ИНН над наименованием --- + +DETAIL_ABOVE = "Детализация по субсчетам, субконто: Контрагенты.ИНН, Контрагенты" + + +def test_inn_attribute_before_owner_gives_hint_above(rules: Rules) -> None: + sheet = _sheet( + r(0, "60.01", A), r(1, 2370012036, A), r(1, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE + ) + + document, warnings, ok = _parse(sheet, rules) + + assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [ + ("ACCOUNT", "Субконто", None), + ("COUNTERPARTY", "Контрагенты", True), + ] + assert ok + assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"] + counterparty = document.rows[1] + assert (counterparty.counterparty_inn, counterparty.level, counterparty.parent_row_number) == ("2370012036", 1, 9) + assert any("2370012036" in warning for warning in warnings), warnings + + +def test_inn_attribute_before_owner_in_tree_gives_hint_above(rules: Rules) -> None: + # ИНН-реквизит стоит над наименованием и является его родителем в дереве (наименование на уровень глубже) + sheet = _sheet( + r(0, "60.01", A), r(1, 2370012036, A), r(2, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE + ) + + document, warnings, ok = _parse(sheet, rules) + + assert ok + assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"] + counterparty = document.rows[1] + assert (counterparty.counterparty_inn, counterparty.level, counterparty.parent_row_number) == ("2370012036", 1, 9) + assert any("«Контрагенты.ИНН»" in warning for warning in warnings), warnings + + +def test_hint_above_glues_sibling_without_checksum(rules: Rules) -> None: + # Подсказка снимает проверку контрольной суммы: «7701234567» — опечатка, но шапка говорит, что это ИНН + sheet = _sheet( + r(0, "60.01", A), + r(1, "7701234567", (None,) * 6), + r(1, "ООО Альфа", A), + [L(0), "Итого", *A], + detail=DETAIL_ABOVE, + ) + + document, _, ok = _parse(sheet, rules) + + assert ok + assert [(row.row_value, row.counterparty_inn) for row in document.rows if row.group_type == "COUNTERPARTY"] == [ + ("ООО Альфа", "7701234567") + ] + + +def test_variant1_inn_attribute_row_above_owner(rules: Rules) -> None: + head: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60 за 9 месяцев 2025 г."], + [], + ["Выводимые данные: БУ (данные бухгалтерского учета)"], + [], + ["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], + ["Контрагенты.ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["Контрагенты"], + ] + sheet = sheet_from_rows( + [*head, r(0, "60.01", A), r(1, 2370012036, A), r(1, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A]] + ) + + document, _, ok = _parse(sheet, rules) + + assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [ + ("ACCOUNT", "Счет", None), + ("COUNTERPARTY", "Контрагенты", True), + ] + assert ok + assert document.rows[1].counterparty_inn == "2370012036" diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index 5026556..8487509 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -70,7 +70,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs 4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе пары нет. 5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа. -6. **Направление документа** (лист ОСВ), первое определённое: +6. **Направление документа** (лист ОСВ): подсказка шапки (п. 1), если она есть; иначе сведения пп. 2–3 вместе: 1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над; 2. пары соседей, найденные по суммам (п. 5, один совпавший сосед); 3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не @@ -142,3 +142,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` | | 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает | | 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` | +| 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index c7adcae..78cfe32 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -119,10 +119,10 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST без сумм — сосед по направлению листа (над → следующий, под → предыдущий). Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление -листа** — первое определённое из: порядок `<группировка>.ИНН` в шапке; пары соседей, найденные по суммам; однозначные блоки -детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН — -под; начинается и кончается — блок сломан, его строки ИНН без пары). Противоречие сведений или их отсутствие — направление -неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары. +листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных +блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой +ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу +или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары. **Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»), её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` — -- 2.47.2 From 1f96012884d87e92010587cfdb4c67930567f9ed Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 15:12:00 +0400 Subject: [PATCH 08/12] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0:=20=D0=B2=D0=BE=D1=81=D0=B5=D0=BC=D1=8C=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20s20?= =?UTF-8?q?=E2=80=93s27,=20s18=20=D1=81=20=D0=B2=D0=B5=D1=80=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=98=D0=9D=D0=9D=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/synthetic/generate.py | 74 ++++++++++++++++++--- api/tests/synthetic/test_synthetic_files.py | 47 +++++++++++-- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 4c13f50..dc7f97a 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -1,7 +1,8 @@ """Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`. Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных -ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание — +ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь +раскладок строки ИНН (#35); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -69,9 +70,11 @@ class Node: num_style: NumStyle = "num" inn_style: InnStyle = "column" closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется + blank: bool = False # строка без сумм (строка ИНН без сумм, #35) + twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит def children(self) -> list["Node"]: - return [kid for kid in self.kids if not kid.indicator] + return [kid for kid in self.kids if not kid.indicator and kid.twin is None] def six(self) -> Six: """Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи).""" @@ -365,7 +368,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec: inn_line = Node(node.inn) _row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True) next_row += 1 - _row_cells(sheet, spec, next_row, (node, depth), node.six()) + _row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank) next_row += 1 total = SheetSpec("") _row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six()) @@ -628,10 +631,13 @@ def _s17() -> list[SheetSpec]: def _s18() -> list[SheetSpec]: - """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.""" - root = Node( - "60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])] - ) + """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет. + ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35).""" + first = alfa(inn_style="above") + first.inn = "7701234560" + third = gamma(inn_style="above") + third.inn = "5001234561" + root = Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third])]) return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] @@ -645,6 +651,50 @@ def _s19() -> list[SheetSpec]: return [build_osv(spec, root)] +InnLayout = Literal["above_sibling", "above_parent", "below_child", "below_sibling"] + + +def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node: + """То же дерево, где ИНН каждого контрагента выведен отдельной строкой в раскладке `layout` (#35): над / под + наименованием, в дереве (родитель / ребёнок) или соседом; `sums` — у строки ИНН те же суммы, иначе строка пустая. + Колонки ИНН нет, в названии ИНН не пишется.""" + kids: list[Node] = [] + for kid in node.kids: + kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией с строками ИНН + if kid.inn is None or kid.indicator: + kids.append(kid) + continue + inn, kid.inn = kid.inn, None + if layout == "above_sibling": + kids += [Node(inn, twin=kid, blank=not sums), kid] + elif layout == "below_sibling": + kids += [kid, Node(inn, twin=kid, blank=not sums)] + elif layout == "above_parent": + kids.append(Node(inn, [kid], blank=not sums)) + else: + kid.kids = [Node(inn, kid.kids, opening=kid.opening, turnover=kid.turnover, blank=not sums)] + kids.append(kid) + node.kids = kids + return node + + +def _inn_layout_tree() -> Node: + """Счёт 60 для раскладок строк ИНН (#35): ИНН с верной контрольной суммой (10 и 12 цифр), физлицо без ИНН.""" + first = alfa() + first.inn = "7701234560" + third = gamma() + third.inn = "5001234561" + return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) + + +def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]: + def build() -> list[SheetSpec]: + root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums) + return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] + + return build + + def _normalized_zip(data: bytes) -> bytes: """Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время при каждом `save`, заданное в свойствах значение перезаписывает).""" @@ -713,6 +763,14 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s17_inn_in_name.xlsx": (_s17, to_xlsx), "s18_inn_row_above.xlsx": (_s18, to_xlsx), "s19_inn_kpp_cell.xlsx": (_s19, to_xlsx), + "s20_inn_above_sibling_blank.xlsx": (_inn_layout("above_sibling", sums=False), to_xlsx), + "s21_inn_above_sibling_sums.xlsx": (_inn_layout("above_sibling", sums=True), to_xlsx), + "s22_inn_above_parent_blank.xlsx": (_inn_layout("above_parent", sums=False), to_xlsx), + "s23_inn_above_parent_sums.xlsx": (_inn_layout("above_parent", sums=True), to_xlsx), + "s24_inn_below_child_blank.xlsx": (_inn_layout("below_child", sums=False), to_xlsx), + "s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx), + "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), + "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), } FILE_NAMES = tuple(FILES) @@ -731,7 +789,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m tests.synthetic.generate", - description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.", + description="Синтетические ОСВ s01…s27 (xlsx и один xlsb) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 9ddfc91..a687ea5 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -26,6 +26,16 @@ def _require_synthetic(synthetic_dir: Path) -> None: SYNTHETIC_DIR = Path("osv") / "synthetic" +INN_LAYOUT_FILES = ( + "s20_inn_above_sibling_blank.xlsx", + "s21_inn_above_sibling_sums.xlsx", + "s22_inn_above_parent_blank.xlsx", + "s23_inn_above_parent_sums.xlsx", + "s24_inn_below_child_blank.xlsx", + "s25_inn_below_child_sums.xlsx", + "s26_inn_below_sibling_sums.xlsx", + "s27_inn_below_sibling_blank.xlsx", +) EXPECTED_NAMES = [ "s01_variant2_header.xlsx", "s02_no_outline_indent.xlsx", @@ -46,6 +56,7 @@ EXPECTED_NAMES = [ "s17_inn_in_name.xlsx", "s18_inn_row_above.xlsx", "s19_inn_kpp_cell.xlsx", + *INN_LAYOUT_FILES, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -109,6 +120,8 @@ EXPECTED: dict[str, Expect] = { "s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False), # колонка «ИНН/КПП» "s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False), + # восемь раскладок строки ИНН (#35) + **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES}, } @@ -180,7 +193,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: - """В `fixtures/osv/synthetic/` лежат файлы набора s01…s19 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s27 (s13 — xlsb; допустима деградация: нет только его).""" present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) assert present == [name for name in EXPECTED_NAMES if name in present] missing = [name for name in EXPECTED_NAMES if name not in present] @@ -518,19 +531,43 @@ def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> N assert "inn" not in doc.columns -@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True) def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None: """OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента.""" outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx") doc = one_document(outcome) assert not [row for row in doc.rows if row.group_type == "OTHER"] - assert not [row for row in doc.rows if row.row_value in {"7701234567", "5001234567"}] + assert not [row for row in doc.rows if row.row_value in {"7701234560", "5001234561"}] counterparties = { row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" } - assert counterparties == {ALFA: "7701234567", BETA: None, "Иванов Иван": None, GAMMA: "5001234567"} - assert any("ИНН 7701234567" in warning for warning in warnings_of(outcome)) + assert counterparties == {ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"} + assert any("ИНН 7701234560" in warning for warning in warnings_of(outcome)) + + +INN_LAYOUT_COUNTERPARTIES = { + ALFA: "7701234560", + BETA: "7707654321", + "Иванов Иван": None, + GAMMA: "5001234561", + "ИП Сидоров": "500100732259", +} + + +@pytest.mark.parametrize("name", INN_LAYOUT_FILES) +def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: str) -> None: + """#35: в каждой из восьми раскладок строки ИНН у каждого контрагента — ИНН его строки, строк ИНН в `rows` нет.""" + outcome = parse_named(fixtures_dir, name) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE, warnings_of(outcome) + counterparties = { + row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" + } + assert counterparties == INN_LAYOUT_COUNTERPARTIES + assert not [row for row in doc.rows if row.group_type == "OTHER"] + inns = {inn for inn in INN_LAYOUT_COUNTERPARTIES.values() if inn} + assert not [row for row in doc.rows if row.row_value in inns] def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: -- 2.47.2 From 6d97fa2c94a1f81b23803f3014bb5ca1275f9bfc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 15:31:08 +0400 Subject: [PATCH 09/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BE=20=D0=BF=D1=80=D0=BE=D1=82=D0=B8=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=B5=D1=87=D0=B8=D0=B8=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20=D1=88=D0=B0?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8,=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20s28=20=D1=81=20=D0=BD=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D1=81=D1=83=D0=BC=D0=BC=D0=BE?= =?UTF-8?q?=D0=B9=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/parser/inn_rows.py | 19 +++++++ api/tests/synthetic/generate.py | 13 ++++- api/tests/synthetic/test_synthetic_files.py | 24 ++++++++- api/tests/unit/parser/test_inn_rows.py | 53 +++++++++++++++++++ .../changes/osv-inn-rows-layouts/design.md | 13 ++++- .../specs/osv-parsing/spec.md | 20 +++++++ 6 files changed, 138 insertions(+), 4 deletions(-) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 6bc27ba..f6ab871 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -368,6 +368,22 @@ def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tupl return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy) +def _contradicts(row: int, partner: int, hint: InnHint) -> bool: + """Фактическая раскладка противоречит подсказке шапки: ИНН стоит под наименованием (строка пары выше) при + подсказке «над» или над ним (строка пары ниже) при подсказке «под». Годится для пар в дереве и для соседей.""" + actual: Direction = "below" if partner < row else "above" + return actual != hint.direction + + +def _contradiction(row: int, partner: int, inn: str, hint: InnHint) -> str: + """Склейка состоялась, но раскладка не та, что указана в шапке (design, решение 10): подсветить, проверка.""" + where, expected = ("под", "над") if hint.direction == "above" else ("над", "под") + return ( + f"строка {row}: ИНН {inn} стоит {where} наименованием в строке {partner}, а шапка («{hint.name}») " + f"указывает {expected} — проверьте" + ) + + def _result( # noqa: PLR0913, PLR0917 - сборка результата из всех частей прохода rows: Sequence[BodyRow], levels: Levels, @@ -402,6 +418,9 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из continue inns[partner] = inn warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}") + if hint is not None and _contradicts(row, partner, hint): + ok = False + warnings.append(_contradiction(row, partner, inn, hint)) elif row in pairs.reason: shown = "" if inn is None else f" {inn}" warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}") diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index dc7f97a..b8cfa99 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -2,7 +2,7 @@ Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь -раскладок строки ИНН (#35); описание — +раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -687,6 +687,14 @@ def _inn_layout_tree() -> Node: return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) +def _s28() -> list[SheetSpec]: + """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" + tree = _inn_layout_tree() + tree.kids[0].kids[0].inn = "7701234567" + root = inn_rows_tree(tree, "above_sibling", sums=False) + return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] + + def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]: def build() -> list[SheetSpec]: root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums) @@ -771,6 +779,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx), "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), + "s28_inn_bad_checksum.xlsx": (_s28, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -789,7 +798,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m tests.synthetic.generate", - description="Синтетические ОСВ s01…s27 (xlsx и один xlsb) в указанный каталог.", + description="Синтетические ОСВ s01…s28 (xlsx и один xlsb) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index a687ea5..ab5695f 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -36,6 +36,7 @@ INN_LAYOUT_FILES = ( "s26_inn_below_sibling_sums.xlsx", "s27_inn_below_sibling_blank.xlsx", ) +INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx" EXPECTED_NAMES = [ "s01_variant2_header.xlsx", "s02_no_outline_indent.xlsx", @@ -57,6 +58,7 @@ EXPECTED_NAMES = [ "s18_inn_row_above.xlsx", "s19_inn_kpp_cell.xlsx", *INN_LAYOUT_FILES, + INN_BAD_CHECKSUM_FILE, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -122,6 +124,8 @@ EXPECTED: dict[str, Expect] = { "s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False), # восемь раскладок строки ИНН (#35) **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES}, + # ИНН с неверной контрольной суммой: строка остаётся строкой, документ на проверку + INN_BAD_CHECKSUM_FILE: Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK), } @@ -193,7 +197,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: - """В `fixtures/osv/synthetic/` лежат файлы набора s01…s27 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s28 (s13 — xlsb; допустима деградация: нет только его).""" present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) assert present == [name for name in EXPECTED_NAMES if name in present] missing = [name for name in EXPECTED_NAMES if name not in present] @@ -570,6 +574,24 @@ def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: assert not [row for row in doc.rows if row.row_value in inns] +def test_s28_bad_checksum_inn_row_stays_a_row_and_needs_check(fixtures_dir: Path) -> None: + """Опечатка в ИНН ООО Альфа: «7701234567» остаётся обычной строкой, у Альфы ИНН нет, предупреждение, проверка; + остальные контрагенты получают свои ИНН (направление «над» — по блоку 60.02).""" + outcome = parse_named(fixtures_dir, INN_BAD_CHECKSUM_FILE) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.NEEDS_CHECK + counterparties = { + row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" + } + assert counterparties.pop("7701234567") is None # обычная строка: классификатор принял её за контрагента + assert counterparties == {**INN_LAYOUT_COUNTERPARTIES, ALFA: None} + assert any( + "похожа на ИНН 7701234567" in warning and "контрольная сумма не сходится" in warning + for warning in warnings_of(outcome) + ) + + def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: """«ИНН/КПП» в одной ячейке: в `counterparty_inn` — только ИНН; колонка распознана как колонка ИНН.""" doc = one_document(parse_named(fixtures_dir, "s19_inn_kpp_cell.xlsx")) diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index fa04484..9022caf 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -475,3 +475,56 @@ def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None: assert glue.unpaired == frozenset({3}) assert has_warning(glue, "строка 3", "нет наименования") + + +# --- Раскладка противоречит подсказке шапки: склеить, но подсветить (H-2, design #10) --- + + +def conflict_warning(glue: InnGlue, row: int, partner: int, where: str, expected: str) -> bool: + return has_warning( + glue, + f"строка {row}: ИНН", + f"стоит {where} наименованием в строке {partner}", + f"указывает {expected} — проверьте", + ) + + +def test_hint_above_inn_child_of_name_is_glued_with_conflict_warning(rules: Rules) -> None: + # T2.12: подсказка «над», строка ИНН без детей под родителем-контрагентом — склеена с родителем, но подсвечена + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=ABOVE) + + assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False) + assert has_warning(glue, f"ИНН {INN_A} («Контрагенты.ИНН») отнесён к контрагенту в строке 2") + assert conflict_warning(glue, 3, 2, "под", "над") + assert has_warning(glue, "шапка («Контрагенты.ИНН»)") + + +def test_hint_below_inn_parent_of_name_is_glued_with_conflict_warning(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=BELOW) + + assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), False) + assert conflict_warning(glue, 2, 3, "над", "под") + + +def test_hint_above_sibling_below_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), hint=ABOVE) + + assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False) + assert conflict_warning(glue, 3, 2, "под", "над") + + +def test_hint_below_sibling_above_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None: + glue = run(rules, r(0, "60", A), r(1, "ООО Бета", B), r(1, INN_A, A), r(1, "ООО Альфа", A), hint=BELOW) + + assert (glue.inns, glue.unpaired, glue.ok) == ({4: INN_A}, frozenset(), False) + assert conflict_warning(glue, 3, 4, "над", "под") + + +def test_hint_agreeing_with_layout_has_no_conflict_warning(rules: Rules) -> None: + below = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=BELOW) + above = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE) + blank = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=BELOW) + + for glue in (below, above, blank): + assert glue.ok + assert not has_warning(glue, "проверьте") diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index 8487509..c992ed3 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -47,6 +47,14 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs 7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32). 8. **Строка ИНН — только ИНН**: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП» (`inn_only_row`); счёт строкой ИНН не бывает. +9. **Синтетика для отказа «неверная контрольная сумма»** (решение владельца 13.10.2026, H-1): «синтетика для отказа + «неверная контрольная сумма» — да». Файл `s28_inn_bad_checksum.xlsx`: раскладка `s20` («над, не в дереве, без сумм»), + у ООО Альфа строка ИНН `7701234567`; ожидание — строка остаётся обычной, ИНН у Альфы нет, предупреждение «похожа на ИНН», + `needs_check`, остальные контрагенты получают свои ИНН (направление — по блоку 60.02). +10. **Противоречие раскладки и подсказки шапки** (решение владельца 13.10.2026, H-2): «Противоречие раскладки и подсказки + шапки — СКЛЕИВАТЬ, НО ПОДСВЕЧИВАТЬ». Пара найдена, но ИНН стоит не там, где указывает подсказка `<группировка>.ИНН` + (в дереве или соседом по суммам) — склейка как обычно, отдельное предупреждение «строка N: ИНН … стоит <под/над> + наименованием в строке M, а шапка («…») указывает <над/под> — проверьте», `ok = False`, `needs_check`. Закрывает T2.12. ### Алгоритм `inn_rows.py` @@ -83,7 +91,8 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает (прежний порядок §1.3). 9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к - контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). + контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки + (пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение 10). 10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32), предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`. @@ -143,3 +152,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает | | 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` | | 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит | +| 19 | 13.10.2026 | H-2: направление фактической раскладки определяется по номерам строк пары (строка пары выше строки ИНН — ИНН «под», ниже — «над»), одинаково для пар в дереве и соседей; проверка выполняется только при подсказке и только для склеенных пар с непустым ИНН и без расхождения с колонкой ИНН | Номер строки однозначно отражает порядок на листе при любом пути поиска пары; пары без ИНН, без пары и с колонкой-победителем уже подсвечены своими предупреждениями | Нет: предупреждение лишь добавляется к уже состоявшейся склейке; результат склейки не меняется | +| 20 | 13.10.2026 | H-1: в s28 строка «7701234567» после классификации попадает в `rows` как контрагент (обычная строка по правилам классификатора) — тест фиксирует это «как есть» и проверяет её наличие среди контрагентов без ИНН | Спека: строка «классифицируется как обычно»; подгонки парсера нет | Нет: поведение соответствует ожиданию владельца | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index 78cfe32..70b5592 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -129,6 +129,13 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST «строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся, в `warnings` — расхождение, документ — `needs_check`. +**Противоречие подсказке шапки:** если пара найдена, но фактическая раскладка противоречит направлению подсказки +`<группировка>.ИНН` (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него по суммам; подсказка «под», +а строка ИНН — родитель наименования или сосед выше него по суммам), строка ИНН MUST склеиваться как обычно, а в +`warnings` MUST быть отдельное предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка +(«<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся +из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт. + **Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N: ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться. @@ -156,6 +163,19 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST - **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится) - **THEN** строка не склеивается, в `warnings` — «похожа на ИНН, контрольная сумма не сходится», статус `needs_check` +#### Scenario: Опечатка в ИНН среди верных (s28) +- **WHEN** разбирается синтетическая ОСВ `s28` (раскладка `s20`, но у ООО Альфа строка ИНН «7701234567» с неверной + контрольной суммой; шапка без подсказки) +- **THEN** строка «7701234567» остаётся обычной строкой, у ООО Альфа `counterparty_inn` нет, в `warnings` — «строка N + похожа на ИНН 7701234567, но контрольная сумма не сходится», остальные контрагенты получают свои ИНН (направление «над» — + по блоку 60.02), статус `needs_check` + +#### Scenario: Раскладка противоречит подсказке шапки +- **WHEN** в шапке `Контрагенты.ИНН` («над»), а строка ИНН без детей стоит под родителем-контрагентом (или наоборот: + подсказка «под», строка ИНН — родитель наименования) +- **THEN** строка ИНН склеена с этим наименованием, в `warnings` — предупреждение «стоит под (над) наименованием … а шапка + … указывает над (под) — проверьте», статус `needs_check` + #### Scenario: Ведущий ноль у числовой строки ИНН - **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму) - **THEN** `counterparty_inn = "0123456788"` -- 2.47.2 From ce776f6ab73711a680807645f1928696b9e3076c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 15:50:10 +0400 Subject: [PATCH 10/12] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D1=8B=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B8:=20s28=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=2013=20=D0=B2?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B8=20=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=91=D1=82=D1=81=D1=8F=20COUNTERPARTY=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=98=D0=9D=D0=9D;=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0=20gen?= =?UTF-8?q?erate.py=20(H.3,=20T5.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/synthetic/generate.py | 9 ++++----- api/tests/synthetic/test_synthetic_files.py | 8 ++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index b8cfa99..74d1d02 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -2,10 +2,9 @@ Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь -раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — -`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + -`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок -записи фиксирован. +раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории +fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — +метки времени книги и zip постоянны, порядок записи фиксирован. Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и @@ -660,7 +659,7 @@ def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node: Колонки ИНН нет, в названии ИНН не пишется.""" kids: list[Node] = [] for kid in node.kids: - kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией с строками ИНН + kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией со строками ИНН if kid.inn is None or kid.indicator: kids.append(kid) continue diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index ab5695f..e76c92e 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -37,6 +37,7 @@ INN_LAYOUT_FILES = ( "s27_inn_below_sibling_blank.xlsx", ) INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx" +TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ "s01_variant2_header.xlsx", "s02_no_outline_indent.xlsx", @@ -590,6 +591,13 @@ def test_s28_bad_checksum_inn_row_stays_a_row_and_needs_check(fixtures_dir: Path "похожа на ИНН 7701234567" in warning and "контрольная сумма не сходится" in warning for warning in warnings_of(outcome) ) + # решение 20: предупреждение названо для строки 13, строка остаётся COUNTERPARTY без ИНН, в OTHER не уходит + assert any(f"строка {TYPO_ROW}: похожа на ИНН 7701234567" in warning for warning in warnings_of(outcome)) + typo_rows = [row for row in doc.rows if row.row_number == TYPO_ROW] + assert [(row.row_value, row.group_type, row.counterparty_inn) for row in typo_rows] == [ + ("7701234567", "COUNTERPARTY", None) + ] + assert not [row for row in doc.rows if row.group_type == "OTHER"] def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: -- 2.47.2 From 79ffe602f62843d170dbd89691be2479714248ff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 15:50:10 +0400 Subject: [PATCH 11/12] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F:=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=98?= =?UTF-8?q?=D0=9D=D0=9D,=20tasks=20change=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++--- README.md | 12 +++++++----- .../changes/osv-inn-rows-layouts/design.md | 1 + openspec/changes/osv-inn-rows-layouts/tasks.md | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/osv-inn-rows-layouts/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index a17c556..dd57b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,8 +56,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote - `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок (`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре. - `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка - и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `numbers.py` — - `Decimal` и ИНН; `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` — + и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `inn_rows.py` — + строки ИНН рядом с контрагентом (OQ-163); `numbers.py` — `Decimal` и ИНН (контрольная сумма); `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` — оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла. - `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера. @@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 113 файлов: 72 реальных + 22 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 19 синтетических) — регрессия, а не +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 123 файла: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 28 синтетических) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/README.md b/README.md index 7de7c2a..d30dac4 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) | | `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет | +| `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации | | `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) | | `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (91, 99) — вне тождества строки (`osv_checks.yaml`) | | `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики | @@ -174,11 +175,12 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо **Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты, Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true` -(`header.GroupSchema.positions` держит его позицию для классификатора). 1С выводит ИНН строкой под контрагентом, с теми же -суммами; `classify.glue_inn_attributes` отдаёт ИНН контрагенту (извлечение — как из колонки ИНН, R-24), строку убирает, -её детей поднимает на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». Пустая ячейка (физлицо без ИНН) — -склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или расходится) — строка `OTHER`, -предупреждение с причиной, документ `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`). +(`header.GroupSchema.positions` держит его позицию для классификатора). подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает +проверку контрольной суммы. Склейку выполняет `inn_rows.py` (OQ-163): ИНН строки отдаётся наименованию (извлечение — как из +колонки ИНН, R-24), строка убирается, её дети переходят на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». +Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или +расходится, направление неизвестно) — строка `OTHER`, предупреждение с причиной, документ `needs_check`; раскладка, противоречащая +подсказке шапки, склеивается с отдельным предупреждением и `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`). **Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала — нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index c992ed3..0b01565 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -154,3 +154,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит | | 19 | 13.10.2026 | H-2: направление фактической раскладки определяется по номерам строк пары (строка пары выше строки ИНН — ИНН «под», ниже — «над»), одинаково для пар в дереве и соседей; проверка выполняется только при подсказке и только для склеенных пар с непустым ИНН и без расхождения с колонкой ИНН | Номер строки однозначно отражает порядок на листе при любом пути поиска пары; пары без ИНН, без пары и с колонкой-победителем уже подсвечены своими предупреждениями | Нет: предупреждение лишь добавляется к уже состоявшейся склейке; результат склейки не меняется | | 20 | 13.10.2026 | H-1: в s28 строка «7701234567» после классификации попадает в `rows` как контрагент (обычная строка по правилам классификатора) — тест фиксирует это «как есть» и проверяет её наличие среди контрагентов без ИНН | Спека: строка «классифицируется как обычно»; подгонки парсера нет | Нет: поведение соответствует ожиданию владельца | +| 21 | 13.10.2026 | Task 7: в tasks.md добавлены пункты 1.5 (H-2) и 2.2 (s28, H-1) сверх брифа; число golden в CLAUDE.md пересчитано по факту (123: 72 + 23 + 28, ранее значилось 113 = 72 + 22 + 19) | Бриф писался до H-1/H-2; счёт — по `find` | Нет: только документация | diff --git a/openspec/changes/osv-inn-rows-layouts/tasks.md b/openspec/changes/osv-inn-rows-layouts/tasks.md new file mode 100644 index 0000000..efcd4f6 --- /dev/null +++ b/openspec/changes/osv-inn-rows-layouts/tasks.md @@ -0,0 +1,18 @@ +## 1. Код (tabular-parser) + +- [x] 1.1 Контрольная сумма ИНН и ИНН строки (`numbers.py`) +- [x] 1.2 Модуль `inn_rows.py`: склейка в дереве, контрольная сумма, защита «над, в дереве» +- [x] 1.3 Модуль `inn_rows.py`: соседи по суммам и направление листа +- [x] 1.4 Подключение в `osv.py`, подсказка шапки в обоих порядках, удаление `_glue_inn_rows` и `glue_inn_attributes` +- [x] 1.5 H-2: предупреждение о противоречии раскладки подсказке шапки (`needs_check`) + +## 2. Данные (fixtures) + +- [x] 2.1 Синтетика `s20`–`s27`, `s18` с верными ИНН +- [x] 2.2 Синтетика `s28` (H-1: неверная контрольная сумма среди верных ИНН) +- [x] 2.3 Сравнение main и ветки на всех fixtures, golden после согласования с владельцем + +## 3. Документация + +- [x] 3.1 docs: `05-domain-rules.md` §1.1, §1.3 (с H-1 и H-2), OQ-163 +- [x] 3.2 README и CLAUDE.md tabular-parser -- 2.47.2 From ad1a6ec10e6e08feb579c1583b248b0a7a4a309b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 16:20:39 +0400 Subject: [PATCH 12/12] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D0=98=D0=9D=D0=9D=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=81=D1=85=D0=B5=D0=BC=D0=B5,=20=D0=BF=D0=BE=D0=B4=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=C2=AB=D0=BD=D0=B0=D0=B4=C2=BB?= =?UTF-8?q?,=20=D1=81=D1=82=D1=80=D0=B0=D1=85=D0=BE=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C=D1=86=D0=B0,=20?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=81=D0=BA=D0=BB=D0=B5=D0=B9=D0=BA=D0=B8?= =?UTF-8?q?=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-6 (design #22), R-7 (#23), R-8 (#24), R-9 (#25), R-10 (#26), H.1/H.2 (#27); словарь позиций соседей (T2.6/T3.3); spec/design/README: текст предупреждения, цена ошибки design #1, ссылки на решение владельца 10 (M-3, M-4, M-5, M-8). Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++- api/src/parser/inn_rows.py | 58 ++++++++---- api/src/parser/osv.py | 17 +++- api/tests/unit/parser/test_inn_rows.py | 94 +++++++++++++++++++ api/tests/unit/parser/test_inn_rows_osv.py | 47 ++++++++++ .../changes/osv-inn-rows-layouts/design.md | 14 ++- .../specs/osv-parsing/spec.md | 19 ++-- 7 files changed, 229 insertions(+), 33 deletions(-) create mode 100644 api/tests/unit/parser/test_inn_rows_osv.py diff --git a/README.md b/README.md index d30dac4..e5f4745 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,9 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. **ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из -строки без сумм прямо над названием на том же уровне оформления (такая строка в `rows` не попадает, в -`metrics.warnings` — «ИНН … над строкой … отнесён к ней»), затем из конца текста названия («Полиграф, 3528315710», +строки ИНН рядом с наименованием (любая из восьми раскладок, `inn_rows.py`, OQ-163: такая строка в `rows` не попадает, в +`metrics.warnings` — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием» +заменено), затем из конца текста названия («Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк `COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`, @@ -175,11 +176,13 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо **Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты, Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true` -(`header.GroupSchema.positions` держит его позицию для классификатора). подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает +(владелец и направление — `GroupSchema.inn_hint`). Подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает проверку контрольной суммы. Склейку выполняет `inn_rows.py` (OQ-163): ИНН строки отдаётся наименованию (извлечение — как из колонки ИНН, R-24), строка убирается, её дети переходят на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». -Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или -расходится, направление неизвестно) — строка `OTHER`, предупреждение с причиной, документ `needs_check`; раскладка, противоречащая +Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан, направление неизвестно) — строка `OTHER`, +предупреждение с причиной, документ `needs_check`; ИНН строки, расходящийся с ИНН колонки у наименования, не применяется — +остаётся ИНН колонки, предупреждение, `needs_check`; строка из 10/12 цифр с неверной контрольной суммой (без подсказки) остаётся +обычной строкой, «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка оставлена как есть», `needs_check`; раскладка, противоречащая подсказке шапки, склеивается с отдельным предупреждением и `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`). **Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала — diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index f6ab871..3ecc22b 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -21,7 +21,7 @@ from src.contract.result import OsvGroup from src.parser.classify import row_data_type from src.parser.grid import Cell, Sheet from src.parser.header import InnHint -from src.parser.hierarchy import BodyRow, Levels, parents +from src.parser.hierarchy import BodyRow, Levels from src.parser.numbers import column_inn, inn_checksum_ok, row_inn from src.parser.rules import Rules @@ -44,6 +44,7 @@ class InnGlue: unpaired: frozenset[int] warnings: list[str] ok: bool + sources: dict[int, int] = field(default_factory=dict) # строка наименования -> строка ИНН, давшая ему ИНН @dataclass @@ -57,6 +58,10 @@ class _Tree: kind: dict[int, Kind] inn: dict[int, str | None] = field(default_factory=dict) # ИНН строки ИНН; None — пустой или не распознан lookalike: dict[int, str] = field(default_factory=dict) # 10/12 цифр с неверной контрольной суммой + position: dict[int, int] = field(default_factory=dict) # строка -> индекс среди детей её родителя + + def siblings(self, row: int) -> list[int]: + return self.children.get(self.parent[row], []) def kids(self, row: int) -> list[int]: """Дети строки, кроме Вал./Кол. (они не уровень аналитики).""" @@ -104,6 +109,8 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст for item in rows: children.setdefault(levels.parent[item.row], []).append(item.row) tree = _Tree(rows, {item.row: item for item in rows}, dict(levels.parent), children, {}) + for siblings in children.values(): + tree.position.update((row, index) for index, row in enumerate(siblings)) for item in rows: tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree) if hint is not None and hint.direction == "below": @@ -167,9 +174,10 @@ def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool: def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool: """Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка - шапки решает; без неё — «под», если родитель по схеме — контрагент (design, решение без обсуждения №10).""" - if hint is not None: - return hint.direction == "below" + «под» — родителя; иначе «под», если родитель по схеме — контрагент (design №10). Подсказка «над» против родителя- + контрагента не уводит ИНН ребёнку-договору: пара — родитель, а противоречие подсвечивает H-2 (R-7, design №23).""" + if hint is not None and hint.direction == "below": + return True position = depth[parent] return position < len(schema) and schema[position].type == "COUNTERPARTY" @@ -184,20 +192,26 @@ def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGrou return position >= len(schema) or schema[position].type == "COUNTERPARTY" -def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: +def _unrecognised_attributes(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None: """С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка - реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9).""" + реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design №9). Кандидат — ребёнок только у + наименования, которое по схеме контрагент (R-6, design №22); тогда ребёнок может иметь детей (договоры остаются + договорами). Позиция вне схемы или схема без позиции — прежнее условие R-1 (design №14): ребёнок — лист.""" if hint is None or hint.direction != "below": return + depth = _depths(tree) for item in tree.rows: if tree.kind[item.row] != "name": continue + position = depth[item.row] + if position < len(schema) and schema[position].type != "COUNTERPARTY": + continue kids = tree.kids(item.row) if len(kids) != 1: continue kid = tree.by_row[kids[0]] - # ребёнок со своими детьми — подлинное наименование (у него своя строка ИНН), не реквизит (R-1, design #14) - if tree.kind[kid.row] == "name" and not tree.kids(kid.row) and kid.text and _same_sums(kid, item): + allowed = position < len(schema) or not tree.kids(kid.row) + if tree.kind[kid.row] == "name" and allowed and kid.text and _same_sums(kid, item): tree.kind[kid.row] = "candidate" tree.inn[kid.row] = None pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)" @@ -238,8 +252,8 @@ def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]: """Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование).""" - siblings = tree.children.get(tree.parent[row], []) - index = siblings.index(row) + siblings = tree.siblings(row) + index = tree.position[row] before = siblings[index - 1] if index > 0 else None after = siblings[index + 1] if index + 1 < len(siblings) else None return ( @@ -342,8 +356,8 @@ def _drop_conflicts(pairs: _Pairs) -> None: def _near_name(tree: _Tree, row: int) -> bool: """Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование.""" parent = tree.parent[row] - siblings = tree.children.get(parent, []) - index = siblings.index(row) + siblings = tree.siblings(row) + index = tree.position[row] near = [*siblings[max(index - 1, 0) : index], *siblings[index + 1 : index + 2], *tree.children.get(row, [])] if parent is not None: near.append(parent) @@ -351,11 +365,14 @@ def _near_name(tree: _Tree, row: int) -> bool: def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]: - """Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень, родители пересчитываются.""" + """Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень; новый родитель — ближайший + неудалённый предок по прежнему дереву, а не пересчёт по уровням (иначе пропуск уровней outline даёт чужого + родителя; R-9, design №25).""" if not removed: return list(rows), levels lifted: dict[int, int] = {} by_row: dict[int, int] = {} + parent_of: dict[int, int | None] = {} kept: list[BodyRow] = [] for item in rows: parent = levels.parent[item.row] @@ -364,8 +381,11 @@ def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tupl if item.row in removed: continue by_row[item.row] = levels.by_row[item.row] - lift + while parent is not None and parent in removed: + parent = levels.parent[parent] + parent_of[item.row] = parent kept.append(item) - return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy) + return kept, Levels(by_row=by_row, parent=parent_of, strategy=levels.strategy) def _contradicts(row: int, partner: int, hint: InnHint) -> bool: @@ -376,7 +396,7 @@ def _contradicts(row: int, partner: int, hint: InnHint) -> bool: def _contradiction(row: int, partner: int, inn: str, hint: InnHint) -> str: - """Склейка состоялась, но раскладка не та, что указана в шапке (design, решение 10): подсветить, проверка.""" + """Склейка состоялась, но раскладка не та, что указана в шапке (решение владельца 10, H-2): подсветить, проверка.""" where, expected = ("под", "над") if hint.direction == "above" else ("над", "под") return ( f"строка {row}: ИНН {inn} стоит {where} наименованием в строке {partner}, а шапка («{hint.name}») " @@ -397,6 +417,7 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из label = "" if hint is None else f" («{hint.name}»)" warnings: list[str] = [] inns: dict[int, str] = {} + sources: dict[int, int] = {} ok = not pairs.reason for item in rows: row = item.row @@ -404,6 +425,8 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из if row in pairs.partner: partner = pairs.partner[row] if inn is None: + # пустой кандидат бывает только «под, в дереве» и склеивается с родителем: конфликта с колонкой нет + # (H.1 финального ревью: случай «расхождение с колонкой» для пустого ИНН недостижим) warnings.append( f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН" ) @@ -417,6 +440,7 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из ) continue inns[partner] = inn + sources[partner] = row warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}") if hint is not None and _contradicts(row, partner, hint): ok = False @@ -431,7 +455,7 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из "оставлена как есть" ) kept, kept_levels = _without(rows, levels, set(pairs.partner)) - return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok) + return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok, sources) def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно @@ -452,7 +476,7 @@ def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, ч """ tree = _build(rows, levels, total_row, sheet, hint, rules) pairs = _Pairs() - _unrecognised_attributes(tree, hint, pairs) + _unrecognised_attributes(tree, schema, hint, pairs) _pair_in_tree(tree, schema, hint, pairs) _pair_flat(tree, hint, pairs) _check_sums(tree, pairs) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index d50bf67..252e66f 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -19,7 +19,7 @@ from src.parser.errors import InvalidFormatError from src.parser.grid import Sheet, column_letter from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header from src.parser.hierarchy import resolve_levels -from src.parser.inn_rows import glue_inn_rows +from src.parser.inn_rows import InnGlue, glue_inn_rows from src.parser.rules import Rules __all__ = ["OsvParser", "data_rows", "sheet_line", "unique"] @@ -126,6 +126,20 @@ def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> Invalid return InvalidFormatError(f"лист «{sheet.name}», шаг «{step}»: {reason}") +def _check_inn_owners(glue: InnGlue, classified: list[ClassifiedRow]) -> InnGlue: + """Страховка (R-8, design №24): ИНН склеен со строкой, которая по схеме не контрагент (подразделение, договор, + OTHER) — ИНН пропадёт молча, поэтому предупреждение и `needs_check`.""" + types = {item.body.row: item.group_type for item in classified} + extra = [ + f"строка {glue.sources[owner]}: ИНН {inn} отнесён к строке {owner}, но она не контрагент — проверьте" + for owner, inn in glue.inns.items() + if owner in glue.sources and types.get(owner) != "COUNTERPARTY" + ] + if not extra: + return glue + return replace(glue, warnings=[*glue.warnings, *extra], ok=False) + + class OsvParser: """Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`.""" @@ -174,6 +188,7 @@ class OsvParser: ) except InvalidFormatError as exc: raise _invalid_format(sheet, step, exc) from exc + glue = _check_inn_owners(glue, classified) doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") checks, check_warnings = run_checks(classified, rules.checks) diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index 9022caf..40b2738 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -528,3 +528,97 @@ def test_hint_agreeing_with_layout_has_no_conflict_warning(rules: Rules) -> None for glue in (below, above, blank): assert glue.ok assert not has_warning(glue, "проверьте") + + +# --- Финальное ревью: R-6 … R-10, T2.6 --- + +SCHEMA_DEPARTMENT = [ + OsvGroup(type="ACCOUNT", name="Счет"), + OsvGroup(type="DEPARTMENT", name="Подразделения"), + OsvGroup(type="COUNTERPARTY", name="Контрагенты"), +] + + +def test_hint_below_unrecognised_attribute_with_contracts_under_it_is_other(rules: Rules) -> None: + # R-6 (design #22): родитель — контрагент: «нерез.» с договором под ней — реквизит (OTHER), договор — договор + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Нерезидент", A), + r(2, "нерез.", A), + r(3, "Договор 1", A), + hint=BELOW, + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert glue.unpaired == frozenset({3}) + assert glue.inns == {} + assert not glue.ok + assert (glue.levels.by_row[4], glue.levels.parent[4]) == (3, 3) # дерево не тронуто: классификатор пропустит OTHER + + +def test_hint_below_single_child_of_department_is_not_an_attribute(rules: Rules) -> None: + # R-6 (design #22): родитель по схеме — подразделение, его единственный ребёнок-лист — контрагент, не реквизит + glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), hint=BELOW, schema=SCHEMA_DEPARTMENT) + + assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), True) + assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"] + + +def test_hint_below_inn_with_single_contract_of_same_sums_is_glued_without_check(rules: Rules) -> None: + # R-6 (design #22): контрагент -> ИНН -> один договор с теми же суммами: ИНН у контрагента, договор остаётся + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(2, INN_A, A), + r(3, "Договор 1", A), + hint=BELOW, + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), True) + assert texts(glue) == ["60", "ООО Альфа", "Договор 1"] + + +def test_hint_above_inn_with_contract_under_counterparty_stays_with_parent_and_is_flagged(rules: Rules) -> None: + # R-7 (design #23): подсказка «над», контрагент -> ИНН -> договор: пара — родитель по схеме, H-2 подсвечивает + glue = run( + rules, + r(0, "60", A), + r(1, "ООО Альфа", A), + r(2, INN_A, A), + r(3, "Договор 1", A), + hint=ABOVE, + schema=SCHEMA_WITH_CONTRACTS, + ) + + assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False) + assert texts(glue) == ["60", "ООО Альфа", "Договор 1"] + assert conflict_warning(glue, 3, 2, "под", "над") + + +def test_levels_skipping_keep_nearest_surviving_ancestor(rules: Rules) -> None: + # R-9 (design #25): outline с пропуском уровней — новый родитель ближайший неудалённый предок (строка ИНН убрана) + glue = run( + rules, + r(0, "60", A), + r(1, "Физлицо", A), + r(3, "Договор", A), + r(1, INN_A, A), + r(3, "ООО Альфа", A), + ) + + assert glue.inns == {5: INN_A} + kept = {item.text: glue.levels.parent[item.row] for item in glue.rows} + assert kept == {"60": None, "Физлицо": 1, "Договор": 2, "ООО Альфа": 1} + + +def test_column_mismatch_plus_contradiction_gives_one_warning(rules: Rules) -> None: + # H.2: расхождение с колонкой ИНН и противоречие подсказке — предупреждение только о расхождении + glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, INN_B), r(2, INN_A, A), hint=ABOVE) + + assert glue.inns == {} + assert not glue.ok + assert has_warning(glue, "расходится с ИНН", INN_B) + assert not has_warning(glue, "шапка («") diff --git a/api/tests/unit/parser/test_inn_rows_osv.py b/api/tests/unit/parser/test_inn_rows_osv.py new file mode 100644 index 0000000..395ab28 --- /dev/null +++ b/api/tests/unit/parser/test_inn_rows_osv.py @@ -0,0 +1,47 @@ +"""Строки ИНН на уровне листа ОСВ: страховка после классификации (R-8, design №24; change osv-inn-rows-layouts, #35).""" + +import pytest +from gridbuilder import L, M, sheet_from_rows + +from src.parser.deadline import Deadline +from src.parser.osv import OsvParser +from src.parser.rules import Rules, load_rules + +TIMEOUT_SECONDS = 60 +DETAIL = "Детализация по субсчетам, субконто: Подразделения, Контрагенты" +A = (None, None, 760000, 760000, None, None) +INN_A = 7701234560 + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) -> None: + head: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60"], + ["Период: 9 месяцев 2025 г."], + [DETAIL], + ["Выводимые данные: сумма"], + [], + ["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M("Сальдо на конец периода", 2)], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ] + sheet = sheet_from_rows( + [ + *head, + [L(0), "60.01", *A], # 9 + [L(1), INN_A, *A], # 10 + [L(1), "ООО Альфа", *A], # 11 + [L(0), "Итого", *A], + ] + ) + + document, warnings, ok = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) + + assert [(row.row_value, row.group_type) for row in document.rows][1:2] == [("ООО Альфа", "DEPARTMENT")] + assert not ok + expected = f"строка 10: ИНН {INN_A} отнесён к строке 11, но она не контрагент — проверьте" + assert expected in warnings, warnings diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index 0b01565..7e3d2fd 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -92,7 +92,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs (прежний порядок §1.3). 9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки - (пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение 10). + (пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение владельца 10, H-2). 10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32), предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`. @@ -134,7 +134,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | # | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| -| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | Файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия | +| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | При ветке глубже схемы контрагент, названный ИНН, с одним договором отдаёт ИНН договору без `needs_check` (защита работает только без подсказки и только когда лист не глубже схемы); файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия | | 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка | | 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке | | 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки | @@ -143,11 +143,11 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` | | 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста | | 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` | -| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится | +| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: подсказка «под» — родителю (дети — договоры); иначе — родителю, если он по схеме `COUNTERPARTY` (при подсказке «над» — с подсветкой H-2, R-7, design #23), иначе ребёнку («над») | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится | | 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда | | 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isascii() and isdigit()`, не `isdecimal()` (ревью T1.2) | `isdecimal()` пропускает не-ASCII десятичные цифры, а ИНН — только `[0-9]`; `isdigit()` без `isascii()` пропускает «²» | Нет: ИНН с не-ASCII цифрами не встречаются; такая строка даёт `False` и не становится ИНН | | 13 | 13.10.2026 | В `classify.py` локальная переменная `row_data_type` в `_classify_row` переименована в `data_type` (при публичной функции `row_data_type` иначе имя стало бы локальным и вызов упал с `UnboundLocalError`) | Переименование локальной переменной, поведение не меняется | Нет | -| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` | +| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве); уточнено R-6 (design #22) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` | | 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` | | 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает | | 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` | @@ -155,3 +155,9 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs | 19 | 13.10.2026 | H-2: направление фактической раскладки определяется по номерам строк пары (строка пары выше строки ИНН — ИНН «под», ниже — «над»), одинаково для пар в дереве и соседей; проверка выполняется только при подсказке и только для склеенных пар с непустым ИНН и без расхождения с колонкой ИНН | Номер строки однозначно отражает порядок на листе при любом пути поиска пары; пары без ИНН, без пары и с колонкой-победителем уже подсвечены своими предупреждениями | Нет: предупреждение лишь добавляется к уже состоявшейся склейке; результат склейки не меняется | | 20 | 13.10.2026 | H-1: в s28 строка «7701234567» после классификации попадает в `rows` как контрагент (обычная строка по правилам классификатора) — тест фиксирует это «как есть» и проверяет её наличие среди контрагентов без ИНН | Спека: строка «классифицируется как обычно»; подгонки парсера нет | Нет: поведение соответствует ожиданию владельца | | 21 | 13.10.2026 | Task 7: в tasks.md добавлены пункты 1.5 (H-2) и 2.2 (s28, H-1) сверх брифа; число golden в CLAUDE.md пересчитано по факту (123: 72 + 23 + 28, ранее значилось 113 = 72 + 22 + 19) | Бриф писался до H-1/H-2; счёт — по `find` | Нет: только документация | +| 22 | 13.10.2026 | R-6 / I-1 (design #22): `_unrecognised_attributes` — ребёнок-реквизит только у наименования, чья позиция по схеме COUNTERPARTY; при такой позиции условие «ребёнок — лист» снимается (строка «нерез.» с договорами → OTHER, договоры остаются договорами); позиция по схеме не COUNTERPARTY — ребёнок не реквизит; позиции нет — прежнее условие «лист» | Иначе (регрессия #32) единственный контрагент подразделения с теми же суммами получал «ИНН не распознан» | Контрагент с единственным договором тех же сумм при подсказке «под» без строки реквизита — `needs_check` (как в №9) | +| 23 | 13.10.2026 | R-7 / I-2 (design #23): `_owned_by_parent`: подсказка «под» — родитель; иначе родитель, если по схеме COUNTERPARTY; подсказка «над» не уводит ИНН ребёнку-договору | По спеке пара — родитель; противоречие подсказке ловит H-2 (предупреждение, `needs_check`) вместо молчаливой потери ИНН | Подсказка «над» и реальный ИНН над контрагентом, у которого в схеме родитель — контрагент: ИНН уходит родителю с предупреждением, `needs_check` | +| 24 | 13.10.2026 | R-8 / I-3 (design #24): страховка в `osv.py` после классификации: ИНН склеен со строкой, не ставшей COUNTERPARTY, — предупреждение «ИНН … отнесён к строке M, но она не контрагент — проверьте», `needs_check` | ИНН иначе пропадает молча (подразделение/договор не хранят ИНН) | Лишнее `needs_check` на редкой раскладке; склейка не отменяется | +| 25 | 13.10.2026 | R-9 / M-1 (design #25): `_without` — новый родитель = ближайший неудалённый предок по прежнему `levels.parent`, а не пересчёт `parents()` по уровням | Пропуск уровней outline давал неверного родителя | Нет: уровни (`by_row`) считаются как прежде | +| 26 | 13.10.2026 | R-10 / T2.5 (design #26): порядок `_drop_conflicts` после `_check_sums` оставлен; формулировка спеки: «две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары» | Строка, не прошедшая суммы, пары не претендует; сохранение порядка не меняет результаты golden | Две строки ИНН с одной парой, одна из которых не прошла суммы: вторая склеивается | +| 27 | 13.10.2026 | H.1 финального ревью отклонено как недостижимое: пустой кандидат бывает только «под, в дереве» и всегда склеивается с родителем, расхождение с колонкой для него невозможно (комментарий в `_result`). H.2: тест на сочетание «расхождение с колонкой + противоречие подсказке» — одно предупреждение о расхождении | Недостижимый код не добавляется | Нет | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index 70b5592..cc728ac 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -101,12 +101,14 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST (10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН (алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН: -она классифицируется как обычно, в `warnings` — «строка N похожа на ИНН …, контрольная сумма не сходится», документ — -`needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН +она классифицируется как обычно, в `warnings` — «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка +оставлена как есть», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН «ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол., не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не -ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); таким ребёнком MUST быть только строка без детей -(лист), у строки со своими детьми — подлинное наименование (design #14). +ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); такой ребёнок бывает только у наименования, которое +по схеме — COUNTERPARTY (договоры под ним остаются договорами, design #22); при другой позиции по схеме (подразделение и +т. д.) ребёнок — подлинное наименование; если позиции по схеме нет, ребёнком MUST быть только строка без детей (лист, +design #14). **Пара строки ИНН** (связь в дереве важнее соседства): - у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в @@ -114,7 +116,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16); - родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара — родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки - ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт); + ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт). Строка ИНН с детьми под + наименованием при подсказке «над» и родителе-контрагенте по схеме остаётся у родителя (дети — договоры), а + противоречие подсказке подсвечивается (design #23); - иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа (над → следующий, под → предыдущий). @@ -122,7 +126,7 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу -или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары. +или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары (design #26). **Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»), её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` — @@ -136,6 +140,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST («<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт. +**Страховка после классификации:** ИНН склеен со строкой, которая по схеме не стала `COUNTERPARTY`, — в `warnings` «строка N: +ИНН … отнесён к строке M, но она не контрагент — проверьте», документ — `needs_check` (design #24). + **Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N: ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться. -- 2.47.2