From 64f836d5a6fe67201fe3b2471350f3b70e23f6fd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 17:25:43 +0400 Subject: [PATCH] =?UTF-8?q?openspec:=20=D0=B0=D1=80=D1=85=D0=B8=D0=B2=20os?= =?UTF-8?q?v-inn-rows-layouts=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../design.md | 0 .../proposal.md | 0 .../specs/osv-parsing/spec.md | 0 .../2026-10-13-osv-inn-rows-layouts}/tasks.md | 0 openspec/specs/osv-parsing/spec.md | 170 ++++++++++++++++-- 5 files changed, 151 insertions(+), 19 deletions(-) rename openspec/changes/{osv-inn-rows-layouts => archive/2026-10-13-osv-inn-rows-layouts}/design.md (100%) rename openspec/changes/{osv-inn-rows-layouts => archive/2026-10-13-osv-inn-rows-layouts}/proposal.md (100%) rename openspec/changes/{osv-inn-rows-layouts => archive/2026-10-13-osv-inn-rows-layouts}/specs/osv-parsing/spec.md (100%) rename openspec/changes/{osv-inn-rows-layouts => archive/2026-10-13-osv-inn-rows-layouts}/tasks.md (100%) diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/design.md similarity index 100% rename from openspec/changes/osv-inn-rows-layouts/design.md rename to openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/design.md diff --git a/openspec/changes/osv-inn-rows-layouts/proposal.md b/openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/proposal.md similarity index 100% rename from openspec/changes/osv-inn-rows-layouts/proposal.md rename to openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/proposal.md diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/specs/osv-parsing/spec.md similarity index 100% rename from openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md rename to openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/specs/osv-parsing/spec.md diff --git a/openspec/changes/osv-inn-rows-layouts/tasks.md b/openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/tasks.md similarity index 100% rename from openspec/changes/osv-inn-rows-layouts/tasks.md rename to openspec/changes/archive/2026-10-13-osv-inn-rows-layouts/tasks.md diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 162dafb..9161711 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -65,10 +65,14 @@ `data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки → -`has_inn = true`. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по `rules/periods.yaml` (§1.5, -`ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме обязательных по контракту, — -`null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и предупреждение (контракт требует -строку). +`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` @@ -85,6 +89,15 @@ Контрагенты, Договоры», заголовком «Субконто» - **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 определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, @@ -113,27 +126,43 @@ - **THEN** `hierarchy=trivial`, «Итого» — уровень 0 без родителя ### Requirement: Классификация строк -Каждая строка данных 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; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY -`counterparty_name = row_value`, `counterparty_inn` — **только** из колонки ИНН: цифры текстовой ячейки колонки («ИНН -352819549855» → «352819549855», R-15), результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр -(ИНН регионов 01–09 с потерянным ведущим нулём) при извлечении дополняется нулём слева (R-24); ИНН внутри текста строки -(«Полиграф, 3528315710») MUST NOT извлекаться (R-17). Строки «Вал.», «Кол.», трёхбуквенного кода валюты 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)`: хранить ли «Итого», решает ядро). +Классификация 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` — исходный текст @@ -316,3 +345,106 @@ SHALL загружаться из YAML и валидироваться pydantic- #### Scenario: Новый файл без снимка - **WHEN** в `fixtures/osv/` добавлен файл без снимка - **THEN** property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update» + +### Requirement: Строки ИНН рядом с контрагентом +Между построением иерархии и классификацией парсер SHALL найти строки ИНН и сопоставить каждую с её строкой-наименованием +в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве +(сосед того же родителя) × с суммами / без сумм. + +**Строка ИНН** — не счёт, не «Итого», не строка Вал./Кол., подпись которой целиком — ИНН по словарю `inn_only_row` +(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр +дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН +(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН: +она классифицируется как обычно, в `warnings` — «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка +оставлена как есть», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН +«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол., +не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не +ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); такой ребёнок бывает только у наименования, которое +по схеме — COUNTERPARTY (договоры под ним остаются договорами, design #22); при другой позиции по схеме (подразделение и +т. д.) ребёнок — подлинное наименование; если позиции по схеме нет, ребёнком MUST быть только строка без детей (лист, +design #14). + +**Пара строки ИНН** (связь в дереве важнее соседства): +- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в + `group_schema` больше одной группировки, ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы и в шапке нет + подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16); +- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара — + родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки + ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт). Строка ИНН с детьми под + наименованием при подсказке «над» и родителе-контрагенте по схеме остаётся у родителя (дети — договоры), а + противоречие подсказке подсвечивается (design #23); +- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); + без сумм — сосед по направлению листа (над → следующий, под → предыдущий). + +Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление +листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных +блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой +ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу +или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары (design #26). + +**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»), +её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` — +«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся, +в `warnings` — расхождение, документ — `needs_check`. + +**Противоречие подсказке шапки:** если пара найдена, но фактическая раскладка противоречит направлению подсказки +`<группировка>.ИНН` (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него по суммам; подсказка «под», +а строка ИНН — родитель наименования или сосед выше него по суммам), строка ИНН MUST склеиваться как обычно, а в +`warnings` MUST быть отдельное предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка +(«<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся +из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт. + +**Страховка после классификации:** ИНН склеен со строкой, которая по схеме не стала `COUNTERPARTY`, — в `warnings` «строка N: +ИНН … отнесён к строке M, но она не контрагент — проверьте», документ — `needs_check` (design #24). + +**Без пары:** строка ИНН остаётся в `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: Опечатка в ИНН среди верных (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"` + +#### Scenario: Неоднозначное направление +- **WHEN** под счётом блок «наименование, ИНН без сумм, наименование» и других сведений о направлении нет +- **THEN** строка ИНН без пары, `group_type = OTHER`, предупреждение «направление неизвестно», статус `needs_check` + +#### Scenario: Несколько детей у строки ИНН +- **WHEN** у строки ИНН в дереве два ребёнка-наименования +- **THEN** строка ИНН без пары, `group_type = OTHER`, статус `needs_check`