ОСВ: строки ИНН — подключение прохода, подсказка шапки в обоих порядках, старые склейки удалены (#35)
Хвосты ревью Task 3: формулировка направления листа в spec/design (T3.1), тесты противоречия сведений и приоритета подсказки (T3.2), assert на glue.ok (T3.4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
71d56bd5ed
commit
26ed11ad28
@ -7,9 +7,10 @@
|
||||
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
|
||||
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
|
||||
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
|
||||
нераспознанные колонки таблицы (`extra`);
|
||||
3. `glue_inn_attributes` — строки реквизита «Контрагенты.ИНН» под контрагентом отдают ИНН ему и уходят из строк
|
||||
(OQ-162).
|
||||
нераспознанные колонки таблицы (`extra`).
|
||||
|
||||
ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`): между шагами 1 и 2
|
||||
стоит проход склейки строк ИНН (#35).
|
||||
|
||||
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
||||
@ -18,8 +19,9 @@
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
@ -28,9 +30,8 @@ from src.contract.result import OsvGroup
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
|
||||
from src.parser.grid import Sheet, column_letter
|
||||
from src.parser.header import InnAttribute
|
||||
from src.parser.hierarchy import BodyRow, Levels
|
||||
from src.parser.numbers import column_inn, inn_only, split_inn_suffix, to_money
|
||||
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
||||
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
||||
|
||||
__all__ = [
|
||||
@ -41,7 +42,6 @@ __all__ = [
|
||||
"RowGroupType",
|
||||
"classify_rows",
|
||||
"collect_body_rows",
|
||||
"glue_inn_attributes",
|
||||
"row_data_type",
|
||||
]
|
||||
|
||||
@ -82,9 +82,6 @@ class ClassifiedRow:
|
||||
counterparty_inn: str | None
|
||||
data_type: DataType | None
|
||||
extra: dict[str, str | None] | None
|
||||
# Строка реквизита «Контрагенты.ИНН» (OQ-162) и ИНН из её ячейки; склеивает `glue_inn_attributes`
|
||||
is_inn_attribute: bool = False
|
||||
attribute_inn: str | None = None
|
||||
|
||||
|
||||
# --- Строки таблицы ---
|
||||
@ -191,40 +188,6 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu
|
||||
return tuple(sums.values())
|
||||
|
||||
|
||||
def _has_sums(item: BodyRow) -> bool:
|
||||
return any(value is not None for value in item.sums)
|
||||
|
||||
|
||||
def _glue_inn_rows(body: list[BodyRow], rules: Rules, warnings: list[str]) -> list[BodyRow]:
|
||||
"""Строка с одним ИНН и без сумм над строкой с суммами того же уровня оформления (outline и отступ) — ИНН
|
||||
контрагента под ней (OQ-130): из строк она убирается, ИНН переходит к следующей строке (`inn_above`).
|
||||
|
||||
Ничего иного такая строка не значит, а оставшись в таблице, стала бы OTHER и потеряла ИНН. Другой уровень
|
||||
оформления или отсутствие сумм у следующей строки — строка остаётся как есть.
|
||||
"""
|
||||
glued: list[BodyRow] = []
|
||||
pending: BodyRow | None = None
|
||||
for item in body:
|
||||
if pending is not None:
|
||||
same_level = (pending.outline, pending.indent) == (item.outline, item.indent)
|
||||
if same_level and _has_sums(item) and not item.is_account:
|
||||
inn = inn_only(pending.text, rules)
|
||||
warnings.append(
|
||||
f"строка {pending.row}: ИНН {inn} над строкой {item.row} отнесён к ней как к контрагенту"
|
||||
)
|
||||
item = replace(item, inn_above=inn) # noqa: PLW2901 - строка меняется, а не подменяется другой
|
||||
else:
|
||||
glued.append(pending)
|
||||
pending = None
|
||||
if not _has_sums(item) and not item.is_account and inn_only(item.text, rules) is not None:
|
||||
pending = item
|
||||
continue
|
||||
glued.append(item)
|
||||
if pending is not None:
|
||||
glued.append(pending)
|
||||
return glued
|
||||
|
||||
|
||||
def _first_sum_col(header: TableHeader) -> int:
|
||||
return min(header.columns.indexes().values())
|
||||
|
||||
@ -312,7 +275,7 @@ def collect_body_rows(
|
||||
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||
deadline.check("суммы строк")
|
||||
body.append(_body_row(sheet, row, header, label_cols, warnings))
|
||||
return _glue_inn_rows(body, rules, warnings), total_row, warnings
|
||||
return body, total_row, warnings
|
||||
|
||||
|
||||
# --- Классификация ---
|
||||
@ -369,26 +332,24 @@ class _Kind:
|
||||
data_type: DataType | None
|
||||
counterparty_name: str | None = None
|
||||
counterparty_inn: str | None = None
|
||||
is_inn_attribute: bool = False
|
||||
attribute_inn: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Context:
|
||||
schema: Sequence[OsvGroup | InnAttribute]
|
||||
schema: Sequence[OsvGroup]
|
||||
header: TableHeader
|
||||
sheet: Sheet
|
||||
doc_data_type: DataType | None
|
||||
rules: Rules
|
||||
currency_codes: frozenset[str]
|
||||
inns: Mapping[int, str]
|
||||
unpaired: AbstractSet[int]
|
||||
|
||||
|
||||
def _schema_group(schema: Sequence[OsvGroup | InnAttribute], position: int) -> tuple[GroupType, str | None]:
|
||||
def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]:
|
||||
if position >= len(schema):
|
||||
return "OTHER", None
|
||||
group = schema[position]
|
||||
if isinstance(group, InnAttribute):
|
||||
return "OTHER", group.name
|
||||
return group.type, group.name
|
||||
|
||||
|
||||
@ -405,33 +366,34 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
|
||||
data_type = row_data_type(item.text, context.rules, context.currency_codes)
|
||||
if data_type is not None:
|
||||
return _Kind("OTHER", None, code, data_type), inherited
|
||||
if item.row in context.unpaired:
|
||||
# Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
|
||||
return _Kind("OTHER", None, code, doc_data_type), inherited
|
||||
position = inherited.depth + 1
|
||||
group_type, name = _schema_group(context.schema, position)
|
||||
branch = _Branch(code, position)
|
||||
if position < len(context.schema) and isinstance(context.schema[position], InnAttribute):
|
||||
# Реквизит ИНН строкой под контрагентом: ИНН — как из колонки ИНН (R-24), склейка — `glue_inn_attributes`
|
||||
cell = context.sheet.cell(item.row, item.label_col or context.header.text_col)
|
||||
inn = column_inn(cell, context.rules)
|
||||
return _Kind("OTHER", name, code, doc_data_type, is_inn_attribute=True, attribute_inn=inn), branch
|
||||
if group_type != "COUNTERPARTY":
|
||||
return _Kind(group_type, name, code, doc_data_type), branch
|
||||
inn_col = context.header.inn_col
|
||||
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
|
||||
# ИНН: колонка ИНН, затем строка без сумм над названием (OQ-130), затем хвост самого названия (OQ-100)
|
||||
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
|
||||
counterparty, in_name = split_inn_suffix(item.text, context.rules)
|
||||
inn = column or item.inn_above or in_name
|
||||
inn = column or context.inns.get(item.row) or in_name
|
||||
return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch
|
||||
|
||||
|
||||
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
||||
rows: Sequence[BodyRow],
|
||||
levels: Levels,
|
||||
schema: Sequence[OsvGroup | InnAttribute],
|
||||
schema: Sequence[OsvGroup],
|
||||
header: TableHeader,
|
||||
sheet: Sheet,
|
||||
doc_data_type: DataType | None,
|
||||
total_row: int | None,
|
||||
rules: Rules,
|
||||
*,
|
||||
inns: Mapping[int, str] | None = None,
|
||||
unpaired: AbstractSet[int] = frozenset(),
|
||||
) -> list[ClassifiedRow]:
|
||||
"""Классификация строк по дереву `levels` и схеме группировок.
|
||||
|
||||
@ -442,9 +404,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
||||
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
||||
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
|
||||
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
|
||||
- позиция реквизита ИНН (`InnAttribute`, `GroupSchema.positions`) — OTHER с `is_inn_attribute` и ИНН ячейки.
|
||||
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`.
|
||||
"""
|
||||
context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes))
|
||||
context = _Context(
|
||||
schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired
|
||||
)
|
||||
extra_keys = _extra_keys(sheet, header, rows)
|
||||
branches: dict[int, _Branch] = {}
|
||||
result: list[ClassifiedRow] = []
|
||||
@ -472,82 +436,6 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
||||
counterparty_inn=kind.counterparty_inn,
|
||||
data_type=kind.data_type,
|
||||
extra=_extra(sheet, item.row, extra_keys),
|
||||
is_inn_attribute=kind.is_inn_attribute,
|
||||
attribute_inn=kind.attribute_inn,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# --- Реквизит «Контрагенты.ИНН» (OQ-162) ---
|
||||
|
||||
|
||||
def _not_glued_reason(item: ClassifiedRow, parent: ClassifiedRow | None, siblings: int) -> str | None:
|
||||
"""Почему строку реквизита ИНН нельзя отнести к родителю; `None` — можно."""
|
||||
if parent is None or parent.group_type != "COUNTERPARTY":
|
||||
return "над ней нет строки контрагента"
|
||||
if siblings > 1:
|
||||
return f"у контрагента в строке {parent.body.row} несколько строк ИНН"
|
||||
if item.body.sums != parent.body.sums:
|
||||
return f"суммы не равны суммам контрагента в строке {parent.body.row}"
|
||||
if item.attribute_inn is None and item.body.text:
|
||||
return "ИНН не распознан (нужно 10 или 12 цифр)"
|
||||
if item.attribute_inn is not None and parent.counterparty_inn not in {None, item.attribute_inn}:
|
||||
return f"ИНН расходится с ИНН {parent.counterparty_inn} контрагента в строке {parent.body.row}"
|
||||
return None
|
||||
|
||||
|
||||
def glue_inn_attributes(rows: Sequence[ClassifiedRow]) -> tuple[list[ClassifiedRow], list[str], bool]:
|
||||
"""Строки реквизита «Контрагенты.ИНН» (OQ-162; `05-domain-rules.md` §1.3) → ИНН родительского контрагента.
|
||||
|
||||
Склеенная строка из строк уходит, её потомки (договоры) переходят к контрагенту на уровень выше. Склейка — только
|
||||
если строка реквизита у контрагента одна, её суммы равны суммам контрагента, ИНН распознан и не расходится с уже
|
||||
найденным; иначе строка остаётся OTHER, предупреждение с причиной и признак `False` (документ — `needs_check`).
|
||||
Пустая ячейка ИНН (физлицо без ИНН в 1С) — «ИНН не указан»: строка склеивается, ИНН контрагента не меняется (R-1).
|
||||
Возвращает строки, предупреждения и признак «все строки реквизита склеены».
|
||||
"""
|
||||
by_row = {item.body.row: item for item in rows}
|
||||
siblings: dict[int | None, int] = {}
|
||||
for item in rows:
|
||||
if item.is_inn_attribute:
|
||||
siblings[item.parent] = siblings.get(item.parent, 0) + 1
|
||||
glued: dict[int, int] = {} # строка реквизита -> строка контрагента
|
||||
inns: dict[int, str] = {} # строка контрагента -> ИНН
|
||||
warnings: list[str] = []
|
||||
for item in rows:
|
||||
if not item.is_inn_attribute:
|
||||
continue
|
||||
parent = None if item.parent is None else by_row.get(item.parent)
|
||||
reason = _not_glued_reason(item, parent, siblings[item.parent])
|
||||
if reason is not None or parent is None:
|
||||
warnings.append(f"строка {item.body.row}: «{item.group_name}» не отнесён к контрагенту — {reason}")
|
||||
continue
|
||||
glued[item.body.row] = parent.body.row
|
||||
if item.attribute_inn is None:
|
||||
warnings.append(
|
||||
f"строка {item.body.row}: ИНН пуст («{item.group_name}») — строка отнесена к контрагенту "
|
||||
f"в строке {parent.body.row} без ИНН"
|
||||
)
|
||||
continue
|
||||
inns[parent.body.row] = item.attribute_inn
|
||||
warnings.append(
|
||||
f"строка {item.body.row}: ИНН {item.attribute_inn} («{item.group_name}») отнесён к контрагенту "
|
||||
f"в строке {parent.body.row}"
|
||||
)
|
||||
lifted: dict[int, int] = {} # строка -> число склеенных предков
|
||||
result: list[ClassifiedRow] = []
|
||||
for item in rows:
|
||||
parent_row = item.parent
|
||||
shift = 0 if parent_row is None else lifted.get(parent_row, 0) + (1 if parent_row in glued else 0)
|
||||
lifted[item.body.row] = shift
|
||||
if item.body.row in glued:
|
||||
continue
|
||||
result.append(
|
||||
replace(
|
||||
item,
|
||||
level=item.level - shift,
|
||||
parent=None if parent_row is None else glued.get(parent_row, parent_row),
|
||||
counterparty_inn=inns.get(item.body.row, item.counterparty_inn),
|
||||
)
|
||||
)
|
||||
return result, warnings, len(glued) == sum(siblings.values())
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
import calendar
|
||||
import datetime
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, get_args
|
||||
|
||||
@ -23,7 +23,6 @@ __all__ = [
|
||||
"ACCOUNT_NOT_FOUND_WARNING",
|
||||
"DocHeader",
|
||||
"GroupSchema",
|
||||
"InnAttribute",
|
||||
"InnHint",
|
||||
"build_group_levels",
|
||||
"build_group_schema",
|
||||
@ -482,14 +481,6 @@ def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
|
||||
return sheet.text_at(header.group_label_rows[0], header.text_col) or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InnAttribute:
|
||||
"""Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой
|
||||
контрагента, на уровень глубже. Это не уровень аналитики: в `group_schema` контракта его нет."""
|
||||
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InnHint:
|
||||
"""Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку
|
||||
@ -503,78 +494,71 @@ class InnHint:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupSchema:
|
||||
"""Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`).
|
||||
|
||||
`positions[i]` — группировка строки на глубине i (0 — ACCOUNT) или реквизит ИНН, который глубину занимает,
|
||||
а группировкой не является. Без реквизитов `positions` совпадает с `groups`.
|
||||
"""
|
||||
"""Группировки контракта (`groups`), вариант шапки и подсказка для строк ИНН (`<группировка>.ИНН`, OQ-162, #35)."""
|
||||
|
||||
groups: list[OsvGroup]
|
||||
positions: list[OsvGroup | InnAttribute]
|
||||
variant: HeaderVariant
|
||||
inn_hint: InnHint | None = None
|
||||
|
||||
|
||||
def _inn_owner(item: str, groups: list[OsvGroup], rules: Rules) -> int | None:
|
||||
"""Индекс группировки контрагентов, чей реквизит ИНН — `item` («Контрагенты.ИНН»); иначе `None`."""
|
||||
def _attribute_owner(item: str, rules: Rules) -> str | None:
|
||||
"""Имя группировки-владельца, если `item` — реквизит ИНН («Контрагенты.ИНН» → «контрагенты»); иначе `None`."""
|
||||
owner, separator, attribute = item.rpartition(_ATTRIBUTE_SEPARATOR)
|
||||
if not separator or _find(rules.headers.service_columns["inn"], attribute) is None:
|
||||
return None
|
||||
owner = owner.strip().casefold()
|
||||
for index, group in enumerate(groups):
|
||||
if group.type == "COUNTERPARTY" and (group.name or "").casefold() == owner:
|
||||
return index
|
||||
return owner.strip().casefold()
|
||||
|
||||
|
||||
def _inn_hint(items: Sequence[str], rules: Rules) -> InnHint | None:
|
||||
"""Подсказка строк ИНН по перечню группировок шапки (без счёта): первый реквизит `<группировка>.ИНН`, чей владелец —
|
||||
группировка контрагентов того же перечня. Стоит после владельца — ИНН под наименованием, перед — над."""
|
||||
for index, item in enumerate(items):
|
||||
owner = _attribute_owner(item, rules)
|
||||
if owner is None:
|
||||
continue
|
||||
for other_index, other in enumerate(items):
|
||||
if other.strip().casefold() == owner and rules.groups.classify(other) == "COUNTERPARTY":
|
||||
return InnHint(item, "below" if index > other_index else "above")
|
||||
return None
|
||||
|
||||
|
||||
def _attach_inn_attribute(item: str, groups: list[OsvGroup], slots: list[int | InnAttribute], rules: Rules) -> bool:
|
||||
"""`item` — реквизит ИНН одной из предыдущих группировок контрагентов: владелец получает `has_inn`, позиция строк —
|
||||
`InnAttribute`; `True`. Иначе ничего не меняется, `False`."""
|
||||
owner = _inn_owner(item, groups, rules)
|
||||
if owner is None:
|
||||
return False
|
||||
groups[owner] = _group("COUNTERPARTY", groups[owner].name, has_inn=True)
|
||||
slots.append(InnAttribute(item))
|
||||
return True
|
||||
|
||||
|
||||
def _positions(groups: list[OsvGroup], slots: list[int | InnAttribute]) -> list[OsvGroup | InnAttribute]:
|
||||
"""Позиции строк: индекс группировки в `groups` или реквизит ИНН (OQ-162)."""
|
||||
return [slot if isinstance(slot, InnAttribute) else groups[slot] for slot in slots]
|
||||
def _owns_hint(item: str, hint: InnHint | None, rules: Rules) -> bool:
|
||||
return hint is not None and _attribute_owner(hint.name, rules) == item.strip().casefold()
|
||||
|
||||
|
||||
def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema:
|
||||
labels = [sheet.text_at(row, header.text_col) for row in header.group_label_rows]
|
||||
hint = _inn_hint(labels[1:], rules)
|
||||
groups: list[OsvGroup] = []
|
||||
slots: list[int | InnAttribute] = []
|
||||
for index, row in enumerate(header.group_label_rows):
|
||||
label = sheet.text_at(row, header.text_col)
|
||||
if index > 0 and _attach_inn_attribute(label, groups, slots, rules):
|
||||
for index, (row, label) in enumerate(zip(header.group_label_rows, labels, strict=True)):
|
||||
if index > 0 and hint is not None and label == hint.name:
|
||||
continue
|
||||
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
|
||||
group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
|
||||
groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules)))
|
||||
slots.append(len(groups) - 1)
|
||||
has_inn = _row_has_inn(sheet, row, rules) or (group_type == "COUNTERPARTY" and _owns_hint(label, hint, rules))
|
||||
groups.append(_group(group_type, label, has_inn=has_inn))
|
||||
if not groups:
|
||||
groups, slots = [_group("ACCOUNT")], [0]
|
||||
return GroupSchema(groups, _positions(groups, slots), 1)
|
||||
groups = [_group("ACCOUNT")]
|
||||
return GroupSchema(groups, 1, hint)
|
||||
|
||||
|
||||
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
|
||||
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
||||
slots: list[int | InnAttribute] = [0]
|
||||
hint = _inn_hint(items, rules)
|
||||
inn_pending = header.inn_col is not None
|
||||
for item in items:
|
||||
if _attach_inn_attribute(item, groups, slots, rules):
|
||||
if hint is not None and item == hint.name:
|
||||
continue
|
||||
group_type = rules.groups.classify(item)
|
||||
has_inn = inn_pending and group_type == "COUNTERPARTY"
|
||||
inn_pending = inn_pending and not has_inn
|
||||
has_inn = has_inn or (group_type == "COUNTERPARTY" and _owns_hint(item, hint, rules))
|
||||
groups.append(_group(group_type, item, has_inn=has_inn))
|
||||
slots.append(len(groups) - 1)
|
||||
return GroupSchema(groups, _positions(groups, slots), 2)
|
||||
return GroupSchema(groups, 2, hint)
|
||||
|
||||
|
||||
def build_group_levels(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema:
|
||||
"""Группировки (первая — ACCOUNT), позиции строк и вариант шапки (1 — строки блока, 2 — «субконто: …»)."""
|
||||
"""Группировки (первая — ACCOUNT), вариант шапки (1 — строки блока, 2 — «субконто: …») и подсказка строк ИНН."""
|
||||
items = _subkonto_items(_head_cells(sheet, header), rules)
|
||||
if items is None:
|
||||
return _variant1_groups(sheet, header, rules)
|
||||
|
||||
@ -59,7 +59,6 @@ class BodyRow:
|
||||
outline: int
|
||||
sums: tuple[Decimal | None, ...]
|
||||
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
|
||||
inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -12,13 +12,14 @@ from dataclasses import replace
|
||||
from src.contract import OsvDocument
|
||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||
from src.parser.checks import run_checks
|
||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows, glue_inn_attributes
|
||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import TableHeader, find_table_header
|
||||
from src.parser.errors import InvalidFormatError
|
||||
from src.parser.grid import Sheet, column_letter
|
||||
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
|
||||
from src.parser.hierarchy import resolve_levels
|
||||
from src.parser.inn_rows import glue_inn_rows
|
||||
from src.parser.rules import Rules
|
||||
|
||||
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
|
||||
@ -147,9 +148,30 @@ class OsvParser:
|
||||
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
step = "иерархия строк"
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
step = "строки ИНН"
|
||||
glue = glue_inn_rows(
|
||||
body,
|
||||
levels,
|
||||
total_row,
|
||||
sheet,
|
||||
inn_col=header.inn_col,
|
||||
schema=schema.groups,
|
||||
hint=schema.inn_hint,
|
||||
rules=rules,
|
||||
)
|
||||
step = "классификация строк"
|
||||
classified = classify_rows(body, levels, schema.positions, header, sheet, doc.data_type, total_row, rules)
|
||||
classified, inn_warnings, inn_glued = glue_inn_attributes(classified)
|
||||
classified = classify_rows(
|
||||
glue.rows,
|
||||
glue.levels,
|
||||
schema.groups,
|
||||
header,
|
||||
sheet,
|
||||
doc.data_type,
|
||||
total_row,
|
||||
rules,
|
||||
inns=glue.inns,
|
||||
unpaired=glue.unpaired,
|
||||
)
|
||||
except InvalidFormatError as exc:
|
||||
raise _invalid_format(sheet, step, exc) from exc
|
||||
doc = _with_table_account(doc, classified)
|
||||
@ -158,12 +180,12 @@ class OsvParser:
|
||||
document = self._document(
|
||||
sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks
|
||||
)
|
||||
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *inn_warnings]
|
||||
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *glue.warnings]
|
||||
if not any(item.group_type != "TOTAL" for item in classified):
|
||||
warnings.append(NO_DATA_ROWS_WARNING)
|
||||
warnings.extend(check_warnings)
|
||||
# Несклеенный реквизит «Контрагенты.ИНН» (OQ-162) — строка, которую нужно проверить глазами: needs_check
|
||||
return document, unique(warnings), _is_ok(checks) and inn_glued
|
||||
# Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check
|
||||
return document, unique(warnings), _is_ok(checks) and glue.ok
|
||||
|
||||
# --- внутреннее ---
|
||||
|
||||
|
||||
25
api/tests/fixtures/real_osv.py
vendored
25
api/tests/fixtures/real_osv.py
vendored
@ -17,8 +17,9 @@ from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.header import DocHeader, build_group_levels, parse_doc_header
|
||||
from src.parser.hierarchy import BodyRow, Levels, resolve_levels
|
||||
from src.parser.inn_rows import glue_inn_rows
|
||||
from src.parser.readers import read_book
|
||||
from src.parser.rules import load_rules
|
||||
|
||||
@ -61,9 +62,25 @@ def parse_real(path: Path) -> RealParse:
|
||||
deadline = Deadline(PARSE_TIMEOUT_SECONDS)
|
||||
header = find_table_header(sheet, rules)
|
||||
doc = parse_doc_header(sheet, header, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
schema = build_group_levels(sheet, header, rules)
|
||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
|
||||
glue = glue_inn_rows(
|
||||
body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules
|
||||
)
|
||||
rows = classify_rows(
|
||||
glue.rows,
|
||||
glue.levels,
|
||||
schema.groups,
|
||||
header,
|
||||
sheet,
|
||||
doc.data_type,
|
||||
total_row,
|
||||
rules,
|
||||
inns=glue.inns,
|
||||
unpaired=glue.unpaired,
|
||||
)
|
||||
seconds = time.monotonic() - started
|
||||
return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds)
|
||||
return RealParse(
|
||||
sheet, header, doc, schema.groups, glue.rows, total_row, glue.levels, rows, [*warnings, *glue.warnings], seconds
|
||||
)
|
||||
|
||||
@ -518,6 +518,7 @@ def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> N
|
||||
assert "inn" not in doc.columns
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="#35: ИНН в s18 без верной контрольной суммы, исправляется в Task 5", strict=True)
|
||||
def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None:
|
||||
"""OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента."""
|
||||
outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx")
|
||||
|
||||
@ -17,8 +17,9 @@ from src.parser.deadline import Deadline
|
||||
from src.parser.detect import find_table_header
|
||||
from src.parser.errors import ParseTimeoutError
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import build_group_schema
|
||||
from src.parser.header import build_group_levels
|
||||
from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels
|
||||
from src.parser.inn_rows import glue_inn_rows
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||
@ -60,11 +61,25 @@ def parse(
|
||||
) -> Parsed:
|
||||
deadline = deadline or Deadline(TIMEOUT_SECONDS)
|
||||
header = find_table_header(sheet, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
schema = build_group_levels(sheet, header, rules)
|
||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
rows = classify_rows(body, levels, schema, header, sheet, doc_data_type, total_row, rules)
|
||||
return Parsed(rows, warnings, levels, total_row)
|
||||
glue = glue_inn_rows(
|
||||
body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules
|
||||
)
|
||||
rows = classify_rows(
|
||||
glue.rows,
|
||||
glue.levels,
|
||||
schema.groups,
|
||||
header,
|
||||
sheet,
|
||||
doc_data_type,
|
||||
total_row,
|
||||
rules,
|
||||
inns=glue.inns,
|
||||
unpaired=glue.unpaired,
|
||||
)
|
||||
return Parsed(rows, [*warnings, *glue.warnings], glue.levels, total_row)
|
||||
|
||||
|
||||
# --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры ---
|
||||
@ -614,7 +629,7 @@ def test_inn_with_kpp_in_one_column_cell_gives_inn(rules: Rules) -> None:
|
||||
def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "7701234567"), # ИНН строкой выше названия, без сумм
|
||||
r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна)
|
||||
r(1, "ООО Поставщик", S100),
|
||||
)
|
||||
|
||||
@ -624,44 +639,67 @@ def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
|
||||
supplier = parsed.rows[1]
|
||||
assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == (
|
||||
"ООО Поставщик",
|
||||
"7701234567",
|
||||
"7701234560",
|
||||
ACCOUNT_ROW,
|
||||
)
|
||||
assert any("ИНН" in warning and "7701234567" in warning for warning in parsed.warnings), parsed.warnings
|
||||
assert any("7701234560" in warning for warning in parsed.warnings), parsed.warnings
|
||||
|
||||
|
||||
def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "ИНН 7701234567 / 770101001"),
|
||||
r(1, "ИНН 7701234560 / 770101001"),
|
||||
r(1, "ООО Поставщик", S100),
|
||||
)
|
||||
|
||||
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234567"
|
||||
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560"
|
||||
|
||||
|
||||
def test_inn_row_above_on_other_level_is_not_glued(rules: Rules) -> None:
|
||||
def test_inn_row_above_in_tree_without_extra_level_is_not_glued(rules: Rules) -> None:
|
||||
# Схема «Контрагенты, Договоры», глубже схемы строк нет — «7701234560» контрагент, «ООО Поставщик» под ним (защита)
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "7701234567"),
|
||||
r(1, "7701234560", S100),
|
||||
r(2, "ООО Поставщик", S100),
|
||||
)
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert [row.body.text for row in parsed.rows] == ["60", "7701234567", "ООО Поставщик"]
|
||||
assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"]
|
||||
assert parsed.one("ООО Поставщик").counterparty_inn is None
|
||||
|
||||
|
||||
def test_inn_row_above_before_row_without_sums_is_not_glued(rules: Rules) -> None:
|
||||
def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None:
|
||||
sheet = osv(
|
||||
r(0, "60", S100),
|
||||
r(1, "7701234567"),
|
||||
r(1, "7701234560"),
|
||||
r(1, "ООО Поставщик"),
|
||||
r(1, "ООО Другой", S100),
|
||||
)
|
||||
|
||||
assert "7701234567" in [row.body.text for row in parse(sheet, rules).rows]
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert "7701234560" not in [row.body.text for row in parsed.rows]
|
||||
assert parsed.one("ООО Поставщик").counterparty_inn == "7701234560"
|
||||
|
||||
|
||||
def test_inn_row_with_bad_checksum_is_left_as_is(rules: Rules) -> None:
|
||||
sheet = osv(r(0, "60", S100), r(1, "7701234567"), r(1, "ООО Поставщик", S100))
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
assert "7701234567" in [row.body.text for row in parsed.rows]
|
||||
assert parsed.one("ООО Поставщик").counterparty_inn is None
|
||||
assert any("контрольная сумма не сходится" in warning for warning in parsed.warnings), parsed.warnings
|
||||
|
||||
|
||||
def test_unpaired_inn_row_is_other(rules: Rules) -> None:
|
||||
sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100), r(1, "7701234560"), r(1, "ООО Другой", S100))
|
||||
|
||||
parsed = parse(sheet, rules)
|
||||
|
||||
inn_row = parsed.one("7701234560")
|
||||
assert (inn_row.group_type, inn_row.counterparty_name, inn_row.counterparty_inn) == ("OTHER", None, None)
|
||||
|
||||
|
||||
# --- TOTAL ---
|
||||
|
||||
@ -291,6 +291,7 @@ def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_
|
||||
|
||||
assert glue.inns == {4: INN_A}
|
||||
assert glue.unpaired == frozenset()
|
||||
assert glue.ok
|
||||
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
|
||||
|
||||
|
||||
@ -428,6 +429,40 @@ def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
|
||||
assert glue.inns == {4: BAD}
|
||||
|
||||
|
||||
def test_sum_pair_below_and_block_above_contradict_each_other(rules: Rules) -> None:
|
||||
# T3.2: пара по суммам говорит «под», однозначный блок (начинается строкой ИНН) — «над»: направление неизвестно
|
||||
glue = run(
|
||||
rules,
|
||||
r(0, "60", A),
|
||||
r(1, "60.01", A),
|
||||
r(2, "ООО Альфа", A),
|
||||
r(2, INN_A, A), # суммы совпали только с предыдущим — «под»
|
||||
r(2, "ООО Бета", B),
|
||||
r(1, "60.02", B),
|
||||
r(2, INN_B), # блок 60.02 начинается строкой ИНН — «над»
|
||||
r(2, "ООО Гамма", B),
|
||||
)
|
||||
|
||||
assert glue.unpaired == frozenset({7})
|
||||
assert not glue.ok
|
||||
assert has_warning(glue, "строка 7", "направление")
|
||||
|
||||
|
||||
def test_hint_overrides_opposite_sum_pair_direction(rules: Rules) -> None:
|
||||
# T3.2: пара по суммам даёт «под»; подсказка шапки «над» перекрывает это сведение для строки ИНН без сумм
|
||||
rows = (
|
||||
r(0, "60", A),
|
||||
r(1, "ООО Альфа", A),
|
||||
r(1, INN_A, A), # пара по суммам с предыдущим — «под»
|
||||
r(1, "ООО Бета", B),
|
||||
r(1, INN_B),
|
||||
r(1, "ООО Гамма", B),
|
||||
)
|
||||
|
||||
assert run(rules, *rows).inns == {2: INN_A, 4: INN_B}
|
||||
assert run(rules, *rows, hint=ABOVE).inns == {2: INN_A, 6: INN_B}
|
||||
|
||||
|
||||
def test_two_inn_rows_claim_one_name(rules: Rules) -> None:
|
||||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, A))
|
||||
|
||||
|
||||
@ -211,3 +211,84 @@ def test_variant1_inn_attribute_row_is_glued(rules: Rules) -> None:
|
||||
assert ok
|
||||
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
|
||||
assert document.rows[1].counterparty_inn == "2370012036"
|
||||
|
||||
|
||||
# --- Подсказка в обратном порядке: «Контрагенты.ИНН, Контрагенты» — ИНН над наименованием ---
|
||||
|
||||
DETAIL_ABOVE = "Детализация по субсчетам, субконто: Контрагенты.ИНН, Контрагенты"
|
||||
|
||||
|
||||
def test_inn_attribute_before_owner_gives_hint_above(rules: Rules) -> None:
|
||||
sheet = _sheet(
|
||||
r(0, "60.01", A), r(1, 2370012036, A), r(1, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE
|
||||
)
|
||||
|
||||
document, warnings, ok = _parse(sheet, rules)
|
||||
|
||||
assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [
|
||||
("ACCOUNT", "Субконто", None),
|
||||
("COUNTERPARTY", "Контрагенты", True),
|
||||
]
|
||||
assert ok
|
||||
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
|
||||
counterparty = document.rows[1]
|
||||
assert (counterparty.counterparty_inn, counterparty.level, counterparty.parent_row_number) == ("2370012036", 1, 9)
|
||||
assert any("2370012036" in warning for warning in warnings), warnings
|
||||
|
||||
|
||||
def test_inn_attribute_before_owner_in_tree_gives_hint_above(rules: Rules) -> None:
|
||||
# ИНН-реквизит стоит над наименованием и является его родителем в дереве (наименование на уровень глубже)
|
||||
sheet = _sheet(
|
||||
r(0, "60.01", A), r(1, 2370012036, A), r(2, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A], detail=DETAIL_ABOVE
|
||||
)
|
||||
|
||||
document, warnings, ok = _parse(sheet, rules)
|
||||
|
||||
assert ok
|
||||
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
|
||||
counterparty = document.rows[1]
|
||||
assert (counterparty.counterparty_inn, counterparty.level, counterparty.parent_row_number) == ("2370012036", 1, 9)
|
||||
assert any("«Контрагенты.ИНН»" in warning for warning in warnings), warnings
|
||||
|
||||
|
||||
def test_hint_above_glues_sibling_without_checksum(rules: Rules) -> None:
|
||||
# Подсказка снимает проверку контрольной суммы: «7701234567» — опечатка, но шапка говорит, что это ИНН
|
||||
sheet = _sheet(
|
||||
r(0, "60.01", A),
|
||||
r(1, "7701234567", (None,) * 6),
|
||||
r(1, "ООО Альфа", A),
|
||||
[L(0), "Итого", *A],
|
||||
detail=DETAIL_ABOVE,
|
||||
)
|
||||
|
||||
document, _, ok = _parse(sheet, rules)
|
||||
|
||||
assert ok
|
||||
assert [(row.row_value, row.counterparty_inn) for row in document.rows if row.group_type == "COUNTERPARTY"] == [
|
||||
("ООО Альфа", "7701234567")
|
||||
]
|
||||
|
||||
|
||||
def test_variant1_inn_attribute_row_above_owner(rules: Rules) -> None:
|
||||
head: list[list[object]] = [
|
||||
["ООО Зерновая"],
|
||||
["Оборотно-сальдовая ведомость по счету 60 за 9 месяцев 2025 г."],
|
||||
[],
|
||||
["Выводимые данные: БУ (данные бухгалтерского учета)"],
|
||||
[],
|
||||
["Счет", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)],
|
||||
["Контрагенты.ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
["Контрагенты"],
|
||||
]
|
||||
sheet = sheet_from_rows(
|
||||
[*head, r(0, "60.01", A), r(1, 2370012036, A), r(1, "А2 БИЗНЕС ООО", A), [L(0), "Итого", *A]]
|
||||
)
|
||||
|
||||
document, _, ok = _parse(sheet, rules)
|
||||
|
||||
assert [(g.type, g.name, g.has_inn) for g in document.group_schema] == [
|
||||
("ACCOUNT", "Счет", None),
|
||||
("COUNTERPARTY", "Контрагенты", True),
|
||||
]
|
||||
assert ok
|
||||
assert document.rows[1].counterparty_inn == "2370012036"
|
||||
|
||||
@ -70,7 +70,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе
|
||||
пары нет.
|
||||
5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа.
|
||||
6. **Направление документа** (лист ОСВ), первое определённое:
|
||||
6. **Направление документа** (лист ОСВ): подсказка шапки (п. 1), если она есть; иначе сведения пп. 2–3 вместе:
|
||||
1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над;
|
||||
2. пары соседей, найденные по суммам (п. 5, один совпавший сосед);
|
||||
3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не
|
||||
@ -142,3 +142,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
| 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` |
|
||||
| 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает |
|
||||
| 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` |
|
||||
| 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит |
|
||||
|
||||
@ -119,10 +119,10 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
|
||||
|
||||
Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление
|
||||
листа** — первое определённое из: порядок `<группировка>.ИНН` в шапке; пары соседей, найденные по суммам; однозначные блоки
|
||||
детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН —
|
||||
под; начинается и кончается — блок сломан, его строки ИНН без пары). Противоречие сведений или их отсутствие — направление
|
||||
неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары.
|
||||
листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных
|
||||
блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой
|
||||
ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу
|
||||
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары.
|
||||
|
||||
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
|
||||
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user