Merge pull request 'ОСВ: реквизит «Контрагенты.ИНН» строкой под контрагентом — ИНН родителю, без «.0» (#32)' (#33) from feat/32-osv-inn-subconto into main
Reviewed-on: #33
This commit is contained in:
commit
b342b54002
@ -172,6 +172,14 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
|
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
|
||||||
`inn_kpp_cell`.
|
`inn_kpp_cell`.
|
||||||
|
|
||||||
|
**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты,
|
||||||
|
Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `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`): январь–конец квартала —
|
**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала —
|
||||||
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
||||||
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
|
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
|
||||||
|
|||||||
@ -7,7 +7,9 @@
|
|||||||
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
|
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
|
||||||
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
|
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
|
||||||
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
|
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
|
||||||
нераспознанные колонки таблицы (`extra`).
|
нераспознанные колонки таблицы (`extra`);
|
||||||
|
3. `glue_inn_attributes` — строки реквизита «Контрагенты.ИНН» под контрагентом отдают ИНН ему и уходят из строк
|
||||||
|
(OQ-162).
|
||||||
|
|
||||||
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
||||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
||||||
@ -26,6 +28,7 @@ from src.contract.result import OsvGroup
|
|||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
|
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.grid import Sheet, column_letter
|
||||||
|
from src.parser.header import InnAttribute
|
||||||
from src.parser.hierarchy import BodyRow, Levels
|
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, inn_only, split_inn_suffix, to_money
|
||||||
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
||||||
@ -38,6 +41,7 @@ __all__ = [
|
|||||||
"RowGroupType",
|
"RowGroupType",
|
||||||
"classify_rows",
|
"classify_rows",
|
||||||
"collect_body_rows",
|
"collect_body_rows",
|
||||||
|
"glue_inn_attributes",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
||||||
@ -77,6 +81,9 @@ class ClassifiedRow:
|
|||||||
counterparty_inn: str | None
|
counterparty_inn: str | None
|
||||||
data_type: DataType | None
|
data_type: DataType | None
|
||||||
extra: dict[str, str | None] | 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
|
data_type: DataType | None
|
||||||
counterparty_name: str | None = None
|
counterparty_name: str | None = None
|
||||||
counterparty_inn: str | None = None
|
counterparty_inn: str | None = None
|
||||||
|
is_inn_attribute: bool = False
|
||||||
|
attribute_inn: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _Context:
|
class _Context:
|
||||||
schema: Sequence[OsvGroup]
|
schema: Sequence[OsvGroup | InnAttribute]
|
||||||
header: TableHeader
|
header: TableHeader
|
||||||
sheet: Sheet
|
sheet: Sheet
|
||||||
doc_data_type: DataType | None
|
doc_data_type: DataType | None
|
||||||
@ -373,10 +382,13 @@ class _Context:
|
|||||||
currency_codes: frozenset[str]
|
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):
|
if position >= len(schema):
|
||||||
return "OTHER", None
|
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]:
|
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
|
position = inherited.depth + 1
|
||||||
group_type, name = _schema_group(context.schema, position)
|
group_type, name = _schema_group(context.schema, position)
|
||||||
branch = _Branch(code, 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":
|
if group_type != "COUNTERPARTY":
|
||||||
return _Kind(group_type, name, code, doc_data_type), branch
|
return _Kind(group_type, name, code, doc_data_type), branch
|
||||||
inn_col = context.header.inn_col
|
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): всё, что нужно классификатору, приходит явно
|
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
||||||
rows: Sequence[BodyRow],
|
rows: Sequence[BodyRow],
|
||||||
levels: Levels,
|
levels: Levels,
|
||||||
schema: list[OsvGroup],
|
schema: Sequence[OsvGroup | InnAttribute],
|
||||||
header: TableHeader,
|
header: TableHeader,
|
||||||
sheet: Sheet,
|
sheet: Sheet,
|
||||||
doc_data_type: DataType | None,
|
doc_data_type: DataType | None,
|
||||||
@ -423,7 +440,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
|
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
|
||||||
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
||||||
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
|
- прочая строка — `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))
|
context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes))
|
||||||
extra_keys = _extra_keys(sheet, header, rows)
|
extra_keys = _extra_keys(sheet, header, rows)
|
||||||
@ -453,6 +471,82 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
counterparty_inn=kind.counterparty_inn,
|
counterparty_inn=kind.counterparty_inn,
|
||||||
data_type=kind.data_type,
|
data_type=kind.data_type,
|
||||||
extra=_extra(sheet, item.row, extra_keys),
|
extra=_extra(sheet, item.row, extra_keys),
|
||||||
|
is_inn_attribute=kind.is_inn_attribute,
|
||||||
|
attribute_inn=kind.attribute_inn,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
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.grid import Sheet
|
||||||
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset
|
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"]
|
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
||||||
QUARTERS: tuple[Quarter, ...] = get_args(Quarter)
|
QUARTERS: tuple[Quarter, ...] = get_args(Quarter)
|
||||||
@ -31,6 +39,8 @@ _ACCOUNT_CODE = re.compile(r"\d[^\s,;]*")
|
|||||||
# Метка отделяется от значения двоеточием («Валюта БУ, НУ: RUB»); перечисление субконто — запятыми
|
# Метка отделяется от значения двоеточием («Валюта БУ, НУ: RUB»); перечисление субконто — запятыми
|
||||||
_LABEL_SEPARATOR = ":"
|
_LABEL_SEPARATOR = ":"
|
||||||
_LIST_SEPARATOR = ","
|
_LIST_SEPARATOR = ","
|
||||||
|
# Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН»
|
||||||
|
_ATTRIBUTE_SEPARATOR = "."
|
||||||
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
|
||||||
_CODE_TRAILING = ".,;:"
|
_CODE_TRAILING = ".,;:"
|
||||||
|
|
||||||
@ -471,30 +481,95 @@ def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
|
|||||||
return sheet.text_at(header.group_label_rows[0], header.text_col) or 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]:
|
@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 _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] = []
|
groups: list[OsvGroup] = []
|
||||||
|
slots: list[int | InnAttribute] = []
|
||||||
for index, row in enumerate(header.group_label_rows):
|
for index, row in enumerate(header.group_label_rows):
|
||||||
label = sheet.text_at(row, header.text_col)
|
label = sheet.text_at(row, header.text_col)
|
||||||
|
if index > 0 and _attach_inn_attribute(label, groups, slots, rules):
|
||||||
|
continue
|
||||||
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
|
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
|
||||||
group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
|
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)))
|
groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules)))
|
||||||
return groups or [_group("ACCOUNT")]
|
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]) -> list[OsvGroup]:
|
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
|
||||||
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
||||||
|
slots: list[int | InnAttribute] = [0]
|
||||||
inn_pending = header.inn_col is not None
|
inn_pending = header.inn_col is not None
|
||||||
for item in items:
|
for item in items:
|
||||||
|
if _attach_inn_attribute(item, groups, slots, rules):
|
||||||
|
continue
|
||||||
group_type = rules.groups.classify(item)
|
group_type = rules.groups.classify(item)
|
||||||
has_inn = inn_pending and group_type == "COUNTERPARTY"
|
has_inn = inn_pending and group_type == "COUNTERPARTY"
|
||||||
inn_pending = inn_pending and not has_inn
|
inn_pending = inn_pending and not has_inn
|
||||||
groups.append(_group(group_type, item, has_inn=has_inn))
|
groups.append(_group(group_type, item, has_inn=has_inn))
|
||||||
return groups
|
slots.append(len(groups) - 1)
|
||||||
|
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:
|
||||||
|
return _variant1_groups(sheet, header, rules)
|
||||||
|
return _variant2_groups(sheet, header, rules, items)
|
||||||
|
|
||||||
|
|
||||||
def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], HeaderVariant]:
|
def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], HeaderVariant]:
|
||||||
"""Группировки в порядке вложенности (первая — ACCOUNT) и вариант шапки (1 — строки блока, 2 — «субконто: …»)."""
|
"""Группировки контракта в порядке вложенности (первая — ACCOUNT) и вариант шапки."""
|
||||||
items = _subkonto_items(_head_cells(sheet, header), rules)
|
schema = build_group_levels(sheet, header, rules)
|
||||||
if items is None:
|
return schema.groups, schema.variant
|
||||||
return _variant1_groups(sheet, header, rules), 1
|
|
||||||
return _variant2_groups(sheet, header, rules, items), 2
|
|
||||||
|
|||||||
@ -12,12 +12,12 @@ from dataclasses import replace
|
|||||||
from src.contract import OsvDocument
|
from src.contract import OsvDocument
|
||||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||||
from src.parser.checks import run_checks
|
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.deadline import Deadline
|
||||||
from src.parser.detect import TableHeader, find_table_header
|
from src.parser.detect import TableHeader, find_table_header
|
||||||
from src.parser.errors import InvalidFormatError
|
from src.parser.errors import InvalidFormatError
|
||||||
from src.parser.grid import Sheet, column_letter
|
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.hierarchy import resolve_levels
|
||||||
from src.parser.rules import Rules
|
from src.parser.rules import Rules
|
||||||
|
|
||||||
@ -141,25 +141,29 @@ class OsvParser:
|
|||||||
header = find_table_header(sheet, rules, deadline)
|
header = find_table_header(sheet, rules, deadline)
|
||||||
step = "шапка документа"
|
step = "шапка документа"
|
||||||
doc = parse_doc_header(sheet, header, rules)
|
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}»: строки таблицы")
|
deadline.check(f"лист «{sheet.name}»: строки таблицы")
|
||||||
step = "строки таблицы"
|
step = "строки таблицы"
|
||||||
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||||
step = "иерархия строк"
|
step = "иерархия строк"
|
||||||
levels = resolve_levels(body, total_row, deadline)
|
levels = resolve_levels(body, total_row, deadline)
|
||||||
step = "классификация строк"
|
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:
|
except InvalidFormatError as exc:
|
||||||
raise _invalid_format(sheet, step, exc) from exc
|
raise _invalid_format(sheet, step, exc) from exc
|
||||||
doc = _with_table_account(doc, classified)
|
doc = _with_table_account(doc, classified)
|
||||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||||
checks, check_warnings = run_checks(classified, rules.checks)
|
checks, check_warnings = run_checks(classified, rules.checks)
|
||||||
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
|
document = self._document(
|
||||||
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
|
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):
|
if not any(item.group_type != "TOTAL" for item in classified):
|
||||||
warnings.append(NO_DATA_ROWS_WARNING)
|
warnings.append(NO_DATA_ROWS_WARNING)
|
||||||
warnings.extend(check_warnings)
|
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:
|
def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | None:
|
||||||
"""Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает).
|
"""Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает).
|
||||||
|
|
||||||
Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); bool проверяется раньше int.
|
Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); целый float — как int (без «.0»);
|
||||||
|
bool проверяется раньше int.
|
||||||
Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне
|
Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне
|
||||||
хранения, а не только в аксессорах `Sheet`. Время без даты — текстом.
|
хранения, а не только в аксессорах `Sheet`. Время без даты — текстом.
|
||||||
"""
|
"""
|
||||||
@ -26,6 +27,9 @@ def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | N
|
|||||||
return None
|
return None
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return Cell(value, "bool", bold=bold, indent=indent)
|
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):
|
if isinstance(value, int | float):
|
||||||
return Cell(Decimal(str(value)), "number", bold=bold, indent=indent)
|
return Cell(Decimal(str(value)), "number", bold=bold, indent=indent)
|
||||||
if isinstance(value, datetime.date): # datetime.datetime — подкласс date
|
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
|
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 МБ
|
EXPECTED_WITHOUT_GOLDEN = 3 # Агрокомплекс 62, 76, 76 (скользящий): 9–14 МБ
|
||||||
MAX_PARSE_SECONDS_SMALL = 5
|
MAX_PARSE_SECONDS_SMALL = 5
|
||||||
MAX_PARSE_SECONDS_LARGE = 30
|
MAX_PARSE_SECONDS_LARGE = 30
|
||||||
|
|||||||
213
api/tests/unit/parser/test_inn_subkonto.py
Normal file
213
api/tests/unit/parser/test_inn_subkonto.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""Реквизит субконто «Контрагенты.ИНН» строкой под контрагентом (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
|
||||||
|
|
||||||
|
|
||||||
|
# --- Шапка варианта 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"
|
||||||
@ -17,6 +17,7 @@ from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatErr
|
|||||||
from src.parser.grid import EMPTY_CELL, Cell, MergedRange
|
from src.parser.grid import EMPTY_CELL, Cell, MergedRange
|
||||||
from src.parser.readers import read_book
|
from src.parser.readers import read_book
|
||||||
from src.parser.readers import xlsb as xlsb_reader
|
from src.parser.readers import xlsb as xlsb_reader
|
||||||
|
from src.parser.readers._common import make_cell
|
||||||
|
|
||||||
|
|
||||||
def _synthetic_xlsx() -> bytes:
|
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:
|
def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None:
|
||||||
with pytest.raises(UnreadableError):
|
with pytest.raises(UnreadableError):
|
||||||
read_book(_zip_with("hello.txt"), "XLSX")
|
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