diff --git a/README.md b/README.md index cbdb142..cb98e34 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,8 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо `COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`, `inn_kpp_cell`. -**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): в шапке варианта 2 элемент `<группировка>.ИНН` («субконто: Контрагенты, -Контрагенты.ИНН») — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true` +**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты, +Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true` (`header.GroupSchema.positions` держит его позицию для классификатора). 1С выводит ИНН строкой под контрагентом, с теми же суммами; `classify.glue_inn_attributes` отдаёт ИНН контрагенту (извлечение — как из колонки ИНН, R-24), строку убирает, её детей поднимает на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». Пустая ячейка (физлицо без ИНН) — diff --git a/api/src/parser/header.py b/api/src/parser/header.py index ce89730..c662c16 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -481,16 +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 -def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]: - groups: list[OsvGroup] = [] - for index, row in enumerate(header.group_label_rows): - label = sheet.text_at(row, header.text_col) - # Первая группировка — всегда 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))) - return groups or [_group("ACCOUNT")] - - @dataclass(frozen=True) class InnAttribute: """Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой @@ -524,32 +514,58 @@ def _inn_owner(item: str, groups: list[OsvGroup], rules: Rules) -> int | None: 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 _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema: + 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): + 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) + if not groups: + groups, slots = [_group("ACCOUNT")], [0] + return GroupSchema(groups, _positions(groups, slots), 1) + + def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema: groups = [_group("ACCOUNT", _first_group_name(sheet, header))] - # Позиция строки: индекс группировки в `groups` или реквизит ИНН (OQ-162) slots: list[int | InnAttribute] = [0] inn_pending = header.inn_col is not None for item in items: - owner = _inn_owner(item, groups, rules) - if owner is not None: - groups[owner] = _group("COUNTERPARTY", groups[owner].name, has_inn=True) - slots.append(InnAttribute(item)) + if _attach_inn_attribute(item, groups, slots, rules): continue group_type = rules.groups.classify(item) has_inn = inn_pending and group_type == "COUNTERPARTY" inn_pending = inn_pending and not has_inn groups.append(_group(group_type, item, has_inn=has_inn)) slots.append(len(groups) - 1) - positions = [slot if isinstance(slot, InnAttribute) else groups[slot] for slot in slots] - return GroupSchema(groups, positions, 2) + return GroupSchema(groups, _positions(groups, slots), 2) def build_group_levels(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema: """Группировки (первая — ACCOUNT), позиции строк и вариант шапки (1 — строки блока, 2 — «субконто: …»).""" items = _subkonto_items(_head_cells(sheet, header), rules) if items is None: - groups = _variant1_groups(sheet, header, rules) - return GroupSchema(groups, list(groups), 1) + return _variant1_groups(sheet, header, rules) return _variant2_groups(sheet, header, rules, items) diff --git a/api/tests/unit/parser/test_inn_subkonto.py b/api/tests/unit/parser/test_inn_subkonto.py index c5af3a2..55ee51c 100644 --- a/api/tests/unit/parser/test_inn_subkonto.py +++ b/api/tests/unit/parser/test_inn_subkonto.py @@ -179,3 +179,35 @@ def test_unrecognised_inn_text_is_not_glued(rules: Rules) -> None: assert not ok assert "нерез." in _values(document) assert any("не распознан" in warning for warning in warnings), warnings + + +# --- Шапка варианта 1: группировки — строки блока заголовка («Счет / Контрагенты / Контрагенты.ИНН») --- + + +def _variant1_sheet(*rows: list[object]) -> Sheet: + head: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60 за 9 месяцев 2025 г."], + [], + ["Выводимые данные: БУ (данные бухгалтерского учета)"], + [], + ["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)], + ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["Контрагенты.ИНН"], + ] + return sheet_from_rows([*head, *rows]) + + +def test_variant1_inn_attribute_row_is_glued(rules: Rules) -> None: + sheet = _variant1_sheet(r(0, "60.01", A), r(1, "А2 БИЗНЕС ООО", A), r(2, 2370012036, A), [L(0), "Итого", *A]) + + document, _, ok = _parse(sheet, rules) + + assert document.header.header_variant == 1 + 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 БИЗНЕС ООО", "Итого"] + assert document.rows[1].counterparty_inn == "2370012036"