ОСВ: реквизит «Контрагенты.ИНН» строкой под контрагентом — ИНН родителю, без «.0» (#32)
В шапке варианта 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 <noreply@anthropic.com>
This commit is contained in:
parent
18eea82d2e
commit
bb78f81094
@ -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: ядро такой документ к версии не привязывает).
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
# --- внутреннее ---
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
3
api/tests/fixtures/test_osv_dkb.py
vendored
3
api/tests/fixtures/test_osv_dkb.py
vendored
@ -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
|
||||
|
||||
181
api/tests/unit/parser/test_inn_subkonto.py
Normal file
181
api/tests/unit/parser/test_inn_subkonto.py
Normal file
@ -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
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user