diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 048b7c0..f5dee97 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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()) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 52ca6c2..ac7e8a9 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -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) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index bf30d7f..e5866c1 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -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) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index c978de2..d50bf67 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -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 # --- внутреннее --- diff --git a/api/tests/fixtures/real_osv.py b/api/tests/fixtures/real_osv.py index 9bbbdd7..336edd4 100644 --- a/api/tests/fixtures/real_osv.py +++ b/api/tests/fixtures/real_osv.py @@ -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 + ) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index d079ae1..9ddfc91 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -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") diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index dc177ac..7ba8974 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -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 --- diff --git a/api/tests/unit/parser/test_inn_rows.py b/api/tests/unit/parser/test_inn_rows.py index 1a03d15..fa04484 100644 --- a/api/tests/unit/parser/test_inn_rows.py +++ b/api/tests/unit/parser/test_inn_rows.py @@ -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)) diff --git a/api/tests/unit/parser/test_inn_subkonto.py b/api/tests/unit/parser/test_inn_subkonto.py index 55ee51c..d529612 100644 --- a/api/tests/unit/parser/test_inn_subkonto.py +++ b/api/tests/unit/parser/test_inn_subkonto.py @@ -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" diff --git a/openspec/changes/osv-inn-rows-layouts/design.md b/openspec/changes/osv-inn-rows-layouts/design.md index 5026556..8487509 100644 --- a/openspec/changes/osv-inn-rows-layouts/design.md +++ b/openspec/changes/osv-inn-rows-layouts/design.md @@ -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) и напомнит | diff --git a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md index c7adcae..78cfe32 100644 --- a/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md +++ b/openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md @@ -119,10 +119,10 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST без сумм — сосед по направлению листа (над → следующий, под → предыдущий). Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление -листа** — первое определённое из: порядок `<группировка>.ИНН` в шапке; пары соседей, найденные по суммам; однозначные блоки -детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН — -под; начинается и кончается — блок сломан, его строки ИНН без пары). Противоречие сведений или их отсутствие — направление -неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары. +листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных +блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой +ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу +или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары. **Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»), её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —