# Строки ИНН рядом с контрагентом — 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; мерджить вместе).