107 KiB
Строки ИНН рядом с контрагентом — 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С пишет ИНН числом; xls даёт целое
Decimal, 9/11 цифр — потерянный ноль) — распознаётся как ИНН с ведущим нулём; тест — Task 1 (row_inn) и Task 2 (test_number_with_lost_leading_zero_is_restored). - Контрагент, у которого вместо имени ИНН, с одним договором (схема «Контрагенты, Договоры», лишнего уровня нет) — не склеивается, договор не становится контрагентом; тест — Task 2 (
test_no_extra_level_keeps_inn_named_counterparty). - Строка ИНН с неверной контрольной суммой рядом с наименованием без подсказки — не склеивается, предупреждение,
needs_check; одиночный «контрагент-число» без соседей — без предупреждения; тесты — Task 2. - Лист, где направление не выводится (каждый блок «имя, ИНН, имя») — строки ИНН без пары,
needs_check, а не склейка «на удачу»; тест — Task 3 (test_ambiguous_block_leaves_inn_unpaired). - Файл банка #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(только добавить dataclassInnHint, без изменения поведения) - 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
InnHintandrow_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 действительно требует (лишниеnoqaruff помечает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; мерджить вместе).