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"`