arm-tabular-parser/docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md

107 KiB
Raw Blame History

Строки ИНН рядом с контрагентом — 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/<repo>. Команды 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 <noreply@anthropic.com>.
  • Валидные ИНН для тестов (контрольная сумма верна): 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:

# --- Контрольная сумма ИНН и ИНН строки (#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

и расширить импорты в начале файла:

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 рядом с прочими константами модуля:
# Контрольная сумма ИНН (алгоритм ФНС): весовые коэффициенты контрольных разрядов; остаток от деления на 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

и в конец файла:

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 и коммит
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 <noreply@anthropic.com>"

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:

"""Строки ИНН рядом с контрагентом (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__):

@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
"""Строки ИНН рядом с контрагентом (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 и коммит
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 <noreply@anthropic.com>"

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:

# --- Не в дереве: соседи того же родителя ---


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 на:
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 и коммит
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 <noreply@anthropic.com>"

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 дописать:

# --- Подсказка в обратном порядке: «Контрагенты.ИНН, Контрагенты» — ИНН над наименованием ---

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:

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) заменить на:

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:
@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: после ветки «Вал./Кол.» и до позиции:
    if item.row in context.unpaired:
        # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
        return _Kind("OTHER", None, code, doc_data_type), inherited

ветку InnAttribute удалить; ИНН контрагента:

    # ИНН: колонка ИНН, затем строка ИНН рядом (`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:

            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 <id упавших golden-тестов> плюс ruff check . && ruff format --check . && mypy src; список deselect и суть каждого diff — в отчёт задачи. Любое иное падение (в том числе golden Новопетровской) — дефект: разобрать и исправить.

  • Step 7: Коммит
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 <noreply@anthropic.com>"

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:

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

    **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES},

(константу INN_LAYOUT_FILES — кортеж восьми имён выше — объявить до EXPECTED; EXPECTED_NAMES может использовать её через *INN_LAYOUT_FILES.)

тест раскладок и правка s18:

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):

    blank: bool = False  # строка без сумм (строка ИНН без сумм, #35)
    twin: "Node | None" = None  # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит

children():

    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()) заменить на:

        _row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank)

после _s19 — раскладки:

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 — ИНН с верной контрольной суммой:

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:

    "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: Сгенерировать файлы и проверить
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)»); добавить восемь строк таблицы:
| `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 и коммиты
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 <noreply@anthropic.com>"
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 <noreply@anthropic.com>"

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

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:
"""Разбор всех файлов 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:

"""Отличия разбора 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: Прогон
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 и манифест
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 (зелёный целиком).

git -C $WT/fixtures add golden
git -C $WT/fixtures commit -m "Golden ОСВ: строки ИНН — восемь раскладок, s18 с верными ИНН (tabular-parser#35)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
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 <noreply@anthropic.com>"   # если менялся
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 второй источник заменить (вместо «строка без сумм прямо над названием…» и абзаца «Отдельно — строка реквизита…») на правило строк ИНН:

затем **строка ИНН** рядом с наименованием (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:
| 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:

## 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: Коммиты
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 <noreply@anthropic.com>"
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 <noreply@anthropic.com>"

После плана (контроллер)

Финальное ревью всей ветки (opus) по SDD; таблица находок с маркерами и статусами; решения без обсуждения — в design.md. PR и merge — не в плане: их делает сессия по finishing-a-development-branch после отчёта контроллера (три PR: tabular-parser, docs, fixtures; мерджить вместе).