From bb78f810946c17b904212a9c68085be769cca6ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 11:11:06 +0400 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=20=C2=AB=D0=9A=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B.=D0=98=D0=9D=D0=9D?= =?UTF-8?q?=C2=BB=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=BC=20=E2=80=94=20=D0=98=D0=9D=D0=9D=20?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8E,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=C2=AB.0=C2=BB=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В шапке варианта 2 «субконто: Контрагенты, Контрагенты.ИНН» 1С выводит ИНН отдельной строкой на уровень глубже контрагента. Парсер принимал реквизит за второй уровень контрагентов: строка ИНН становилась контрагентом с именем «2370012036.0» и без ИНН, у наименования ИНН тоже не было, в свод ядра попадали обе строки. - header: элемент «<группировка>.ИНН» — InnAttribute, в group_schema не попадает, у владельца has_inn = true; GroupSchema.positions держит позицию для классификатора; - classify: строка реквизита — OTHER с ИНН ячейки (column_inn, R-24); glue_inn_attributes отдаёт ИНН контрагенту, убирает строку, поднимает её детей; не склеилось (несколько строк, суммы не равны, ИНН не распознан или расходится) — OTHER, предупреждение, needs_check; пустая ячейка (физлицо) — склейка без ИНН (R-1); - readers: целый float из xls читается как int — без «.0». Файл банка: 713 из 716 контрагентов с ИНН, 3 физлица без ИНН, status done. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + api/src/parser/classify.py | 106 +++++++++++- api/src/parser/header.py | 75 ++++++++- api/src/parser/osv.py | 18 +- api/src/parser/readers/_common.py | 6 +- api/tests/fixtures/test_osv_dkb.py | 3 +- api/tests/unit/parser/test_inn_subkonto.py | 181 +++++++++++++++++++++ api/tests/unit/parser/test_readers.py | 19 +++ 8 files changed, 393 insertions(+), 23 deletions(-) create mode 100644 api/tests/unit/parser/test_inn_subkonto.py diff --git a/README.md b/README.md index e35ec02..cbdb142 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,14 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо `COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`, `inn_kpp_cell`. +**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): в шапке варианта 2 элемент `<группировка>.ИНН` («субконто: Контрагенты, +Контрагенты.ИНН») — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true` +(`header.GroupSchema.positions` держит его позицию для классификатора). 1С выводит ИНН строкой под контрагентом, с теми же +суммами; `classify.glue_inn_attributes` отдаёт ИНН контрагенту (извлечение — как из колонки ИНН, R-24), строку убирает, +её детей поднимает на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». Пустая ячейка (физлицо без ИНН) — +склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или расходится) — строка `OTHER`, +предупреждение с причиной, документ `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`). + **Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала — нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает). diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 49bd328..4ce1629 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -7,7 +7,9 @@ счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка «Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0. 2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`, - нераспознанные колонки таблицы (`extra`). + нераспознанные колонки таблицы (`extra`); +3. `glue_inn_attributes` — строки реквизита «Контрагенты.ИНН» под контрагентом отдают ИНН ему и уходят из строк + (OQ-162). Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде — `ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе @@ -26,6 +28,7 @@ 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.rules import GroupType, Rules, first_match, normalize_text @@ -38,6 +41,7 @@ __all__ = [ "RowGroupType", "classify_rows", "collect_body_rows", + "glue_inn_attributes", ] # Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или @@ -77,6 +81,9 @@ 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 # --- Строки таблицы --- @@ -361,11 +368,13 @@ 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] + schema: Sequence[OsvGroup | InnAttribute] header: TableHeader sheet: Sheet doc_data_type: DataType | None @@ -373,10 +382,13 @@ class _Context: currency_codes: frozenset[str] -def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]: +def _schema_group(schema: Sequence[OsvGroup | InnAttribute], position: int) -> tuple[GroupType, str | None]: if position >= len(schema): return "OTHER", None - return schema[position].type, schema[position].name + group = schema[position] + if isinstance(group, InnAttribute): + return "OTHER", group.name + return group.type, group.name def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]: @@ -395,6 +407,11 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple 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 @@ -408,7 +425,7 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно rows: Sequence[BodyRow], levels: Levels, - schema: list[OsvGroup], + schema: Sequence[OsvGroup | InnAttribute], header: TableHeader, sheet: Sheet, doc_data_type: DataType | None, @@ -423,7 +440,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta - строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень группировки, а вид сумм родителя; позицию в схеме она не занимает; - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой — - OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН). + OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); + - позиция реквизита ИНН (`InnAttribute`, `GroupSchema.positions`) — OTHER с `is_inn_attribute` и ИНН ячейки. """ context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes)) extra_keys = _extra_keys(sheet, header, rows) @@ -453,6 +471,82 @@ 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 87e0c7e..ce89730 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -19,7 +19,15 @@ from src.parser.detect import TableHeader from src.parser.grid import Sheet from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset -__all__ = ["ACCOUNT_NOT_FOUND_WARNING", "DocHeader", "build_group_schema", "parse_doc_header"] +__all__ = [ + "ACCOUNT_NOT_FOUND_WARNING", + "DocHeader", + "GroupSchema", + "InnAttribute", + "build_group_levels", + "build_group_schema", + "parse_doc_header", +] Quarter = Literal["Q1", "Q2", "Q3", "Q4"] QUARTERS: tuple[Quarter, ...] = get_args(Quarter) @@ -31,6 +39,8 @@ _ACCOUNT_CODE = re.compile(r"\d[^\s,;]*") # Метка отделяется от значения двоеточием («Валюта БУ, НУ: RUB»); перечисление субконто — запятыми _LABEL_SEPARATOR = ":" _LIST_SEPARATOR = "," +# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН» +_ATTRIBUTE_SEPARATOR = "." # Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются _CODE_TRAILING = ".,;:" @@ -481,20 +491,69 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[Os return groups or [_group("ACCOUNT")] -def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> list[OsvGroup]: +@dataclass(frozen=True) +class InnAttribute: + """Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой + контрагента, на уровень глубже. Это не уровень аналитики: в `group_schema` контракта его нет.""" + + name: str + + +@dataclass(frozen=True) +class GroupSchema: + """Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`). + + `positions[i]` — группировка строки на глубине i (0 — ACCOUNT) или реквизит ИНН, который глубину занимает, + а группировкой не является. Без реквизитов `positions` совпадает с `groups`. + """ + + groups: list[OsvGroup] + positions: list[OsvGroup | InnAttribute] + variant: HeaderVariant + + +def _inn_owner(item: str, groups: list[OsvGroup], rules: Rules) -> int | 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 None + + +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)) + 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)) - return groups + slots.append(len(groups) - 1) + positions = [slot if isinstance(slot, InnAttribute) else groups[slot] for slot in slots] + return GroupSchema(groups, positions, 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 _variant2_groups(sheet, header, rules, items) def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], HeaderVariant]: - """Группировки в порядке вложенности (первая — ACCOUNT) и вариант шапки (1 — строки блока, 2 — «субконто: …»).""" - items = _subkonto_items(_head_cells(sheet, header), rules) - if items is None: - return _variant1_groups(sheet, header, rules), 1 - return _variant2_groups(sheet, header, rules, items), 2 + """Группировки контракта в порядке вложенности (первая — ACCOUNT) и вариант шапки.""" + schema = build_group_levels(sheet, header, rules) + return schema.groups, schema.variant diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 8b0bb46..c978de2 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -12,12 +12,12 @@ 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 +from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows, glue_inn_attributes 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_schema, parse_doc_header +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.rules import Rules @@ -141,25 +141,29 @@ class OsvParser: header = find_table_header(sheet, rules, deadline) step = "шапка документа" doc = parse_doc_header(sheet, header, rules) - schema, _ = build_group_schema(sheet, header, rules) + schema = build_group_levels(sheet, header, rules) deadline.check(f"лист «{sheet.name}»: строки таблицы") step = "строки таблицы" body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) step = "иерархия строк" levels = resolve_levels(body, total_row, deadline) step = "классификация строк" - classified = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules) + classified = classify_rows(body, levels, schema.positions, header, sheet, doc.data_type, total_row, rules) + classified, inn_warnings, inn_glued = glue_inn_attributes(classified) except InvalidFormatError as exc: raise _invalid_format(sheet, step, exc) from exc doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") checks, check_warnings = run_checks(classified, rules.checks) - document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks) - warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings] + 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] if not any(item.group_type != "TOTAL" for item in classified): warnings.append(NO_DATA_ROWS_WARNING) warnings.extend(check_warnings) - return document, unique(warnings), _is_ok(checks) + # Несклеенный реквизит «Контрагенты.ИНН» (OQ-162) — строка, которую нужно проверить глазами: needs_check + return document, unique(warnings), _is_ok(checks) and inn_glued # --- внутреннее --- diff --git a/api/src/parser/readers/_common.py b/api/src/parser/readers/_common.py index 4e457a3..8446fdb 100644 --- a/api/src/parser/readers/_common.py +++ b/api/src/parser/readers/_common.py @@ -18,7 +18,8 @@ def leading_spaces(text: str) -> int: def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | None: """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает). - Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); bool проверяется раньше int. + Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»); + bool проверяется раньше int. Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне хранения, а не только в аксессорах `Sheet`. Время без даты — текстом. """ @@ -26,6 +27,9 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N return None if isinstance(value, bool): return Cell(value, "bool", bold=bold, indent=indent) + if isinstance(value, float) and value.is_integer(): + # xls хранит любое число как float: «2370012036.0» — артефакт чтения, в файле целое (OQ-162) + value = int(value) if isinstance(value, int | float): return Cell(Decimal(str(value)), "number", bold=bold, indent=indent) if isinstance(value, datetime.date): # datetime.datetime — подкласс date diff --git a/api/tests/fixtures/test_osv_dkb.py b/api/tests/fixtures/test_osv_dkb.py index 421f75b..8184bfc 100644 --- a/api/tests/fixtures/test_osv_dkb.py +++ b/api/tests/fixtures/test_osv_dkb.py @@ -21,7 +21,8 @@ from src.parser import ParseOutcome, get_parser pytestmark = pytest.mark.fixtures -EXPECTED_FILES = 25 # пакет ДКБ 06.10.2026 без отчётов «Анализ счета»: 22 файла до порога снимка + 3 большие +# Пакет ДКБ 06.10.2026 без отчётов «Анализ счета» (25) + ОСВ 60 Новопетровской (#32): 23 до порога снимка + 3 большие +EXPECTED_FILES = 26 EXPECTED_WITHOUT_GOLDEN = 3 # Агрокомплекс 62, 76, 76 (скользящий): 9–14 МБ MAX_PARSE_SECONDS_SMALL = 5 MAX_PARSE_SECONDS_LARGE = 30 diff --git a/api/tests/unit/parser/test_inn_subkonto.py b/api/tests/unit/parser/test_inn_subkonto.py new file mode 100644 index 0000000..c5af3a2 --- /dev/null +++ b/api/tests/unit/parser/test_inn_subkonto.py @@ -0,0 +1,181 @@ +"""Реквизит субконто «Контрагенты.ИНН» строкой под контрагентом (OQ-162, issue #32; `05-domain-rules.md` §1.1, §1.3). + +1С с шапкой варианта 2 «субконто: Контрагенты, Контрагенты.ИНН» выводит ИНН отдельной строкой на уровень глубже +контрагента, с теми же суммами и числом в ячейке. Это реквизит контрагента, а не второй уровень контрагентов: +ИНН переходит к родителю, строка из `rows` уходит; не склеилось — строка OTHER и `needs_check`. +""" + +from decimal import Decimal + +import pytest +from gridbuilder import L, M, sheet_from_rows + +from src.contract.result import OsvDocument +from src.parser.deadline import Deadline +from src.parser.grid import Sheet +from src.parser.osv import OsvParser +from src.parser.rules import Rules, load_rules + +TIMEOUT_SECONDS = 60 +DETAIL = "Детализация по субсчетам, субконто: Контрагенты, Контрагенты.ИНН" + +Sums = tuple[object, object, object, object, object, object] +A: Sums = (None, None, 760000, 760000, None, None) +B: Sums = (None, None, 100000, 104500, None, 4500) +ROOT: Sums = (None, None, 860000, 864500, None, 4500) + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def _sheet(*rows: list[object], detail: str = DETAIL) -> Sheet: + head: list[list[object]] = [ + ["ООО Зерновая"], + ["Оборотно-сальдовая ведомость по счету 60"], + ["Период: 9 месяцев 2025 г."], + [detail], + ["Выводимые данные: сумма"], + [], + ["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M("Сальдо на конец периода", 2)], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ] + return sheet_from_rows([*head, *rows]) + + +def r(level: int, text: object, sums: Sums) -> list[object]: + return [L(level), text, *sums] + + +def _parse(sheet: Sheet, rules: Rules) -> tuple[OsvDocument, list[str], bool]: + return OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS)) + + +def _values(document: OsvDocument) -> list[str]: + return [row.row_value for row in document.rows] + + +def test_inn_attribute_is_not_a_group_and_owner_has_inn(rules: Rules) -> None: + sheet = _sheet(r(0, "60.01", A), r(1, "А2 БИЗНЕС ООО", A), r(2, 2370012036, A), [L(0), "Итого", *A]) + + document, _, _ = _parse(sheet, rules) + + assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [ + ("ACCOUNT", "Субконто", None), + ("COUNTERPARTY", "Контрагенты", True), + ] + + +def test_inn_row_is_glued_to_parent_counterparty(rules: Rules) -> None: + sheet = _sheet( + r(0, "60.01", ROOT), # 9 + r(1, "А2 БИЗНЕС ООО", A), # 10 + r(2, 2370012036, A), # 11 + r(1, "Аббазов Игорь Рафаилович", B), # 12 + r(2, Decimal("501604329883"), B), # 13 + [L(0), "Итого", *ROOT], + ) + + document, warnings, ok = _parse(sheet, rules) + + assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Аббазов Игорь Рафаилович", "Итого"] + counterparties = [row for row in document.rows if row.group_type == "COUNTERPARTY"] + fields = [(row.counterparty_name, row.counterparty_inn, row.level, row.parent_row_number) for row in counterparties] + assert fields == [ + ("А2 БИЗНЕС ООО", "2370012036", 1, 9), + ("Аббазов Игорь Рафаилович", "501604329883", 1, 9), + ] + assert ok + assert any("2370012036" in warning and "строке 10" in warning for warning in warnings), warnings + + +def test_inn_number_with_lost_leading_zero_is_restored(rules: Rules) -> None: + sheet = _sheet(r(0, "60.01", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), [L(0), "Итого", *A]) + + document, _, ok = _parse(sheet, rules) + + assert ok + assert [row.counterparty_inn for row in document.rows if row.group_type == "COUNTERPARTY"] == ["0105012345"] + + +def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None: + sheet = _sheet( + r(0, "60.01", A), # 9 + r(1, "А2 БИЗНЕС ООО", A), # 10 + r(2, 2370012036, A), # 11 + r(3, "Договор № 1", A), # 12 + [L(0), "Итого", *A], + detail="Детализация по субсчетам, субконто: Контрагенты, Контрагенты.ИНН, Договоры", + ) + + document, _, ok = _parse(sheet, rules) + + assert ok + assert [g.type for g in document.group_schema] == ["ACCOUNT", "COUNTERPARTY", "CONTRACT"] + contract = next(row for row in document.rows if row.row_value == "Договор № 1") + assert (contract.group_type, contract.level, contract.parent_row_number) == ("CONTRACT", 2, 10) + + +def test_inn_row_with_other_sums_is_not_glued_and_needs_check(rules: Rules) -> None: + sheet = _sheet(r(0, "60.01", A), r(1, "А2 БИЗНЕС ООО", A), r(2, 2370012036, B), [L(0), "Итого", *A]) + + document, warnings, ok = _parse(sheet, rules) + + inn_row = next(row for row in document.rows if row.row_value == "2370012036") + assert (inn_row.group_type, inn_row.counterparty_name, inn_row.counterparty_inn) == ("OTHER", None, None) + parent = next(row for row in document.rows if row.row_value == "А2 БИЗНЕС ООО") + assert parent.counterparty_inn is None + assert not ok + assert any("строка 11" in warning and "Контрагенты.ИНН" in warning for warning in warnings), warnings + + +def test_two_inn_rows_under_one_counterparty_are_not_glued(rules: Rules) -> None: + sheet = _sheet( + r(0, "60.01", ROOT), + r(1, "А2 БИЗНЕС ООО", ROOT), + r(2, 2370012036, A), + r(2, 7706808138, B), + [L(0), "Итого", *ROOT], + ) + + document, _, ok = _parse(sheet, rules) + + assert not ok + assert [row.group_type for row in document.rows if row.level == 2] == ["OTHER", "OTHER"] # noqa: PLR2004 + + +def test_inn_attribute_of_unknown_group_stays_a_group(rules: Rules) -> None: + sheet = _sheet( + r(0, "60.01", A), + r(1, "А2 БИЗНЕС ООО", A), + r(2, "Договор № 1", A), + [L(0), "Итого", *A], + detail="Детализация по субсчетам, субконто: Контрагенты, Договоры.ИНН", + ) + + document, _, _ = _parse(sheet, rules) + + assert [g.name for g in document.group_schema] == ["Субконто", "Контрагенты", "Договоры.ИНН"] + + +def test_empty_inn_row_is_glued_without_inn(rules: Rules) -> None: + # R-1: у физлица 1С выводит строку реквизита с пустой ячейкой ИНН — это «ИНН не указан», а не ошибка разбора + sheet = _sheet(r(0, "60.01", A), r(1, "Кривенков Алексей Анатольевич", A), r(2, None, A), [L(0), "Итого", *A]) + + document, warnings, ok = _parse(sheet, rules) + + assert ok + assert _values(document) == ["60.01", "Кривенков Алексей Анатольевич", "Итого"] + assert document.rows[1].counterparty_inn is None + assert any("строка 11" in warning and "пуст" in warning for warning in warnings), warnings + + +def test_unrecognised_inn_text_is_not_glued(rules: Rules) -> None: + sheet = _sheet(r(0, "60.01", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), [L(0), "Итого", *A]) + + document, warnings, ok = _parse(sheet, rules) + + assert not ok + assert "нерез." in _values(document) + assert any("не распознан" in warning for warning in warnings), warnings diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py index 4c689ea..2a65490 100644 --- a/api/tests/unit/parser/test_readers.py +++ b/api/tests/unit/parser/test_readers.py @@ -17,6 +17,7 @@ from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatErr from src.parser.grid import EMPTY_CELL, Cell, MergedRange from src.parser.readers import read_book from src.parser.readers import xlsb as xlsb_reader +from src.parser.readers._common import make_cell def _synthetic_xlsx() -> bytes: @@ -189,3 +190,21 @@ def test_xlsb_signature_is_read_by_pyxlsb_without_outline(monkeypatch: pytest.Mo def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None: with pytest.raises(UnreadableError): read_book(_zip_with("hello.txt"), "XLSX") + + +@pytest.mark.parametrize( + ("value", "expected_text"), + [ + (2370012036.0, "2370012036"), # xls хранит число как float: «.0» — артефакт чтения (OQ-162, #32) + (501604329883.0, "501604329883"), + (-15.0, "-15"), + (1234.56, "1234.56"), + (7706808138, "7706808138"), + ], +) +def test_integral_float_is_read_without_fraction(value: float, expected_text: str) -> None: + cell = make_cell(value) + + assert cell is not None + assert (cell.kind, cell.text) == ("number", expected_text) + assert cell.value == Decimal(str(value)) -- 2.47.2 From 02ad8be01863de9dd4e7db4734e9697ef3ffa44e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 12:03:10 +0400 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9E=D0=A1=D0=92:=20=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=B2=D0=B8=D0=B7=D0=B8=D1=82=20=C2=AB=D0=9A=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B.=D0=98=D0=9D=D0=9D?= =?UTF-8?q?=C2=BB=20=D0=B8=20=D0=B2=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=B0=201=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строка блока заголовка «Контрагенты.ИНН» под «Контрагенты» — тот же реквизит, что элемент списка субконто варианта 2: в group_schema не попадает, у владельца has_inn, строки склеиваются glue_inn_attributes. Общая часть вариантов — _attach_inn_attribute и _positions. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- api/src/parser/header.py | 54 ++++++++++++++-------- api/tests/unit/parser/test_inn_subkonto.py | 32 +++++++++++++ 3 files changed, 69 insertions(+), 21 deletions(-) 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" -- 2.47.2