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