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

1900 lines
107 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Строки ИНН рядом с контрагентом — 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`:
```python
# --- Контрольная сумма ИНН и ИНН строки (#35) ---
@pytest.mark.parametrize(
("inn", "ok"),
[
("7701234560", True),
("7707654321", True),
("2370012036", True),
("0123456788", True),
("500100732259", True),
("7701234567", False),
("5001234567", False),
("500100732258", False),
("770123456", False),
("77012345601", False),
("77012345a0", False),
],
)
def test_inn_checksum_ok(inn: str, ok: bool) -> None:
assert inn_checksum_ok(inn) is ok
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
@pytest.mark.parametrize(
("cell", "expected"),
[
(num(7701234560), "7701234560"),
(num(123456788), "0123456788"), # 9 цифр числом — потерянный ведущий ноль (R-24, #34)
(num(50010073225), "050010073225"), # 11 цифр числом
(text("7701234560"), "7701234560"),
(text("ИНН 7701234560"), "7701234560"),
(text("ИНН: 7701234560 / 770101001"), "7701234560"),
(text("123456788"), None), # текст из 9 цифр нулём не дополняется
(text("ООО Альфа 7701234560"), None), # не только ИНН
(num(Decimal("7701234560.5")), None),
(EMPTY_CELL, None),
],
)
def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None:
assert row_inn(cell, rules) == expected
```
и расширить импорты в начале файла:
```python
from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money
from src.parser.rules import Rules, load_rules
```
(если фикстура `rules` в файле уже есть — второй не добавлять, использовать существующую).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_numbers.py -q -k "checksum or row_inn"`
Expected: FAIL — `ImportError: cannot import name 'inn_checksum_ok'`.
- [ ] **Step 3: Implement** — в `api/src/parser/numbers.py` рядом с прочими константами модуля:
```python
# Контрольная сумма ИНН (алгоритм ФНС): весовые коэффициенты контрольных разрядов; остаток от деления на 11, затем на 10
_INN10_WEIGHTS = (2, 4, 10, 3, 5, 9, 4, 6, 8)
_INN12_WEIGHTS_FIRST = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
_INN12_WEIGHTS_SECOND = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
_CHECKSUM_MODULUS = 11
_CHECK_DIGIT_MODULUS = 10
_INN_LEGAL_LENGTH = 10
_INN_PERSON_LENGTH = 12
```
и в конец файла:
```python
def _check_digit(digits: str, weights: tuple[int, ...]) -> int:
total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False))
return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS
def inn_checksum_ok(inn: str) -> bool:
"""Контрольная сумма ИНН по алгоритму ФНС: у 10 цифр — последний разряд, у 12 — два последних (#35).
Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки.
"""
if not inn.isdigit():
return False
if len(inn) == _INN_LEGAL_LENGTH:
return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1])
if len(inn) == _INN_PERSON_LENGTH:
return (
_check_digit(inn, _INN12_WEIGHTS_FIRST) == int(inn[-2])
and _check_digit(inn, _INN12_WEIGHTS_SECOND) == int(inn[-1])
)
return False
def row_inn(cell: Cell, rules: Rules) -> str | None:
"""ИНН строки, где написан только он (строка ИНН, `inn_rows`): число — 10/12 цифр или 9/11 с потерянным ведущим
нулём (R-24, #34, как у колонки ИНН); текст — по `inn_only_row` («7701234560», «ИНН 7701234560 / 770101001»)."""
if cell.kind == "number":
return inn_digits(cell)
if cell.kind != "text":
return None
return inn_only(cell.text, rules)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_numbers.py -q`
Expected: PASS.
- [ ] **Step 5: `make check` и коммит**
```bash
cd $WT/tabular-parser && make check
git -C $WT/tabular-parser add api/src/parser/numbers.py api/tests/unit/parser/test_numbers.py
git -C $WT/tabular-parser commit -m "ОСВ: контрольная сумма ИНН и ИНН строки (#35)
Co-Authored-By: Claude Opus 5.5 <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`:
```python
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`, #35).
Тесты модуля `inn_rows` на деревьях, собранных вручную: строка листа — (уровень, подпись, шесть сумм, ИНН колонки).
Подпись — колонка 1, колонка ИНН — 2, суммы — 3…8; уровни — как у стратегии outline, родители — `hierarchy.parents`.
"""
from decimal import Decimal
import pytest
from gridbuilder import sheet_from_rows
from src.contract.result import OsvGroup
from src.parser.classify import ACCOUNT_RE
from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels, parents
from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.rules import Rules, load_rules
Sums = tuple[object, object, object, object, object, object]
NO: Sums = (None, None, None, None, None, None)
A: Sums = (None, None, 100, 40, 60, None)
B: Sums = (10, None, None, 30, None, 20)
INN_A = "7701234560"
INN_B = "7707654321"
INN_12 = "500100732259"
BAD = "7701234567" # контрольная сумма не сходится
LABEL_COL = 1
INN_COL = 2
SCHEMA = [OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="COUNTERPARTY", name="Контрагенты")]
SCHEMA_WITH_CONTRACTS = [*SCHEMA, OsvGroup(type="CONTRACT", name="Договоры")]
BELOW = InnHint("Контрагенты.ИНН", "below")
ABOVE = InnHint("Контрагенты.ИНН", "above")
Row = tuple[int, object, Sums, object]
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def r(level: int, label: object, sums: Sums = NO, inn: object = None) -> Row:
return level, label, sums, inn
def run(
rules: Rules, *rows: Row, hint: InnHint | None = None, schema: list[OsvGroup] | None = None
) -> InnGlue:
sheet = sheet_from_rows([[label, inn, *sums] for _, label, sums, inn in rows])
body: list[BodyRow] = []
for number, (level, _, sums, _) in enumerate(rows, start=1):
text = sheet.text_at(number, LABEL_COL)
account = ACCOUNT_RE.match(text)
body.append(
BodyRow(
row=number,
text=text,
is_account=account is not None,
account_rank=None if account is None else account.group(1).count(".") + 1,
bold=False,
indent=0,
outline=level,
sums=tuple(None if value is None else Decimal(str(value)) for value in sums),
label_col=LABEL_COL,
)
)
by_row = {item.row: level for item, (level, *_) in zip(body, rows, strict=True)}
levels = Levels(by_row=by_row, parent=parents(body, by_row), strategy="outline")
return glue_inn_rows(
body, levels, None, sheet, inn_col=INN_COL, schema=schema or SCHEMA, hint=hint, rules=rules
)
def texts(glue: InnGlue) -> list[str]:
return [item.text for item in glue.rows]
def has_warning(glue: InnGlue, *parts: str) -> bool:
return any(all(part in warning for part in parts) for warning in glue.warnings)
# --- ИНН под наименованием, в дереве ---
def test_inn_below_in_tree_with_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {2: INN_A}
assert (glue.unpaired, glue.ok) == (frozenset(), True)
assert has_warning(glue, "строка 3", INN_A, "отнесён к контрагенту в строке 2")
def test_inn_below_in_tree_without_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_12))
assert glue.inns == {2: INN_12}
assert glue.ok
def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
def test_inn_with_contracts_under_counterparty_belongs_to_parent(rules: Rules) -> None:
# Без подсказки: родитель по схеме — контрагент, значит ИНН его, а дети строки ИНН — договоры
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.inns == {2: INN_A}
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
def test_inn_between_department_and_name_is_above(rules: Rules) -> None:
# Родитель по схеме — подразделение: строка ИНН стоит над наименованием
schema = [
OsvGroup(type="ACCOUNT", name="Счет"),
OsvGroup(type="DEPARTMENT", name="Подразделения"),
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
]
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A, A), r(3, "ООО Альфа", A), schema=schema)
assert glue.inns == {4: INN_A}
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
def test_two_inn_rows_under_one_name_stay_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A), r(2, INN_B))
assert glue.unpaired == frozenset({3, 4})
assert not glue.ok
assert has_warning(glue, "строка 3", "несколько строк ИНН")
def test_inn_row_with_other_sums_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, B))
assert glue.unpaired == frozenset({3})
assert glue.inns == {}
assert has_warning(glue, "строка 3", "суммы не равны")
# --- ИНН над наименованием, в дереве ---
def test_inn_above_in_tree_with_sums_is_glued_and_name_moves_up(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {3: INN_A}
assert (glue.levels.by_row[3], glue.levels.parent[3]) == (1, 1)
def test_inn_above_in_tree_without_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A))
assert glue.inns == {3: INN_A}
assert glue.ok
def test_inn_above_with_two_children_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A), r(2, "ООО Бета", B))
assert glue.unpaired == frozenset({2})
assert texts(glue) == ["60", INN_A, "ООО Альфа", "ООО Бета"]
assert has_warning(glue, "строка 2", "несколько")
def test_no_extra_level_keeps_inn_named_counterparty(rules: Rules) -> None:
# Схема «Контрагенты, Договоры», глубже схемы строк нет: «7701234560» — контрагент с одним договором, не строка ИНН
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "Договор № 1", A), schema=SCHEMA_WITH_CONTRACTS)
assert texts(glue) == ["60", INN_A, "Договор № 1"]
assert (glue.inns, glue.unpaired, glue.warnings, glue.ok) == ({}, frozenset(), [], True)
def test_extra_level_allows_inn_above_with_contract_schema(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, INN_A, A),
r(2, "ООО Альфа", A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.inns == {3: INN_A}
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 3)
# --- Распознавание и контрольная сумма ---
@pytest.mark.parametrize(
("label", "inn"),
[
(Decimal(7701234560), INN_A),
(123456788, "0123456788"), # 9 цифр числом (#34)
("ИНН 7701234560", INN_A),
("ИНН: 7701234560 / 770101001", INN_A),
(INN_12, INN_12),
],
)
def test_inn_row_forms(rules: Rules, label: object, inn: str) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, label, A))
assert glue.inns == {2: inn}
def test_number_with_lost_leading_zero_is_restored(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), hint=BELOW)
assert glue.inns == {2: "0105012345"}
def test_bad_checksum_without_hint_is_lookalike(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A))
assert texts(glue) == ["60", "ООО Альфа", BAD]
assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), False)
assert has_warning(glue, "строка 3", "похожа на ИНН", "контрольная сумма не сходится")
def test_lone_number_named_row_without_names_around_is_silent(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, BAD, A))
assert (glue.warnings, glue.ok) == ([], True)
def test_hint_skips_checksum(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A), hint=BELOW)
assert glue.inns == {2: BAD}
assert has_warning(glue, f"ИНН {BAD} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
def test_hint_below_empty_label_is_glued_without_inn(rules: Rules) -> None:
# R-1 #32: физлицо — 1С выводит строку реквизита с пустой ячейкой
glue = run(rules, r(0, "60", A), r(1, "Иванов Иван", A), r(2, None, A), hint=BELOW)
assert texts(glue) == ["60", "Иванов Иван"]
assert (glue.inns, glue.ok) == ({}, True)
assert has_warning(glue, "строка 3: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке 2 без ИНН")
def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), hint=BELOW)
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "не распознан")
def test_column_inn_wins_over_inn_row(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {}
assert not glue.ok
assert has_warning(glue, "строка 3", "расходится", INN_B)
def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A))
assert (glue.inns, glue.ok) == ({2: INN_A}, True)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py -q`
Expected: FAIL — `ImportError: cannot import name 'InnHint'` / `No module named 'src.parser.inn_rows'`.
- [ ] **Step 3: Add `InnHint` and `row_data_type`**
В `api/src/parser/header.py` рядом с `InnAttribute` (и в `__all__`):
```python
@dataclass(frozen=True)
class InnHint:
"""Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку
элементов — после группировки-владельца ИНН стоит под наименованием (`below`), перед ней — над (`above`).
С подсказкой контрольная сумма ИНН строки не проверяется (`inn_rows`)."""
name: str
direction: Literal["above", "below"]
```
(`Literal` — импортировать из `typing`, если ещё нет.)
В `api/src/parser/classify.py`: `_row_data_type` → `row_data_type` (определение и вызов в `_classify_row`), добавить `"row_data_type"` в `__all__`.
- [ ] **Step 4: Implement `api/src/parser/inn_rows.py`**
```python
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`;
`05-domain-rules.md` §1.3; OQ-130, OQ-162, #35).
Один проход между каскадом иерархии и классификацией. Строка, где написан только ИНН, сопоставляется с наименованием
в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве
(сосед того же родителя) × с суммами / без сумм. Связь в дереве важнее соседства. Склеенная строка уходит из строк и
дерева (её дети — к наименованию), ИНН — наименованию; строка без пары остаётся (классификатор ставит ей OTHER), а
документ уходит на проверку. Классификатор получает чистое дерево.
Без подсказки шапки (`InnHint`) строка — строка ИНН только при верной контрольной сумме; строка из 10/12 цифр с
неверной суммой рядом с наименованием — «похожа на ИНН»: остаётся как есть, предупреждение, `needs_check`.
"""
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal
from src.contract.result import OsvGroup
from src.parser.classify import row_data_type
from src.parser.grid import Cell, Sheet
from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels, parents
from src.parser.numbers import column_inn, inn_checksum_ok, row_inn
from src.parser.rules import Rules
__all__ = ["InnGlue", "glue_inn_rows"]
Kind = Literal["total", "account", "indicator", "candidate", "name"]
_ZERO = Decimal(0)
_UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено"
@dataclass(frozen=True)
class InnGlue:
"""Результат склейки: строки и дерево без склеенных строк ИНН, ИНН по строкам наименований, строки ИНН без пары
(классификатор ставит им OTHER), предупреждения и признак «всё склеено, похожих на ИНН строк и расхождений нет»."""
rows: list[BodyRow]
levels: Levels
inns: dict[int, str]
unpaired: frozenset[int]
warnings: list[str]
ok: bool
@dataclass
class _Tree:
"""Строки листа, их дерево и вид: счёт, «Итого», Вал./Кол., строка ИНН или наименование."""
rows: Sequence[BodyRow]
by_row: dict[int, BodyRow]
parent: dict[int, int | None]
children: dict[int | None, list[int]]
kind: dict[int, Kind]
inn: dict[int, str | None] = field(default_factory=dict) # ИНН строки ИНН; None — пустой или не распознан
lookalike: dict[int, str] = field(default_factory=dict) # 10/12 цифр с неверной контрольной суммой
def kids(self, row: int) -> list[int]:
"""Дети строки, кроме Вал./Кол. (они не уровень аналитики)."""
return [kid for kid in self.children.get(row, []) if self.kind[kid] != "indicator"]
def candidates(self, done: "_Pairs") -> list[int]:
return [item.row for item in self.rows if self.kind[item.row] == "candidate" and not done.decided(item.row)]
@dataclass
class _Pairs:
partner: dict[int, int] = field(default_factory=dict) # строка ИНН -> строка наименования
reason: dict[int, str] = field(default_factory=dict) # строка ИНН без пары -> причина
flat: list[int] = field(default_factory=list) # строки ИНН не в дереве (соседи), по порядку строк
def decided(self, row: int) -> bool:
return row in self.partner or row in self.reason
def _label_cell(sheet: Sheet, item: BodyRow) -> Cell:
if item.label_col is None:
return Cell(item.text or None, "text" if item.text else "empty")
return sheet.cell(*sheet.merged_origin(item.row, item.label_col))
def _has_sums(item: BodyRow) -> bool:
return any(value is not None for value in item.sums)
def _same_sums(left: BodyRow, right: BodyRow) -> bool:
"""Шесть сумм равны; пустая ячейка = 0 (в ОСВ 1С это одно и то же)."""
return [value or _ZERO for value in left.sums] == [value or _ZERO for value in right.sums]
def _build(rows: Sequence[BodyRow], levels: Levels, total_row: int | None, sheet: Sheet, hint: InnHint | None, rules: Rules) -> _Tree: # noqa: PLR0913 - всё нужное для вида строки приходит явно
currency = frozenset(rules.data_types.currency_codes)
children: dict[int | None, list[int]] = {}
for item in rows:
children.setdefault(levels.parent[item.row], []).append(item.row)
tree = _Tree(rows, {item.row: item for item in rows}, dict(levels.parent), children, {})
for item in rows:
tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree)
if hint is not None and hint.direction == "below":
# «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения
for item in rows:
parent = tree.parent[item.row]
if tree.kind[item.row] == "name" and not item.text and parent is not None and tree.kind[parent] == "name":
tree.kind[item.row] = "candidate"
tree.inn[item.row] = None
return tree
def _kind( # noqa: PLR0913 - вид строки зависит от всех этих данных
item: BodyRow,
total_row: int | None,
sheet: Sheet,
hint: InnHint | None,
rules: Rules,
currency: frozenset[str],
tree: _Tree,
) -> Kind:
if total_row is not None and item.row >= total_row:
return "total"
if item.is_account:
return "account"
if row_data_type(item.text, rules, currency) is not None:
return "indicator"
found = row_inn(_label_cell(sheet, item), rules)
if found is None:
return "name"
if hint is None and not inn_checksum_ok(found):
tree.lookalike[item.row] = found
return "name"
tree.inn[item.row] = found
return "candidate"
def _depths(tree: _Tree) -> dict[int, int]:
"""Позиция строки в схеме группировок, как у классификатора: счёт и «Итого» — 0, Вал./Кол. — позиция родителя,
прочие — позиция родителя + 1 (строки ИНН считаются уровнем: склейки ещё не было)."""
depth: dict[int, int] = {}
for item in tree.rows:
parent = tree.parent[item.row]
base = 0 if parent is None else depth.get(parent, 0)
kind = tree.kind[item.row]
if kind in {"account", "total"}:
depth[item.row] = 0
elif kind == "indicator":
depth[item.row] = base
else:
depth[item.row] = base + 1
return depth
def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool:
"""Защита «над, в дереве»: схема задаёт группировки и ни одна строка листа не стоит глубже схемы — лишнего уровня
нет, строка ИНН с детьми — наименование по схеме (контрагент, названный ИНН, с одним договором)."""
deepest = len(schema) - 1
return deepest >= 1 and all(value <= deepest for value in depth.values())
def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool:
"""Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка
шапки решает; без неё — «под», если родитель по схеме — контрагент (design, решение без обсуждения №10)."""
if hint is not None:
return hint.direction == "below"
position = depth[parent]
return position < len(schema) and schema[position].type == "COUNTERPARTY"
def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None:
"""С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка
реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9)."""
if hint is None or hint.direction != "below":
return
for item in tree.rows:
if tree.kind[item.row] != "name":
continue
kids = tree.kids(item.row)
if len(kids) != 1:
continue
kid = tree.by_row[kids[0]]
if tree.kind[kid.row] == "name" and kid.text and _same_sums(kid, item):
tree.kind[kid.row] = "candidate"
tree.inn[kid.row] = None
pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)"
def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None:
"""Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми —
над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`."""
depth = _depths(tree)
no_extra_level = _no_extra_level(depth, schema)
for row in tree.candidates(pairs):
kids = tree.kids(row)
parent = tree.parent[row]
below = parent is not None and tree.kind[parent] == "name"
if below and kids and parent is not None:
below = _owned_by_parent(parent, depth, schema, hint)
if below and parent is not None:
rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"]
if len(rivals) > 1:
pairs.reason[row] = f"у контрагента в строке {parent} несколько строк ИНН"
else:
pairs.partner[row] = parent
elif kids:
if no_extra_level:
tree.kind[row] = "name"
elif len(kids) > 1:
pairs.reason[row] = f"под ней несколько строк ({len(kids)}), а не одно наименование"
elif tree.kind[kids[0]] != "name":
pairs.reason[row] = "под ней не наименование"
else:
pairs.partner[row] = kids[0]
else:
pairs.flat.append(row)
def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None: # noqa: ARG001 - hint нужен в Task 3
"""Строки ИНН не в дереве (соседи наименования) — Task 3; пока без пары."""
for row in pairs.flat:
pairs.reason[row] = _UNKNOWN_DIRECTION
def _check_sums(tree: _Tree, pairs: _Pairs) -> None:
for row, partner in list(pairs.partner.items()):
if _has_sums(tree.by_row[row]) and not _same_sums(tree.by_row[row], tree.by_row[partner]):
del pairs.partner[row]
pairs.reason[row] = f"суммы не равны суммам строки {partner}"
def _drop_conflicts(pairs: _Pairs) -> None:
claimed = Counter(pairs.partner.values())
for row, partner in list(pairs.partner.items()):
if claimed[partner] > 1:
del pairs.partner[row]
pairs.reason[row] = f"на строку {partner} претендуют несколько строк ИНН"
def _near_name(tree: _Tree, row: int) -> bool:
"""Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование."""
parent = tree.parent[row]
siblings = tree.children.get(parent, [])
index = siblings.index(row)
near = [*siblings[max(index - 1, 0) : index], *siblings[index + 1 : index + 2], *tree.children.get(row, [])]
if parent is not None:
near.append(parent)
return any(tree.kind[other] == "name" for other in near)
def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]:
"""Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень, родители пересчитываются."""
if not removed:
return list(rows), levels
lifted: dict[int, int] = {}
by_row: dict[int, int] = {}
kept: list[BodyRow] = []
for item in rows:
parent = levels.parent[item.row]
lift = 0 if parent is None else lifted[parent] + (1 if parent in removed else 0)
lifted[item.row] = lift
if item.row in removed:
continue
by_row[item.row] = levels.by_row[item.row] - lift
kept.append(item)
return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy)
def _result( # noqa: PLR0913 - сборка результата из всех частей прохода
rows: Sequence[BodyRow],
levels: Levels,
tree: _Tree,
pairs: _Pairs,
sheet: Sheet,
inn_col: int | None,
hint: InnHint | None,
rules: Rules,
) -> InnGlue:
label = "" if hint is None else f" («{hint.name}»)"
warnings: list[str] = []
inns: dict[int, str] = {}
ok = not pairs.reason
for item in rows:
row = item.row
inn = tree.inn.get(row)
if row in pairs.partner:
partner = pairs.partner[row]
if inn is None:
warnings.append(
f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН"
)
continue
column = None if inn_col is None else column_inn(sheet.cell(partner, inn_col), rules)
if column is not None and column != inn:
ok = False
warnings.append(
f"строка {row}: ИНН {inn}{label} расходится с ИНН {column} колонки ИНН контрагента в строке "
f"{partner} — оставлен ИНН колонки"
)
continue
inns[partner] = inn
warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}")
elif row in pairs.reason:
shown = "" if inn is None else f" {inn}"
warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}")
elif row in tree.lookalike and tree.kind[row] == "name" and _near_name(tree, row):
ok = False
warnings.append(
f"строка {row}: похожа на ИНН {tree.lookalike[row]}, но контрольная сумма не сходится — строка "
"оставлена как есть"
)
kept, kept_levels = _without(rows, levels, set(pairs.partner))
return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok)
def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно
rows: Sequence[BodyRow],
levels: Levels,
total_row: int | None,
sheet: Sheet,
*,
inn_col: int | None,
schema: Sequence[OsvGroup],
hint: InnHint | None,
rules: Rules,
) -> InnGlue:
"""Строки ИНН рядом с наименованием → ИНН наименованию (spec «Строки ИНН рядом с контрагентом»).
`levels` — дерево каскада иерархии со строками ИНН; `schema` — группировки шапки (`group_schema`) для защиты «над,
в дереве»; `hint` — подсказка `<группировка>.ИНН` (без неё ИНН строки должен пройти контрольную сумму).
"""
tree = _build(rows, levels, total_row, sheet, hint, rules)
pairs = _Pairs()
_unrecognised_attributes(tree, hint, pairs)
_pair_in_tree(tree, schema, hint, pairs)
_pair_flat(tree, hint, pairs)
_check_sums(tree, pairs)
_drop_conflicts(pairs)
return _result(rows, levels, tree, pairs, sheet, inn_col, hint, rules)
```
Примечания для исполнителя:
- `_build` создаёт `_Tree` с пустым `kind` и заполняет его в цикле — `_kind` пишет в `tree.inn`/`tree.lookalike`.
- Строка `"60"` в тестах — счёт (`ACCOUNT_RE`), её `kind` — `account`; «Итого» в тестах нет (`total_row=None`).
- Если ruff требует переформатировать длинную сигнатуру `_build` — `ruff format`; правила `noqa` оставить только те, что ruff действительно требует (лишние `noqa` ruff помечает `RUF100`).
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py tests/unit/parser/test_classify.py -q`
Expected: PASS (`test_classify` не меняется — модуль ещё не подключён).
- [ ] **Step 6: `make check` и коммит**
```bash
cd $WT/tabular-parser && make check
git -C $WT/tabular-parser add api/src/parser/inn_rows.py api/src/parser/header.py api/src/parser/classify.py api/tests/unit/parser/test_inn_rows.py
git -C $WT/tabular-parser commit -m "ОСВ: модуль строк ИНН — склейка в дереве и контрольная сумма (#35)
Co-Authored-By: Claude Opus 5.5 <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`:
```python
# --- Не в дереве: соседи того же родителя ---
def test_inn_above_sibling_without_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B))
assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"]
assert glue.inns == {3: INN_A, 5: INN_B}
assert glue.ok
def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None:
# Дефект main: ИНН первого контрагента доставался второму
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B))
assert glue.inns == {2: INN_A, 4: INN_B}
assert glue.ok
def test_inn_above_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B))
assert glue.inns == {3: INN_A, 5: INN_B}
def test_inn_below_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B))
assert glue.inns == {2: INN_A, 4: INN_B}
def test_sums_match_no_neighbour(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A))
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "суммы не совпали")
def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None:
rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A))
assert run(rules, *rows).unpaired == frozenset({3})
assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A}
def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B))
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "направление")
def test_direction_from_other_block(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, "ООО Альфа", A),
r(2, INN_A),
r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт
r(1, "60.02", B),
r(2, "ООО Гамма", B),
r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием
)
assert glue.inns == {3: INN_A, 7: INN_B}
def test_direction_from_sum_pair(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B), r(1, "Иванов Иван", B))
assert glue.inns == {2: INN_A, 4: INN_B}
def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, INN_A),
r(2, "ООО Альфа", A), # начинается строкой ИНН — над
r(1, "60.02", B),
r(2, "ООО Бета", B),
r(2, INN_B), # кончается строкой ИНН — под
)
assert glue.unpaired == frozenset({3, 7})
def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "строка 2", "начинается и заканчивается")
def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE)
assert glue.inns == {4: BAD}
def test_two_inn_rows_claim_one_name(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "претендуют")
def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE)
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "нет наименования")
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py -q`
Expected: FAIL — новые тесты соседей (строки ИНН без пары с причиной «направление … не определено»).
- [ ] **Step 3: Implement** — в `api/src/parser/inn_rows.py` заменить заглушку `_pair_flat` на:
```python
Direction = Literal["above", "below"]
def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]:
"""Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование)."""
siblings = tree.children.get(tree.parent[row], [])
index = siblings.index(row)
before = siblings[index - 1] if index > 0 else None
after = siblings[index + 1] if index + 1 < len(siblings) else None
return (
before if before is not None and tree.kind[before] == "name" else None,
after if after is not None and tree.kind[after] == "name" else None,
)
def _block_direction(tree: _Tree, pending: list[int], pairs: _Pairs) -> set[Direction]:
"""Сведения о направлении из блоков: дети одного родителя — несопоставленные строки ИНН и наименования.
Начинается строкой ИНН — над (иначе первой строке ИНН не досталось бы владельца), кончается — под; начинается и
кончается — блок сломан, его строки ИНН без пары; ни то ни другое — сведений нет.
"""
waiting = set(pending)
claimed = set(pairs.partner.values())
found: set[Direction] = set()
for parent in dict.fromkeys(tree.parent[row] for row in pending):
block = [
row
for row in tree.children.get(parent, [])
if row in waiting or (tree.kind[row] == "name" and row not in claimed)
]
starts, ends = block[0] in waiting, block[-1] in waiting
if starts and ends:
for row in block:
if row in waiting:
pairs.reason[row] = "блок строк начинается и заканчивается строкой ИНН — направление не определить"
elif starts:
found.add("above")
elif ends:
found.add("below")
return found
def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None:
"""Строки ИНН не в дереве: с суммами — сосед с теми же суммами; без сумм (или совпали оба соседа) — сосед по
направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и блоков."""
pending: list[int] = []
evidence: set[Direction] = set()
for row in pairs.flat:
item = tree.by_row[row]
if not _has_sums(item):
pending.append(row)
continue
before, after = _neighbours(tree, row)
matches: list[tuple[int, Direction]] = [
(other, side)
for other, side in ((before, "below"), (after, "above"))
if other is not None and _same_sums(tree.by_row[other], item)
]
if len(matches) == 1:
other, side = matches[0]
pairs.partner[row] = other
evidence.add(side)
elif matches:
pending.append(row)
else:
pairs.reason[row] = "суммы не совпали ни с одним соседним наименованием"
if not pending:
return
if hint is not None:
direction: Direction | None = hint.direction
else:
evidence |= _block_direction(tree, pending, pairs)
direction = next(iter(evidence)) if len(evidence) == 1 else None
for row in pending:
if row in pairs.reason:
continue
if direction is None:
pairs.reason[row] = _UNKNOWN_DIRECTION
continue
before, after = _neighbours(tree, row)
other = after if direction == "above" else before
if other is None:
where = "под" if direction == "above" else "над"
pairs.reason[row] = f"{where} строкой ИНН нет наименования"
else:
pairs.partner[row] = other
```
Удалить `# noqa: ARG001` у прежней заглушки (её больше нет).
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_inn_rows.py -q`
Expected: PASS (все тесты Task 2 и Task 3).
- [ ] **Step 5: `make check` и коммит**
```bash
cd $WT/tabular-parser && make check
git -C $WT/tabular-parser add api/src/parser/inn_rows.py api/tests/unit/parser/test_inn_rows.py
git -C $WT/tabular-parser commit -m "ОСВ: строки ИНН — соседи по суммам и направление листа (#35)
Co-Authored-By: Claude Opus 5.5 <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` дописать:
```python
# --- Подсказка в обратном порядке: «Контрагенты.ИНН, Контрагенты» — ИНН над наименованием ---
DETAIL_ABOVE = "Детализация по субсчетам, субконто: Контрагенты.ИНН, Контрагенты"
def test_inn_attribute_before_owner_gives_hint_above(rules: Rules) -> None:
sheet = _sheet(r(0, "60.01", A), r(1, 2370012036, A), r(2, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE)
document, warnings, ok = _parse(sheet, rules)
assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [
("ACCOUNT", "Субконто", None),
("COUNTERPARTY", "Контрагенты", True),
]
assert ok
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
counterparty = document.rows[1]
assert (counterparty.counterparty_inn, counterparty.level, counterparty.parent_row_number) == ("2370012036", 1, 9)
assert any("«Контрагенты.ИНН»" in warning for warning in warnings), warnings
def test_hint_above_glues_sibling_without_checksum(rules: Rules) -> None:
# Подсказка снимает проверку контрольной суммы: «7701234567» — опечатка, но шапка говорит, что это ИНН
sheet = _sheet(r(0, "60.01", A), r(1, "7701234567", (None,) * 6), r(1, "ООО Альфа", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE)
document, _, ok = _parse(sheet, rules)
assert ok
assert [(row.row_value, row.counterparty_inn) for row in document.rows if row.group_type == "COUNTERPARTY"] == [
("ООО Альфа", "7701234567")
]
def test_variant1_inn_attribute_row_above_owner(rules: Rules) -> None:
head: list[list[object]] = [
["ООО Зерновая"],
["Оборотно-сальдовая ведомость по счету 60 за 9 месяцев 2025 г."],
[],
["Выводимые данные: БУ (данные бухгалтерского учета)"],
[],
["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)],
["Контрагенты.ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Контрагенты"],
]
sheet = sheet_from_rows([*head, r(0, "60.01", A), r(1, 2370012036, A), r(2, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A]])
document, _, ok = _parse(sheet, rules)
assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [
("ACCOUNT", "Счет", None),
("COUNTERPARTY", "Контрагенты", True),
]
assert ok
assert document.rows[1].counterparty_inn == "2370012036"
```
(Если `_sheet` не принимает строку без сумм через `r(...)` — строка `r(1, "7701234567", (None,) * 6)` даёт пустые суммы, это ожидаемо. Если вариант 1 с меткой группировки `Контрагенты.ИНН` в строке «Дебет/Кредит» не распознаётся детектором заголовка — поменять местами строки 7 и 8 `head` так, чтобы «Контрагенты.ИНН» шла второй строкой блока (как в `_variant1_sheet`), а «Контрагенты» — третьей; цель теста — порядок «реквизит перед владельцем».)
В `api/tests/unit/parser/test_classify.py` заменить помощник `parse` и тесты OQ-130:
```python
def parse(
sheet: Sheet, rules: Rules, doc_data_type: DataType | None = "BU", deadline: Deadline | None = None
) -> Parsed:
deadline = deadline or Deadline(TIMEOUT_SECONDS)
header = find_table_header(sheet, rules)
schema = build_group_levels(sheet, header, rules)
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
levels = resolve_levels(body, total_row, deadline)
glue = glue_inn_rows(
body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules
)
rows = classify_rows(
glue.rows, glue.levels, schema.groups, header, sheet, doc_data_type, total_row, rules,
inns=glue.inns, unpaired=glue.unpaired,
)
return Parsed(rows, [*warnings, *glue.warnings], glue.levels, total_row)
```
(импорты: `from src.parser.header import build_group_levels`, `from src.parser.inn_rows import glue_inn_rows`; `build_group_schema` убрать из импорта, если больше не нужен).
Тесты OQ-130 (`test_inn_only_row_above_is_glued_to_next_counterparty`, `test_inn_row_above_with_label_and_kpp_forms`, `test_inn_row_above_on_other_level_is_not_glued`, `test_inn_row_above_before_row_without_sums_is_not_glued`) заменить на:
```python
def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна)
r(1, "ООО Поставщик", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик"]
supplier = parsed.rows[1]
assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == (
"ООО Поставщик",
"7701234560",
ACCOUNT_ROW,
)
assert any("7701234560" in warning for warning in parsed.warnings), parsed.warnings
def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "ИНН 7701234560 / 770101001"),
r(1, "ООО Поставщик", S100),
)
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560"
def test_inn_row_above_in_tree_without_extra_level_is_not_glued(rules: Rules) -> None:
# Схема «Контрагенты, Договоры», глубже схемы строк нет — «7701234560» контрагент, «ООО Поставщик» под ним (защита)
sheet = osv(
r(0, "60", S100),
r(1, "7701234560", S100),
r(2, "ООО Поставщик", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"]
assert parsed.one("ООО Поставщик").counterparty_inn is None
def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234560"),
r(1, "ООО Поставщик"),
r(1, "ООО Другой", S100),
)
parsed = parse(sheet, rules)
assert "7701234560" not in [row.body.text for row in parsed.rows]
assert parsed.one("ООО Поставщик").counterparty_inn == "7701234560"
def test_inn_row_with_bad_checksum_is_left_as_is(rules: Rules) -> None:
sheet = osv(r(0, "60", S100), r(1, "7701234567"), r(1, "ООО Поставщик", S100))
parsed = parse(sheet, rules)
assert "7701234567" in [row.body.text for row in parsed.rows]
assert parsed.one("ООО Поставщик").counterparty_inn is None
assert any("контрольная сумма не сходится" in warning for warning in parsed.warnings), parsed.warnings
def test_unpaired_inn_row_is_other(rules: Rules) -> None:
sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100), r(1, "7701234560"), r(1, "ООО Другой", S100))
parsed = parse(sheet, rules)
inn_row = parsed.one("7701234560")
assert (inn_row.group_type, inn_row.counterparty_name, inn_row.counterparty_inn) == ("OTHER", None, None)
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit/parser/test_classify.py tests/unit/parser/test_inn_subkonto.py -q`
Expected: FAIL — `GroupSchema` без `inn_hint`, `classify_rows` без `inns`.
- [ ] **Step 3: Header** — в `api/src/parser/header.py`:
```python
@dataclass(frozen=True)
class GroupSchema:
"""Группировки контракта (`groups`), вариант шапки и подсказка для строк ИНН (`<группировка>.ИНН`, OQ-162, #35)."""
groups: list[OsvGroup]
variant: HeaderVariant
inn_hint: InnHint | None = None
def _attribute_owner(item: str, rules: Rules) -> str | None:
"""Имя группировки-владельца, если `item` — реквизит ИНН («Контрагенты.ИНН» → «контрагенты»); иначе `None`."""
owner, separator, attribute = item.rpartition(_ATTRIBUTE_SEPARATOR)
if not separator or _find(rules.headers.service_columns["inn"], attribute) is None:
return None
return owner.strip().casefold()
def _inn_hint(items: Sequence[str], rules: Rules) -> InnHint | None:
"""Подсказка строк ИНН по перечню группировок шапки (без счёта): первый реквизит `<группировка>.ИНН`, чей владелец —
группировка контрагентов того же перечня. Стоит после владельца — ИНН под наименованием, перед — над."""
for index, item in enumerate(items):
owner = _attribute_owner(item, rules)
if owner is None:
continue
for other_index, other in enumerate(items):
if other.strip().casefold() == owner and rules.groups.classify(other) == "COUNTERPARTY":
return InnHint(item, "below" if index > other_index else "above")
return None
def _owns_hint(item: str, hint: InnHint | None, rules: Rules) -> bool:
return hint is not None and _attribute_owner(hint.name, rules) == item.strip().casefold()
def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema:
labels = [sheet.text_at(row, header.text_col) for row in header.group_label_rows]
hint = _inn_hint(labels[1:], rules)
groups: list[OsvGroup] = []
for index, (row, label) in enumerate(zip(header.group_label_rows, labels, strict=True)):
if index > 0 and hint is not None and label == hint.name:
continue
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
has_inn = _row_has_inn(sheet, row, rules) or (group_type == "COUNTERPARTY" and _owns_hint(label, hint, rules))
groups.append(_group(group_type, label, has_inn=has_inn))
if not groups:
groups = [_group("ACCOUNT")]
return GroupSchema(groups, 1, hint)
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
hint = _inn_hint(items, rules)
inn_pending = header.inn_col is not None
for item in items:
if hint is not None and item == hint.name:
continue
group_type = rules.groups.classify(item)
has_inn = inn_pending and group_type == "COUNTERPARTY"
inn_pending = inn_pending and not has_inn
has_inn = has_inn or (group_type == "COUNTERPARTY" and _owns_hint(item, hint, rules))
groups.append(_group(group_type, item, has_inn=has_inn))
return GroupSchema(groups, 2, hint)
```
Удалить `InnAttribute`, `_inn_owner`, `_attach_inn_attribute`, `_positions` (и из `__all__`); поправить докстринг `build_group_levels` («группировки, вариант шапки и подсказка строк ИНН»). Если `_variant1_groups` до правки делал что-то ещё (например, `if not groups` ветку) — сохранить. Проверить: `test_inn_attribute_of_unknown_group_stays_a_group` («Договоры.ИНН» — владелец не контрагенты) остаётся зелёным: `_inn_hint` даёт `None`, элемент становится группировкой.
- [ ] **Step 4: Classify, hierarchy, osv**
`api/src/parser/hierarchy.py`: удалить строку `inn_above: str | None = None …` у `BodyRow`.
`api/src/parser/classify.py`:
- докстринг модуля: шаги 1 и 2 без п. 3; добавить «ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`)»;
- удалить `_glue_inn_rows`, `_has_sums` (если больше не используется), `glue_inn_attributes`, `_not_glued_reason`, раздел «Реквизит «Контрагенты.ИНН» (OQ-162)», поля `is_inn_attribute`/`attribute_inn` у `ClassifiedRow` и `_Kind`, импорт `InnAttribute`, `inn_only` из импорта `numbers` (если не используется), `"glue_inn_attributes"` из `__all__`;
- `collect_body_rows` возвращает `body, total_row, warnings` (без `_glue_inn_rows`);
- `_Context`: `schema: Sequence[OsvGroup]`, новые поля `inns: Mapping[int, str]`, `unpaired: AbstractSet[int]`;
- `_schema_group(schema: Sequence[OsvGroup], position: int)` — без ветки `InnAttribute`;
- `_classify_row`: после ветки «Вал./Кол.» и до позиции:
```python
if item.row in context.unpaired:
# Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
return _Kind("OTHER", None, code, doc_data_type), inherited
```
ветку `InnAttribute` удалить; ИНН контрагента:
```python
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules)
inn = column or context.inns.get(item.row) or in_name
```
- `classify_rows(..., rules: Rules, *, inns: Mapping[int, str] | None = None, unpaired: AbstractSet[int] = frozenset())` — передать в `_Context(schema, header, sheet, doc_data_type, rules, currency_codes, inns or {}, unpaired)`; из `ClassifiedRow(...)` убрать `is_inn_attribute`/`attribute_inn`; докстринг: пункт про реквизит заменить на «строка ИНН без пары (`unpaired`) — OTHER»; `schema: Sequence[OsvGroup]`.
`api/src/parser/osv.py`, `parse_sheet`:
```python
step = "иерархия строк"
levels = resolve_levels(body, total_row, deadline)
step = "строки ИНН"
glue = glue_inn_rows(
body,
levels,
total_row,
sheet,
inn_col=header.inn_col,
schema=schema.groups,
hint=schema.inn_hint,
rules=rules,
)
step = "классификация строк"
classified = classify_rows(
glue.rows,
glue.levels,
schema.groups,
header,
sheet,
doc.data_type,
total_row,
rules,
inns=glue.inns,
unpaired=glue.unpaired,
)
```
`warnings = [..., *body_warnings, *glue.warnings]` (на месте `*inn_warnings`), возврат `_is_ok(checks) and glue.ok`; комментарий над возвратом: «Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — `needs_check`». Импорт `glue_inn_attributes` убрать, добавить `from src.parser.inn_rows import glue_inn_rows`. Предупреждение иерархии `hierarchy={levels.strategy}` — без изменений.
`api/tests/fixtures/real_osv.py`: `schema = build_group_levels(...)`; после `resolve_levels` — `glue = glue_inn_rows(body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules)`; `classify_rows(glue.rows, glue.levels, schema.groups, …, inns=glue.inns, unpaired=glue.unpaired)`; в `RealParse` передать `schema.groups`, `glue.rows` как `body`, `glue.levels` как `levels`, `[*warnings, *glue.warnings]`.
- [ ] **Step 5: Run unit tests**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/unit -q`
Expected: PASS. Падения в `test_inn_subkonto.py` по тексту предупреждения разбирать по существу: тексты склейки при подсказке совпадают с #32 (Global Constraints); текст «не отнесён к контрагенту» стал «без пары» — правка ассерта допустима, если проверяемый смысл тот же (строка, причина).
- [ ] **Step 6: Run fixtures tests**
Run: `cd $WT/tabular-parser && make check`
Expected: зелёное всё, кроме ожидаемого до Task 5–6: golden-снимков, которые меняются из-за нового правила (`s18` и файлы со строкой, «похожей на ИНН»), и `test_s18_inn_row_above_is_glued_and_not_other` (в `s18` ИНН с неверной контрольной суммой). Golden в этой задаче **не обновлять**. `test_s18_…` пометить `@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True)`. Для коммита достаточно зелёного прогона без golden-тестов: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest -m "not integration" -q --deselect <id упавших golden-тестов>` плюс `ruff check . && ruff format --check . && mypy src`; список deselect и суть каждого diff — в отчёт задачи. Любое иное падение (в том числе golden Новопетровской) — дефект: разобрать и исправить.
- [ ] **Step 7: Коммит**
```bash
git -C $WT/tabular-parser add -A api/src/parser api/tests
git -C $WT/tabular-parser commit -m "ОСВ: строки ИНН — подключение прохода, подсказка шапки в обоих порядках, старые склейки удалены (#35)
Co-Authored-By: Claude Opus 5.5 <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`:
```python
"s20_inn_above_sibling_blank.xlsx",
"s21_inn_above_sibling_sums.xlsx",
"s22_inn_above_parent_blank.xlsx",
"s23_inn_above_parent_sums.xlsx",
"s24_inn_below_child_blank.xlsx",
"s25_inn_below_child_sums.xlsx",
"s26_inn_below_sibling_sums.xlsx",
"s27_inn_below_sibling_blank.xlsx",
```
в `EXPECTED`:
```python
**{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES},
```
(константу `INN_LAYOUT_FILES` — кортеж восьми имён выше — объявить до `EXPECTED`; `EXPECTED_NAMES` может использовать её через `*INN_LAYOUT_FILES`.)
тест раскладок и правка `s18`:
```python
INN_LAYOUT_COUNTERPARTIES = {
ALFA: "7701234560",
BETA: "7707654321",
"Иванов Иван": None,
GAMMA: "5001234561",
"ИП Сидоров": "500100732259",
}
@pytest.mark.parametrize("name", INN_LAYOUT_FILES)
def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: str) -> None:
"""#35: в каждой из восьми раскладок строки ИНН у каждого контрагента — ИНН его строки, строк ИНН в `rows` нет."""
outcome = parse_named(fixtures_dir, name)
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE, warnings_of(outcome)
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == INN_LAYOUT_COUNTERPARTIES
assert not [row for row in doc.rows if row.group_type == "OTHER"]
inns = {inn for inn in INN_LAYOUT_COUNTERPARTIES.values() if inn}
assert not [row for row in doc.rows if row.row_value in inns]
```
в `test_s18_inn_row_above_is_glued_and_not_other`: `{"7701234567", "5001234567"}` → `{"7701234560", "5001234561"}`, ожидание `{ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"}`, предупреждение `"ИНН 7701234560"`; снять `xfail`, если Task 4 его ставил.
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q`
Expected: FAIL — файлов `s20`…`s27` нет («запустите генератор»), `s18` с прежними ИНН.
- [ ] **Step 3: Generator** — в `api/tests/synthetic/generate.py`:
у `Node` (после `closing_debit_delta`):
```python
blank: bool = False # строка без сумм (строка ИНН без сумм, #35)
twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит
```
`children()`:
```python
def children(self) -> list["Node"]:
return [kid for kid in self.kids if not kid.indicator and kid.twin is None]
```
в `build_osv` строку `_row_cells(sheet, spec, next_row, (node, depth), node.six())` заменить на:
```python
_row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank)
```
после `_s19` — раскладки:
```python
InnLayout = Literal["above_sibling", "above_parent", "below_child", "below_sibling"]
def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node:
"""То же дерево, где ИНН каждого контрагента выведен отдельной строкой в раскладке `layout` (#35): над / под
наименованием, в дереве (родитель / ребёнок) или соседом; `sums` — у строки ИНН те же суммы, иначе строка пустая.
Колонки ИНН нет, в названии ИНН не пишется."""
kids: list[Node] = []
for kid in node.kids:
kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией с строками ИНН
if kid.inn is None or kid.indicator:
kids.append(kid)
continue
inn, kid.inn = kid.inn, None
if layout == "above_sibling":
kids += [Node(inn, twin=kid, blank=not sums), kid]
elif layout == "below_sibling":
kids += [kid, Node(inn, twin=kid, blank=not sums)]
elif layout == "above_parent":
kids.append(Node(inn, [kid], blank=not sums))
else:
kid.kids = [Node(inn, kid.kids, opening=kid.opening, turnover=kid.turnover, blank=not sums)]
kids.append(kid)
node.kids = kids
return node
def _inn_layout_tree() -> Node:
"""Счёт 60 для раскладок строк ИНН (#35): ИНН с верной контрольной суммой (10 и 12 цифр), физлицо без ИНН."""
first = alfa()
first.inn = "7701234560"
third = gamma()
third.inn = "5001234561"
return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])])
def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]:
def build() -> list[SheetSpec]:
root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums)
return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
return build
```
`_s18` — ИНН с верной контрольной суммой:
```python
def _s18() -> list[SheetSpec]:
"""ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.
ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35)."""
first = alfa(inn_style="above")
first.inn = "7701234560"
third = gamma(inn_style="above")
third.inn = "5001234561"
root = Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third])])
return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
```
в `FILES` после `s19`:
```python
"s20_inn_above_sibling_blank.xlsx": (_inn_layout("above_sibling", sums=False), to_xlsx),
"s21_inn_above_sibling_sums.xlsx": (_inn_layout("above_sibling", sums=True), to_xlsx),
"s22_inn_above_parent_blank.xlsx": (_inn_layout("above_parent", sums=False), to_xlsx),
"s23_inn_above_parent_sums.xlsx": (_inn_layout("above_parent", sums=True), to_xlsx),
"s24_inn_below_child_blank.xlsx": (_inn_layout("below_child", sums=False), to_xlsx),
"s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx),
"s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx),
"s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx),
```
докстринг модуля и `description` в `main`: «s01…s27»; строка про s20…s27 — «восемь раскладок строки ИНН (#35)». `InnStyle` и `inn_style="above"` (s18) не трогать.
- [ ] **Step 4: Сгенерировать файлы и проверить**
```bash
cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m tests.synthetic.generate ../../fixtures/osv/synthetic
git -C $WT/fixtures status --short
```
Expected: изменён только `osv/synthetic/s18_inn_row_above.xlsx`, добавлены восемь `s20`…`s27`; остальные файлы побайтно те же (генератор детерминирован). Если изменился любой другой `sNN` — остановиться и разобраться (вероятно, задеты общие узлы `alfa()`/`gamma()` или `children()`).
Run: `cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python -m pytest tests/synthetic -q`
Expected: PASS. Падение раскладки — разобрать по файлу CLI: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli ../../fixtures/osv/synthetic/<файл>`; чинить парсер (это и есть проверка дизайна), а не генератор, если только генератор не нарушает раскладку. Исправление парсера — в отчёт задачи и в design.md, если это решение.
- [ ] **Step 5: README fixtures** — в `$WT/fixtures/osv/synthetic/README.md`: строку `s18` поправить («у «ООО Альфа» и «ООО Гамма» ИНН с верной контрольной суммой (`7701234560`, `5001234561`) стоит отдельной строкой над названием…; без подсказки в шапке строка с неверной контрольной суммой не склеивается (#35)»); добавить восемь строк таблицы:
```markdown
| `s20_inn_above_sibling_blank.xlsx` | ИНН строкой **над** наименованием, сосед того же уровня, **без сумм** (#35) | ИНН — у своего контрагента, строк ИНН в `rows` нет, `done` |
| `s21_inn_above_sibling_sums.xlsx` | ИНН строкой над наименованием, сосед, **с теми же суммами** | то же; пара — по равенству сумм |
| `s22_inn_above_parent_blank.xlsx` | строка ИНН — **родитель** наименования (уровень выше), без сумм | наименование поднимается на её место, ИНН — ему, `done` |
| `s23_inn_above_parent_sums.xlsx` | строка ИНН — родитель наименования, с суммами | то же |
| `s24_inn_below_child_blank.xlsx` | строка ИНН — **ребёнок** наименования (уровень ниже), без сумм | ИНН — родителю, строка из `rows` уходит, `done` |
| `s25_inn_below_child_sums.xlsx` | строка ИНН — ребёнок наименования, с суммами (как «Контрагенты.ИНН» в 1С, но без подсказки в шапке) | то же |
| `s26_inn_below_sibling_sums.xlsx` | ИНН строкой **под** наименованием, сосед, с суммами | ИНН — своему контрагенту, `done` |
| `s27_inn_below_sibling_blank.xlsx` | ИНН строкой под наименованием, сосед, без сумм (дефект main: ИНН уходил следующему контрагенту) | ИНН — своему контрагенту; направление — по блоку 60.02, кончающемуся строкой ИНН; `done` |
```
и фразу под таблицей: «`s20`–`s27` — восемь раскладок строки ИНН рядом с контрагентом (issue tabular-parser#35); ИНН с верной контрольной суммой — без подсказки в шапке другие не склеиваются».
- [ ] **Step 6: `make check` и коммиты**
```bash
cd $WT/tabular-parser && make check
git -C $WT/tabular-parser add api/tests/synthetic
git -C $WT/tabular-parser commit -m "Синтетика: восемь раскладок строки ИНН s20–s27, s18 с верными ИНН (#35)
Co-Authored-By: Claude Opus 5.5 <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**
```bash
C=$WT/tabular-parser/.superpowers/sdd/osv-inn-rows-layouts/compare
mkdir -p $C/main-src
git -C $WT/tabular-parser archive main api | tar -x -C $C/main-src
```
- [ ] **Step 2: Скрипты** — `$C/dump.py`:
```python
"""Разбор всех файлов fixtures кодом из cwd (каталог api main или ветки) → JSON: файл -> результат."""
import json
import pathlib
import sys
from src.core.config import get_settings
from src.parser import get_parser
fixtures, out = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
parser = get_parser(get_settings())
result = {}
for path in sorted(p for p in fixtures.rglob("*") if p.suffix.lower() in {".xls", ".xlsx", ".xlsb"} and "golden" not in p.parts):
try:
outcome = parser.parse(path.read_bytes(), path.suffix.lower().lstrip("."))
data = {
"status": str(outcome.status.value),
"documents": [doc.model_dump(mode="json") for doc in outcome.documents],
"warnings": list(outcome.metrics.warnings) if outcome.metrics else None,
"error": outcome.error.model_dump(mode="json") if outcome.error else None,
}
except Exception as exc: # noqa: BLE001 - сравнение, не прод
data = {"exception": repr(exc)}
result[str(path.relative_to(fixtures))] = data
out.write_text(json.dumps(result, ensure_ascii=False, sort_keys=True, default=str))
print(len(result))
```
`$C/diff.py`:
```python
"""Отличия разбора main и ветки по файлам: статус, контрагенты (имя, ИНН), строки, предупреждения."""
import json
import pathlib
import sys
main, branch = (json.loads(pathlib.Path(name).read_text()) for name in sys.argv[1:3])
def counterparties(data: dict) -> list[tuple]:
return [
(row["row_number"], row.get("counterparty_name"), row.get("counterparty_inn"), row["group_type"])
for doc in data.get("documents", [])
for row in doc.get("rows", [])
if row["group_type"] in {"COUNTERPARTY", "OTHER"}
]
for name in sorted(set(main) | set(branch)):
left, right = main.get(name), branch.get(name)
if left == right:
continue
print(f"## {name}")
if left is None or right is None:
print("- только в", "ветке" if left is None else "main")
continue
print(f"- статус: {left.get('status')} → {right.get('status')}")
gone = set(counterparties(left)) - set(counterparties(right))
new = set(counterparties(right)) - set(counterparties(left))
for row in sorted(gone, key=str)[:15]:
print(f" - main: {row}")
for row in sorted(new, key=str)[:15]:
print(f" + ветка: {row}")
if len(gone) + len(new) > 30:
print(f" … всего −{len(gone)} / +{len(new)}")
warnings_left, warnings_right = set(left.get("warnings") or []), set(right.get("warnings") or [])
for warning in sorted(warnings_right - warnings_left)[:10]:
print(f" ! новое предупреждение: {warning}")
for warning in sorted(warnings_left - warnings_right)[:10]:
print(f" ~ ушло предупреждение: {warning}")
```
- [ ] **Step 3: Прогон**
```bash
cd $C/main-src/api && ENV_FILE=$WT/tabular-parser/api/.env.test $WT/tabular-parser/api/.venv/bin/python $C/dump.py $WT/fixtures $C/out-main.json
cd $WT/tabular-parser/api && ENV_FILE=.env.test .venv/bin/python $C/dump.py $WT/fixtures $C/out-branch.json
$WT/tabular-parser/api/.venv/bin/python $C/diff.py $C/out-main.json $C/out-branch.json > $C/report.md
```
(`dump.py` запускается из каталога `api` нужной версии — `src` берётся из cwd; если `ENV_FILE` с абсолютным путём не читается настройками main — скопировать `.env.test` в `$C/main-src/api/`.)
- [ ] **Step 4: Разобрать отчёт**
Ожидаемые отличия: `osv/synthetic/s18_inn_row_above.xlsx` (ИНН), `osv/synthetic/s20`…`s27` (только в ветке), файлы со строкой «похожей на ИНН» рядом с наименованием (`done` → `needs_check`), файлы, где раньше ИНН терялся или уходил не тому контрагенту. **Неизменны:** `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` — должно отсутствовать в отчёте; иначе — это дефект, чинить до отчёта владельцу.
Для каждого прочего отличия дописать в `report.md` под заголовком файла одну строку вывода: «ожидаемо: <почему>» или «НЕОЖИДАННО: <что>». Неожиданное — разобрать по `systematic-debugging`, исправить в коде (отдельным коммитом с тестом), повторить Steps 3–4.
- [ ] **Step 5: СТОП — ворота владельца**
Контроллер завершает работу отчётом владельцу: содержимое `report.md` (файл, статус до/после, суть, вывод). Golden **не обновлять** до явного «да» владельца. После одобрения (контроллер продолжается по SendMessage):
- [ ] **Step 6: Golden и манифест**
```bash
cd $WT/tabular-parser && make golden-update
git -C $WT/fixtures diff --stat
```
Diff golden должен совпадать с одобренным `report.md` (те же файлы). Если `needs_check` получили файлы пакета ДКБ — обновить `api/tests/osv_dkb_expectations.yaml` (ожидания — данные) с комментарием «#35: строка, похожая на ИНН, без пары» и прогнать `make check` (зелёный целиком).
```bash
git -C $WT/fixtures add golden
git -C $WT/fixtures commit -m "Golden ОСВ: строки ИНН — восемь раскладок, s18 с верными ИНН (tabular-parser#35)
Co-Authored-By: Claude Opus 5.5 <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` второй источник заменить (вместо «строка без сумм **прямо над** названием…» и абзаца «Отдельно — строка реквизита…») на правило строк ИНН:
```markdown
затем **строка ИНН** рядом с наименованием (OQ-163, решение владельца 13.10.2026; заменяет правила OQ-130 и OQ-162):
строка, подпись которой целиком — ИНН (`inn_only_row`: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП»; число из 9/11
цифр дополняется нулём слева — R-24). Без подсказки `<группировка>.ИНН` в шапке ИНН строки **должен пройти контрольную
сумму ИНН** (алгоритм ФНС); строка из 10/12 цифр с неверной суммой рядом с наименованием остаётся как есть,
предупреждение «похожа на ИНН, контрольная сумма не сходится», документ — `needs_check`. С подсказкой сумма не
проверяется, пустая строка-ребёнок наименования — «ИНН не указан» (рулинг R-1 #32), непустая не-ИНН на этом месте —
«ИНН не распознан». Восемь раскладок — ИНН над / под наименованием × в дереве / не в дереве × с суммами / без:
| Положение строки ИНН | Пара |
|---|---|
| у неё есть дети (над, в дереве) | единственный ребёнок-наименование, он поднимается на её место; несколько детей — без пары. Если в `group_schema` больше одной группировки и в листе нет строк глубже схемы, строка — наименование по схеме (контрагент, названный ИНН) |
| ребёнок наименования (под, в дереве) | родитель; её дети — к нему; несколько строк ИНН у одного наименования — без пары |
| сосед того же родителя (не в дереве) | с суммами — сосед с теми же суммами; без сумм (или совпали оба) — сосед по направлению листа |
Суммы строки ИНН, если есть, должны совпасть с суммами пары по всем шести колонкам (пусто = 0). Направление листа —
первое определённое: порядок элемента `<группировка>.ИНН` в шапке; пары соседей по суммам; блоки детей одного родителя
(начинается строкой ИНН — над, кончается — под, начинается и кончается — блок сломан). Противоречие или нет сведений —
строки без сумм без пары. Две строки ИНН на одно наименование — обе без пары. ИНН колонки, отличный от ИНН строки,
остаётся, документ — `needs_check`. Склеенная строка в `rows` не попадает, предупреждение «строка N: ИНН … отнесён к
контрагенту в строке M»; **без пары** — строка в `rows` с `group_type = OTHER` (в свод не попадает), предупреждение с
причиной, документ — `needs_check`. **Реализовано** (tabular-parser #35, модуль `inn_rows.py`; синтетика `s18`, `s20`–`s27`).
```
Хвост названия (OQ-100) остаётся третьим источником. Абзац «Из текста ИНН отрезается всегда…» — `inn_only_row` теперь «строка ИНН (OQ-163)»; ссылку «OQ-130» там заменить на «OQ-163».
- [ ] **Step 3: 07-open-questions** — строка после OQ-162:
```markdown
| OQ-163 | ОСВ, где ИНН контрагента выведен **отдельной строкой** рядом с наименованием, в любой раскладке: над / под × в дереве / не в дереве × с суммами / без (требование владельца 13.10.2026). До решения парсер склеивал только «над, не в дереве, без сумм» (OQ-130) и «под, в дереве» при подсказке «Контрагенты.ИНН» (OQ-162); «под, не в дереве, без сумм» отдавал ИНН следующему контрагенту | владелец, 13.10.2026; RSHB/arm-tabular-parser#35, #34 | tabular-parser §1.1, §1.3 | **решено владельцем 13.10.2026:** без подсказки в шапке строка ИНН — только с верной контрольной суммой ИНН (во всех раскладках, включая OQ-130); с подсказкой — без проверки, порядок элементов задаёт направление; «над, в дереве» — только единственный ребёнок; соседи — по суммам, без сумм — по направлению листа (шапка → пары по суммам → однозначные блоки); неоднозначное — строка `OTHER`, `needs_check`; ведущий ноль числовой строки ИНН (#34). `05-domain-rules.md` §1.3; **реализовано** RSHB/arm-tabular-parser#35 |
```
В колонке статуса OQ-130 и OQ-162 дописать в конец: «Правило строки ИНН заменено OQ-163 (13.10.2026).»
- [ ] **Step 4: README и CLAUDE.md tabular-parser** — README, раздел «Парсер ОСВ»: в цепочке модулей между `hierarchy.py` и `classify.py` — «`inn_rows.py` — строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, OQ-163)»; убрать упоминания `glue_inn_attributes`/`_glue_inn_rows`, если есть (`grep -n "glue_inn\|inn_above\|InnAttribute" README.md CLAUDE.md`). CLAUDE.md: в списке модулей Среза 2a добавить `inn_rows.py`; строку о golden (`113 файлов: …`) — пересчитанные числа.
- [ ] **Step 5: OpenSpec tasks.md** — `$WT/tabular-parser/openspec/changes/osv-inn-rows-layouts/tasks.md`:
```markdown
## 1. Код (tabular-parser)
- [x] 1.1 Контрольная сумма ИНН и ИНН строки (`numbers.py`)
- [x] 1.2 Модуль `inn_rows.py`: склейка в дереве, контрольная сумма, защита «над, в дереве»
- [x] 1.3 Модуль `inn_rows.py`: соседи по суммам и направление листа
- [x] 1.4 Подключение в `osv.py`, подсказка шапки в обоих порядках, удаление `_glue_inn_rows` и `glue_inn_attributes`
## 2. Данные (fixtures)
- [x] 2.1 Синтетика `s20`–`s27`, `s18` с верными ИНН
- [x] 2.2 Сравнение main и ветки на всех fixtures, golden после согласования с владельцем
## 3. Документация
- [x] 3.1 docs: `05-domain-rules.md` §1.1, §1.3, OQ-163
- [x] 3.2 README и CLAUDE.md tabular-parser
```
Проверка: `cd $WT/tabular-parser && openspec validate osv-inn-rows-layouts --strict`.
- [ ] **Step 6: Коммиты**
```bash
git -C $WT/docs add context/05-domain-rules.md context/07-open-questions.md
git -C $WT/docs commit -m "Правила: строки ИНН рядом с контрагентом — восемь раскладок, OQ-163 (tabular-parser#35)
Co-Authored-By: Claude Opus 5.5 <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; мерджить вместе).