ОСВ: строки ИНН — подключение прохода, подсказка шапки в обоих порядках, старые склейки удалены (#35)

Хвосты ревью Task 3: формулировка направления листа в spec/design (T3.1), тесты противоречия сведений и приоритета подсказки (T3.2), assert на glue.ok (T3.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-13 15:03:40 +04:00
parent 71d56bd5ed
commit 26ed11ad28
11 changed files with 284 additions and 218 deletions

View File

@ -7,9 +7,10 @@
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
нераспознанные колонки таблицы (`extra`);
3. `glue_inn_attributes` — строки реквизита «Контрагенты.ИНН» под контрагентом отдают ИНН ему и уходят из строк
(OQ-162).
нераспознанные колонки таблицы (`extra`).
ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`): между шагами 1 и 2
стоит проход склейки строк ИНН (#35).
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
@ -18,8 +19,9 @@
"""
import re
from collections.abc import Sequence
from dataclasses import dataclass, replace
from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal
@ -28,9 +30,8 @@ from src.contract.result import OsvGroup
from src.parser.deadline import Deadline
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
from src.parser.grid import Sheet, column_letter
from src.parser.header import InnAttribute
from src.parser.hierarchy import BodyRow, Levels
from src.parser.numbers import column_inn, inn_only, split_inn_suffix, to_money
from src.parser.numbers import column_inn, split_inn_suffix, to_money
from src.parser.rules import GroupType, Rules, first_match, normalize_text
__all__ = [
@ -41,7 +42,6 @@ __all__ = [
"RowGroupType",
"classify_rows",
"collect_body_rows",
"glue_inn_attributes",
"row_data_type",
]
@ -82,9 +82,6 @@ class ClassifiedRow:
counterparty_inn: str | None
data_type: DataType | None
extra: dict[str, str | None] | None
# Строка реквизита «Контрагенты.ИНН» (OQ-162) и ИНН из её ячейки; склеивает `glue_inn_attributes`
is_inn_attribute: bool = False
attribute_inn: str | None = None
# --- Строки таблицы ---
@ -191,40 +188,6 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu
return tuple(sums.values())
def _has_sums(item: BodyRow) -> bool:
return any(value is not None for value in item.sums)
def _glue_inn_rows(body: list[BodyRow], rules: Rules, warnings: list[str]) -> list[BodyRow]:
"""Строка с одним ИНН и без сумм над строкой с суммами того же уровня оформления (outline и отступ) — ИНН
контрагента под ней (OQ-130): из строк она убирается, ИНН переходит к следующей строке (`inn_above`).
Ничего иного такая строка не значит, а оставшись в таблице, стала бы OTHER и потеряла ИНН. Другой уровень
оформления или отсутствие сумм у следующей строки — строка остаётся как есть.
"""
glued: list[BodyRow] = []
pending: BodyRow | None = None
for item in body:
if pending is not None:
same_level = (pending.outline, pending.indent) == (item.outline, item.indent)
if same_level and _has_sums(item) and not item.is_account:
inn = inn_only(pending.text, rules)
warnings.append(
f"строка {pending.row}: ИНН {inn} над строкой {item.row} отнесён к ней как к контрагенту"
)
item = replace(item, inn_above=inn) # noqa: PLW2901 - строка меняется, а не подменяется другой
else:
glued.append(pending)
pending = None
if not _has_sums(item) and not item.is_account and inn_only(item.text, rules) is not None:
pending = item
continue
glued.append(item)
if pending is not None:
glued.append(pending)
return glued
def _first_sum_col(header: TableHeader) -> int:
return min(header.columns.indexes().values())
@ -312,7 +275,7 @@ def collect_body_rows(
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check("суммы строк")
body.append(_body_row(sheet, row, header, label_cols, warnings))
return _glue_inn_rows(body, rules, warnings), total_row, warnings
return body, total_row, warnings
# --- Классификация ---
@ -369,26 +332,24 @@ class _Kind:
data_type: DataType | None
counterparty_name: str | None = None
counterparty_inn: str | None = None
is_inn_attribute: bool = False
attribute_inn: str | None = None
@dataclass(frozen=True)
class _Context:
schema: Sequence[OsvGroup | InnAttribute]
schema: Sequence[OsvGroup]
header: TableHeader
sheet: Sheet
doc_data_type: DataType | None
rules: Rules
currency_codes: frozenset[str]
inns: Mapping[int, str]
unpaired: AbstractSet[int]
def _schema_group(schema: Sequence[OsvGroup | InnAttribute], position: int) -> tuple[GroupType, str | None]:
def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]:
if position >= len(schema):
return "OTHER", None
group = schema[position]
if isinstance(group, InnAttribute):
return "OTHER", group.name
return group.type, group.name
@ -405,33 +366,34 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
data_type = row_data_type(item.text, context.rules, context.currency_codes)
if data_type is not None:
return _Kind("OTHER", None, code, data_type), inherited
if item.row in context.unpaired:
# Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
return _Kind("OTHER", None, code, doc_data_type), inherited
position = inherited.depth + 1
group_type, name = _schema_group(context.schema, position)
branch = _Branch(code, position)
if position < len(context.schema) and isinstance(context.schema[position], InnAttribute):
# Реквизит ИНН строкой под контрагентом: ИНН — как из колонки ИНН (R-24), склейка — `glue_inn_attributes`
cell = context.sheet.cell(item.row, item.label_col or context.header.text_col)
inn = column_inn(cell, context.rules)
return _Kind("OTHER", name, code, doc_data_type, is_inn_attribute=True, attribute_inn=inn), branch
if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, doc_data_type), branch
inn_col = context.header.inn_col
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
# ИНН: колонка ИНН, затем строка без сумм над названием (OQ-130), затем хвост самого названия (OQ-100)
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules)
inn = column or item.inn_above or in_name
inn = column or context.inns.get(item.row) or in_name
return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
rows: Sequence[BodyRow],
levels: Levels,
schema: Sequence[OsvGroup | InnAttribute],
schema: Sequence[OsvGroup],
header: TableHeader,
sheet: Sheet,
doc_data_type: DataType | None,
total_row: int | None,
rules: Rules,
*,
inns: Mapping[int, str] | None = None,
unpaired: AbstractSet[int] = frozenset(),
) -> list[ClassifiedRow]:
"""Классификация строк по дереву `levels` и схеме группировок.
@ -442,9 +404,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
группировки, а вид сумм родителя; позицию в схеме она не занимает;
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
- позиция реквизита ИНН (`InnAttribute`, `GroupSchema.positions`) — OTHER с `is_inn_attribute` и ИНН ячейки.
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`.
"""
context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes))
context = _Context(
schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired
)
extra_keys = _extra_keys(sheet, header, rows)
branches: dict[int, _Branch] = {}
result: list[ClassifiedRow] = []
@ -472,82 +436,6 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
counterparty_inn=kind.counterparty_inn,
data_type=kind.data_type,
extra=_extra(sheet, item.row, extra_keys),
is_inn_attribute=kind.is_inn_attribute,
attribute_inn=kind.attribute_inn,
)
)
return result
# --- Реквизит «Контрагенты.ИНН» (OQ-162) ---
def _not_glued_reason(item: ClassifiedRow, parent: ClassifiedRow | None, siblings: int) -> str | None:
"""Почему строку реквизита ИНН нельзя отнести к родителю; `None` — можно."""
if parent is None or parent.group_type != "COUNTERPARTY":
return "над ней нет строки контрагента"
if siblings > 1:
return f"у контрагента в строке {parent.body.row} несколько строк ИНН"
if item.body.sums != parent.body.sums:
return f"суммы не равны суммам контрагента в строке {parent.body.row}"
if item.attribute_inn is None and item.body.text:
return "ИНН не распознан (нужно 10 или 12 цифр)"
if item.attribute_inn is not None and parent.counterparty_inn not in {None, item.attribute_inn}:
return f"ИНН расходится с ИНН {parent.counterparty_inn} контрагента в строке {parent.body.row}"
return None
def glue_inn_attributes(rows: Sequence[ClassifiedRow]) -> tuple[list[ClassifiedRow], list[str], bool]:
"""Строки реквизита «Контрагенты.ИНН» (OQ-162; `05-domain-rules.md` §1.3) → ИНН родительского контрагента.
Склеенная строка из строк уходит, её потомки (договоры) переходят к контрагенту на уровень выше. Склейка — только
если строка реквизита у контрагента одна, её суммы равны суммам контрагента, ИНН распознан и не расходится с уже
найденным; иначе строка остаётся OTHER, предупреждение с причиной и признак `False` (документ — `needs_check`).
Пустая ячейка ИНН (физлицо без ИНН в 1С) — «ИНН не указан»: строка склеивается, ИНН контрагента не меняется (R-1).
Возвращает строки, предупреждения и признак «все строки реквизита склеены».
"""
by_row = {item.body.row: item for item in rows}
siblings: dict[int | None, int] = {}
for item in rows:
if item.is_inn_attribute:
siblings[item.parent] = siblings.get(item.parent, 0) + 1
glued: dict[int, int] = {} # строка реквизита -> строка контрагента
inns: dict[int, str] = {} # строка контрагента -> ИНН
warnings: list[str] = []
for item in rows:
if not item.is_inn_attribute:
continue
parent = None if item.parent is None else by_row.get(item.parent)
reason = _not_glued_reason(item, parent, siblings[item.parent])
if reason is not None or parent is None:
warnings.append(f"строка {item.body.row}: «{item.group_name}» не отнесён к контрагенту — {reason}")
continue
glued[item.body.row] = parent.body.row
if item.attribute_inn is None:
warnings.append(
f"строка {item.body.row}: ИНН пуст («{item.group_name}») — строка отнесена к контрагенту "
f"в строке {parent.body.row} без ИНН"
)
continue
inns[parent.body.row] = item.attribute_inn
warnings.append(
f"строка {item.body.row}: ИНН {item.attribute_inn} («{item.group_name}») отнесён к контрагенту "
f"в строке {parent.body.row}"
)
lifted: dict[int, int] = {} # строка -> число склеенных предков
result: list[ClassifiedRow] = []
for item in rows:
parent_row = item.parent
shift = 0 if parent_row is None else lifted.get(parent_row, 0) + (1 if parent_row in glued else 0)
lifted[item.body.row] = shift
if item.body.row in glued:
continue
result.append(
replace(
item,
level=item.level - shift,
parent=None if parent_row is None else glued.get(parent_row, parent_row),
counterparty_inn=inns.get(item.body.row, item.counterparty_inn),
)
)
return result, warnings, len(glued) == sum(siblings.values())

View File

@ -9,7 +9,7 @@
import calendar
import datetime
import re
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Any, Literal, get_args
@ -23,7 +23,6 @@ __all__ = [
"ACCOUNT_NOT_FOUND_WARNING",
"DocHeader",
"GroupSchema",
"InnAttribute",
"InnHint",
"build_group_levels",
"build_group_schema",
@ -482,14 +481,6 @@ def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
return sheet.text_at(header.group_label_rows[0], header.text_col) or None
@dataclass(frozen=True)
class InnAttribute:
"""Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой
контрагента, на уровень глубже. Это не уровень аналитики: в `group_schema` контракта его нет."""
name: str
@dataclass(frozen=True)
class InnHint:
"""Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку
@ -503,78 +494,71 @@ class InnHint:
@dataclass(frozen=True)
class GroupSchema:
"""Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`).
`positions[i]` — группировка строки на глубине i (0 — ACCOUNT) или реквизит ИНН, который глубину занимает,
а группировкой не является. Без реквизитов `positions` совпадает с `groups`.
"""
"""Группировки контракта (`groups`), вариант шапки и подсказка для строк ИНН (`<группировка>.ИНН`, OQ-162, #35)."""
groups: list[OsvGroup]
positions: list[OsvGroup | InnAttribute]
variant: HeaderVariant
inn_hint: InnHint | None = None
def _inn_owner(item: str, groups: list[OsvGroup], rules: Rules) -> int | None:
"""Индекс группировки контрагентов, чей реквизит ИНН — `item` («Контрагенты.ИНН»); иначе `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
owner = owner.strip().casefold()
for index, group in enumerate(groups):
if group.type == "COUNTERPARTY" and (group.name or "").casefold() == owner:
return index
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 _attach_inn_attribute(item: str, groups: list[OsvGroup], slots: list[int | InnAttribute], rules: Rules) -> bool:
"""`item` — реквизит ИНН одной из предыдущих группировок контрагентов: владелец получает `has_inn`, позиция строк —
`InnAttribute`; `True`. Иначе ничего не меняется, `False`."""
owner = _inn_owner(item, groups, rules)
if owner is None:
return False
groups[owner] = _group("COUNTERPARTY", groups[owner].name, has_inn=True)
slots.append(InnAttribute(item))
return True
def _positions(groups: list[OsvGroup], slots: list[int | InnAttribute]) -> list[OsvGroup | InnAttribute]:
"""Позиции строк: индекс группировки в `groups` или реквизит ИНН (OQ-162)."""
return [slot if isinstance(slot, InnAttribute) else groups[slot] for slot in slots]
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] = []
slots: list[int | InnAttribute] = []
for index, row in enumerate(header.group_label_rows):
label = sheet.text_at(row, header.text_col)
if index > 0 and _attach_inn_attribute(label, groups, slots, rules):
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)
groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules)))
slots.append(len(groups) - 1)
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, slots = [_group("ACCOUNT")], [0]
return GroupSchema(groups, _positions(groups, slots), 1)
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))]
slots: list[int | InnAttribute] = [0]
hint = _inn_hint(items, rules)
inn_pending = header.inn_col is not None
for item in items:
if _attach_inn_attribute(item, groups, slots, rules):
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))
slots.append(len(groups) - 1)
return GroupSchema(groups, _positions(groups, slots), 2)
return GroupSchema(groups, 2, hint)
def build_group_levels(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema:
"""Группировки (первая — ACCOUNT), позиции строк и вариант шапки (1 — строки блока, 2 — «субконто: …»)."""
"""Группировки (первая — ACCOUNT), вариант шапки (1 — строки блока, 2 — «субконто: …») и подсказка строк ИНН."""
items = _subkonto_items(_head_cells(sheet, header), rules)
if items is None:
return _variant1_groups(sheet, header, rules)

View File

@ -59,7 +59,6 @@ class BodyRow:
outline: int
sums: tuple[Decimal | None, ...]
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130)
@dataclass(frozen=True)

View File

@ -12,13 +12,14 @@ from dataclasses import replace
from src.contract import OsvDocument
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows, glue_inn_attributes
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.deadline import Deadline
from src.parser.detect import TableHeader, find_table_header
from src.parser.errors import InvalidFormatError
from src.parser.grid import Sheet, column_letter
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
from src.parser.hierarchy import resolve_levels
from src.parser.inn_rows import glue_inn_rows
from src.parser.rules import Rules
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
@ -147,9 +148,30 @@ class OsvParser:
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
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(body, levels, schema.positions, header, sheet, doc.data_type, total_row, rules)
classified, inn_warnings, inn_glued = glue_inn_attributes(classified)
classified = classify_rows(
glue.rows,
glue.levels,
schema.groups,
header,
sheet,
doc.data_type,
total_row,
rules,
inns=glue.inns,
unpaired=glue.unpaired,
)
except InvalidFormatError as exc:
raise _invalid_format(sheet, step, exc) from exc
doc = _with_table_account(doc, classified)
@ -158,12 +180,12 @@ class OsvParser:
document = self._document(
sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks
)
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *inn_warnings]
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *glue.warnings]
if not any(item.group_type != "TOTAL" for item in classified):
warnings.append(NO_DATA_ROWS_WARNING)
warnings.extend(check_warnings)
# Несклеенный реквизит «Контрагенты.ИНН» (OQ-162) — строка, которую нужно проверить глазами: needs_check
return document, unique(warnings), _is_ok(checks) and inn_glued
# Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check
return document, unique(warnings), _is_ok(checks) and glue.ok
# --- внутреннее ---

View File

@ -17,8 +17,9 @@ from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows
from src.parser.deadline import Deadline
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
from src.parser.grid import Sheet
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
from src.parser.header import DocHeader, build_group_levels, parse_doc_header
from src.parser.hierarchy import BodyRow, Levels, resolve_levels
from src.parser.inn_rows import glue_inn_rows
from src.parser.readers import read_book
from src.parser.rules import load_rules
@ -61,9 +62,25 @@ def parse_real(path: Path) -> RealParse:
deadline = Deadline(PARSE_TIMEOUT_SECONDS)
header = find_table_header(sheet, rules)
doc = parse_doc_header(sheet, header, rules)
schema, _ = build_group_schema(sheet, header, 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)
rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
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,
)
seconds = time.monotonic() - started
return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds)
return RealParse(
sheet, header, doc, schema.groups, glue.rows, total_row, glue.levels, rows, [*warnings, *glue.warnings], seconds
)

View File

@ -518,6 +518,7 @@ def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> N
assert "inn" not in doc.columns
@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True)
def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None:
"""OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента."""
outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx")

View File

@ -17,8 +17,9 @@ from src.parser.deadline import Deadline
from src.parser.detect import find_table_header
from src.parser.errors import ParseTimeoutError
from src.parser.grid import Sheet
from src.parser.header import build_group_schema
from src.parser.header import build_group_levels
from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels
from src.parser.inn_rows import glue_inn_rows
from src.parser.rules import Rules, load_rules
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
@ -60,11 +61,25 @@ def parse(
) -> Parsed:
deadline = deadline or Deadline(TIMEOUT_SECONDS)
header = find_table_header(sheet, rules)
schema, _ = build_group_schema(sheet, header, 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)
rows = classify_rows(body, levels, schema, header, sheet, doc_data_type, total_row, rules)
return Parsed(rows, warnings, levels, total_row)
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)
# --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры ---
@ -614,7 +629,7 @@ def test_inn_with_kpp_in_one_column_cell_gives_inn(rules: Rules) -> None:
def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234567"), # ИНН строкой выше названия, без сумм
r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна)
r(1, "ООО Поставщик", S100),
)
@ -624,44 +639,67 @@ def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
supplier = parsed.rows[1]
assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == (
"ООО Поставщик",
"7701234567",
"7701234560",
ACCOUNT_ROW,
)
assert any("ИНН" in warning and "7701234567" in warning for warning in parsed.warnings), parsed.warnings
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, "ИНН 7701234567 / 770101001"),
r(1, "ИНН 7701234560 / 770101001"),
r(1, "ООО Поставщик", S100),
)
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234567"
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560"
def test_inn_row_above_on_other_level_is_not_glued(rules: Rules) -> None:
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, "7701234567"),
r(1, "7701234560", S100),
r(2, "ООО Поставщик", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "7701234567", "ООО Поставщик"]
assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"]
assert parsed.one("ООО Поставщик").counterparty_inn is None
def test_inn_row_above_before_row_without_sums_is_not_glued(rules: Rules) -> None:
def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234567"),
r(1, "7701234560"),
r(1, "ООО Поставщик"),
r(1, "ООО Другой", S100),
)
assert "7701234567" in [row.body.text for row in parse(sheet, rules).rows]
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)
# --- TOTAL ---

View File

@ -291,6 +291,7 @@ def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_
assert glue.inns == {4: INN_A}
assert glue.unpaired == frozenset()
assert glue.ok
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
@ -428,6 +429,40 @@ def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
assert glue.inns == {4: BAD}
def test_sum_pair_below_and_block_above_contradict_each_other(rules: Rules) -> None:
# T3.2: пара по суммам говорит «под», однозначный блок (начинается строкой ИНН) — «над»: направление неизвестно
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, "ООО Альфа", A),
r(2, INN_A, A), # суммы совпали только с предыдущим — «под»
r(2, "ООО Бета", B),
r(1, "60.02", B),
r(2, INN_B), # блок 60.02 начинается строкой ИНН — «над»
r(2, "ООО Гамма", B),
)
assert glue.unpaired == frozenset({7})
assert not glue.ok
assert has_warning(glue, "строка 7", "направление")
def test_hint_overrides_opposite_sum_pair_direction(rules: Rules) -> None:
# T3.2: пара по суммам даёт «под»; подсказка шапки «над» перекрывает это сведение для строки ИНН без сумм
rows = (
r(0, "60", A),
r(1, "ООО Альфа", A),
r(1, INN_A, A), # пара по суммам с предыдущим — «под»
r(1, "ООО Бета", B),
r(1, INN_B),
r(1, "ООО Гамма", B),
)
assert run(rules, *rows).inns == {2: INN_A, 4: INN_B}
assert run(rules, *rows, hint=ABOVE).inns == {2: INN_A, 6: INN_B}
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))

View File

@ -211,3 +211,84 @@ def test_variant1_inn_attribute_row_is_glued(rules: Rules) -> None:
assert ok
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
assert document.rows[1].counterparty_inn == "2370012036"
# --- Подсказка в обратном порядке: «Контрагенты.ИНН, Контрагенты» — ИНН над наименованием ---
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(1, "А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("2370012036" in warning for warning in warnings), warnings
def test_inn_attribute_before_owner_in_tree_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 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(1, "А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"

View File

@ -70,7 +70,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе
пары нет.
5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа.
6. **Направление документа** (лист ОСВ), первое определённое:
6. **Направление документа** (лист ОСВ): подсказка шапки (п. 1), если она есть; иначе сведения пп. 2–3 вместе:
1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над;
2. пары соседей, найденные по суммам (п. 5, один совпавший сосед);
3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не
@ -142,3 +142,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
| 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` |
| 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает |
| 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` |
| 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит |

View File

@ -119,10 +119,10 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление
листа** — первое определённое из: порядок `<группировка>.ИНН` в шапке; пары соседей, найденные по суммам; однозначные блоки
детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН —
под; начинается и кончается — блок сломан, его строки ИНН без пары). Противоречие сведений или их отсутствие — направление
неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары.
листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных
блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой
ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары.
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —