From a2744b3e404f6534cbc36b25c9cf6c81356d7059 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 14:20:56 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8=20=D0=98=D0=9D=D0=9D=20=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=20=D1=81=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=BC=20(#35);=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D0=BA=D0=B0=20=E2=80=94=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20=D0=BB=D0=B8=D1=81=D1=82=D1=83,=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BA=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=20=C2=AB=D0=BD?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7.=C2=BB,=20=D0=98=D0=9D=D0=9D=20=D1=81?= =?UTF-8?q?=20=D0=B4=D0=B5=D1=82=D1=8C=D0=BC=D0=B8=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=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; иначе действует правило «есть дети»; - иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению); без сумм — сосед по направлению листа (над → следующий, под → предыдущий).