ОСВ: правки финального ревью строк ИНН — реквизит по схеме, подсказка «над», страховка владельца, родитель после склейки (#35)
R-6 (design #22), R-7 (#23), R-8 (#24), R-9 (#25), R-10 (#26), H.1/H.2 (#27); словарь позиций соседей (T2.6/T3.3); spec/design/README: текст предупреждения, цена ошибки design #1, ссылки на решение владельца 10 (M-3, M-4, M-5, M-8). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
79ffe602f6
commit
ad1a6ec10e
13
README.md
13
README.md
@ -166,8 +166,9 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
||||
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
|
||||
|
||||
**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
|
||||
строки без сумм прямо над названием на том же уровне оформления (такая строка в `rows` не попадает, в
|
||||
`metrics.warnings` — «ИНН … над строкой … отнесён к ней»), затем из конца текста названия («Полиграф, 3528315710»,
|
||||
строки ИНН рядом с наименованием (любая из восьми раскладок, `inn_rows.py`, OQ-163: такая строка в `rows` не попадает, в
|
||||
`metrics.warnings` — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием»
|
||||
заменено), затем из конца текста названия («Полиграф, 3528315710»,
|
||||
«ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН
|
||||
отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк
|
||||
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
|
||||
@ -175,11 +176,13 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
||||
|
||||
**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты,
|
||||
Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true`
|
||||
(`header.GroupSchema.positions` держит его позицию для классификатора). подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает
|
||||
(владелец и направление — `GroupSchema.inn_hint`). Подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает
|
||||
проверку контрольной суммы. Склейку выполняет `inn_rows.py` (OQ-163): ИНН строки отдаётся наименованию (извлечение — как из
|
||||
колонки ИНН, R-24), строка убирается, её дети переходят на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …».
|
||||
Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или
|
||||
расходится, направление неизвестно) — строка `OTHER`, предупреждение с причиной, документ `needs_check`; раскладка, противоречащая
|
||||
Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан, направление неизвестно) — строка `OTHER`,
|
||||
предупреждение с причиной, документ `needs_check`; ИНН строки, расходящийся с ИНН колонки у наименования, не применяется —
|
||||
остаётся ИНН колонки, предупреждение, `needs_check`; строка из 10/12 цифр с неверной контрольной суммой (без подсказки) остаётся
|
||||
обычной строкой, «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка оставлена как есть», `needs_check`; раскладка, противоречащая
|
||||
подсказке шапки, склеивается с отдельным предупреждением и `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`).
|
||||
|
||||
**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала —
|
||||
|
||||
@ -21,7 +21,7 @@ from src.contract.result import OsvGroup
|
||||
from src.parser.classify import row_data_type
|
||||
from src.parser.grid import Cell, Sheet
|
||||
from src.parser.header import InnHint
|
||||
from src.parser.hierarchy import BodyRow, Levels, parents
|
||||
from src.parser.hierarchy import BodyRow, Levels
|
||||
from src.parser.numbers import column_inn, inn_checksum_ok, row_inn
|
||||
from src.parser.rules import Rules
|
||||
|
||||
@ -44,6 +44,7 @@ class InnGlue:
|
||||
unpaired: frozenset[int]
|
||||
warnings: list[str]
|
||||
ok: bool
|
||||
sources: dict[int, int] = field(default_factory=dict) # строка наименования -> строка ИНН, давшая ему ИНН
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -57,6 +58,10 @@ class _Tree:
|
||||
kind: dict[int, Kind]
|
||||
inn: dict[int, str | None] = field(default_factory=dict) # ИНН строки ИНН; None — пустой или не распознан
|
||||
lookalike: dict[int, str] = field(default_factory=dict) # 10/12 цифр с неверной контрольной суммой
|
||||
position: dict[int, int] = field(default_factory=dict) # строка -> индекс среди детей её родителя
|
||||
|
||||
def siblings(self, row: int) -> list[int]:
|
||||
return self.children.get(self.parent[row], [])
|
||||
|
||||
def kids(self, row: int) -> list[int]:
|
||||
"""Дети строки, кроме Вал./Кол. (они не уровень аналитики)."""
|
||||
@ -104,6 +109,8 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст
|
||||
for item in rows:
|
||||
children.setdefault(levels.parent[item.row], []).append(item.row)
|
||||
tree = _Tree(rows, {item.row: item for item in rows}, dict(levels.parent), children, {})
|
||||
for siblings in children.values():
|
||||
tree.position.update((row, index) for index, row in enumerate(siblings))
|
||||
for item in rows:
|
||||
tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree)
|
||||
if hint is not None and hint.direction == "below":
|
||||
@ -167,9 +174,10 @@ def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool:
|
||||
|
||||
def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool:
|
||||
"""Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка
|
||||
шапки решает; без неё — «под», если родитель по схеме — контрагент (design, решение без обсуждения №10)."""
|
||||
if hint is not None:
|
||||
return hint.direction == "below"
|
||||
«под» — родителя; иначе «под», если родитель по схеме — контрагент (design №10). Подсказка «над» против родителя-
|
||||
контрагента не уводит ИНН ребёнку-договору: пара — родитель, а противоречие подсвечивает H-2 (R-7, design №23)."""
|
||||
if hint is not None and hint.direction == "below":
|
||||
return True
|
||||
position = depth[parent]
|
||||
return position < len(schema) and schema[position].type == "COUNTERPARTY"
|
||||
|
||||
@ -184,20 +192,26 @@ def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGrou
|
||||
return position >= len(schema) or schema[position].type == "COUNTERPARTY"
|
||||
|
||||
|
||||
def _unrecognised_attributes(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None:
|
||||
def _unrecognised_attributes(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None:
|
||||
"""С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка
|
||||
реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design, решение без обсуждения №9)."""
|
||||
реквизита с нераспознанным ИНН («нерез.»): без пары, как в #32 (design №9). Кандидат — ребёнок только у
|
||||
наименования, которое по схеме контрагент (R-6, design №22); тогда ребёнок может иметь детей (договоры остаются
|
||||
договорами). Позиция вне схемы или схема без позиции — прежнее условие R-1 (design №14): ребёнок — лист."""
|
||||
if hint is None or hint.direction != "below":
|
||||
return
|
||||
depth = _depths(tree)
|
||||
for item in tree.rows:
|
||||
if tree.kind[item.row] != "name":
|
||||
continue
|
||||
position = depth[item.row]
|
||||
if position < len(schema) and schema[position].type != "COUNTERPARTY":
|
||||
continue
|
||||
kids = tree.kids(item.row)
|
||||
if len(kids) != 1:
|
||||
continue
|
||||
kid = tree.by_row[kids[0]]
|
||||
# ребёнок со своими детьми — подлинное наименование (у него своя строка ИНН), не реквизит (R-1, design #14)
|
||||
if tree.kind[kid.row] == "name" and not tree.kids(kid.row) and kid.text and _same_sums(kid, item):
|
||||
allowed = position < len(schema) or not tree.kids(kid.row)
|
||||
if tree.kind[kid.row] == "name" and allowed and kid.text and _same_sums(kid, item):
|
||||
tree.kind[kid.row] = "candidate"
|
||||
tree.inn[kid.row] = None
|
||||
pairs.reason[kid.row] = "ИНН не распознан (нужно 10 или 12 цифр)"
|
||||
@ -238,8 +252,8 @@ def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None,
|
||||
|
||||
def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]:
|
||||
"""Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование)."""
|
||||
siblings = tree.children.get(tree.parent[row], [])
|
||||
index = siblings.index(row)
|
||||
siblings = tree.siblings(row)
|
||||
index = tree.position[row]
|
||||
before = siblings[index - 1] if index > 0 else None
|
||||
after = siblings[index + 1] if index + 1 < len(siblings) else None
|
||||
return (
|
||||
@ -342,8 +356,8 @@ def _drop_conflicts(pairs: _Pairs) -> None:
|
||||
def _near_name(tree: _Tree, row: int) -> bool:
|
||||
"""Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование."""
|
||||
parent = tree.parent[row]
|
||||
siblings = tree.children.get(parent, [])
|
||||
index = siblings.index(row)
|
||||
siblings = tree.siblings(row)
|
||||
index = tree.position[row]
|
||||
near = [*siblings[max(index - 1, 0) : index], *siblings[index + 1 : index + 2], *tree.children.get(row, [])]
|
||||
if parent is not None:
|
||||
near.append(parent)
|
||||
@ -351,11 +365,14 @@ def _near_name(tree: _Tree, row: int) -> bool:
|
||||
|
||||
|
||||
def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]:
|
||||
"""Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень, родители пересчитываются."""
|
||||
"""Строки и дерево без `removed`: потомки убранной строки поднимаются на уровень; новый родитель — ближайший
|
||||
неудалённый предок по прежнему дереву, а не пересчёт по уровням (иначе пропуск уровней outline даёт чужого
|
||||
родителя; R-9, design №25)."""
|
||||
if not removed:
|
||||
return list(rows), levels
|
||||
lifted: dict[int, int] = {}
|
||||
by_row: dict[int, int] = {}
|
||||
parent_of: dict[int, int | None] = {}
|
||||
kept: list[BodyRow] = []
|
||||
for item in rows:
|
||||
parent = levels.parent[item.row]
|
||||
@ -364,8 +381,11 @@ def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tupl
|
||||
if item.row in removed:
|
||||
continue
|
||||
by_row[item.row] = levels.by_row[item.row] - lift
|
||||
while parent is not None and parent in removed:
|
||||
parent = levels.parent[parent]
|
||||
parent_of[item.row] = parent
|
||||
kept.append(item)
|
||||
return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy)
|
||||
return kept, Levels(by_row=by_row, parent=parent_of, strategy=levels.strategy)
|
||||
|
||||
|
||||
def _contradicts(row: int, partner: int, hint: InnHint) -> bool:
|
||||
@ -376,7 +396,7 @@ def _contradicts(row: int, partner: int, hint: InnHint) -> bool:
|
||||
|
||||
|
||||
def _contradiction(row: int, partner: int, inn: str, hint: InnHint) -> str:
|
||||
"""Склейка состоялась, но раскладка не та, что указана в шапке (design, решение 10): подсветить, проверка."""
|
||||
"""Склейка состоялась, но раскладка не та, что указана в шапке (решение владельца 10, H-2): подсветить, проверка."""
|
||||
where, expected = ("под", "над") if hint.direction == "above" else ("над", "под")
|
||||
return (
|
||||
f"строка {row}: ИНН {inn} стоит {where} наименованием в строке {partner}, а шапка («{hint.name}») "
|
||||
@ -397,6 +417,7 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из
|
||||
label = "" if hint is None else f" («{hint.name}»)"
|
||||
warnings: list[str] = []
|
||||
inns: dict[int, str] = {}
|
||||
sources: dict[int, int] = {}
|
||||
ok = not pairs.reason
|
||||
for item in rows:
|
||||
row = item.row
|
||||
@ -404,6 +425,8 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из
|
||||
if row in pairs.partner:
|
||||
partner = pairs.partner[row]
|
||||
if inn is None:
|
||||
# пустой кандидат бывает только «под, в дереве» и склеивается с родителем: конфликта с колонкой нет
|
||||
# (H.1 финального ревью: случай «расхождение с колонкой» для пустого ИНН недостижим)
|
||||
warnings.append(
|
||||
f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН"
|
||||
)
|
||||
@ -417,6 +440,7 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из
|
||||
)
|
||||
continue
|
||||
inns[partner] = inn
|
||||
sources[partner] = row
|
||||
warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}")
|
||||
if hint is not None and _contradicts(row, partner, hint):
|
||||
ok = False
|
||||
@ -431,7 +455,7 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из
|
||||
"оставлена как есть"
|
||||
)
|
||||
kept, kept_levels = _without(rows, levels, set(pairs.partner))
|
||||
return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok)
|
||||
return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok, sources)
|
||||
|
||||
|
||||
def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно
|
||||
@ -452,7 +476,7 @@ def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, ч
|
||||
"""
|
||||
tree = _build(rows, levels, total_row, sheet, hint, rules)
|
||||
pairs = _Pairs()
|
||||
_unrecognised_attributes(tree, hint, pairs)
|
||||
_unrecognised_attributes(tree, schema, hint, pairs)
|
||||
_pair_in_tree(tree, schema, hint, pairs)
|
||||
_pair_flat(tree, hint, pairs)
|
||||
_check_sums(tree, pairs)
|
||||
|
||||
@ -19,7 +19,7 @@ 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.inn_rows import InnGlue, glue_inn_rows
|
||||
from src.parser.rules import Rules
|
||||
|
||||
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
|
||||
@ -126,6 +126,20 @@ def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> Invalid
|
||||
return InvalidFormatError(f"лист «{sheet.name}», шаг «{step}»: {reason}")
|
||||
|
||||
|
||||
def _check_inn_owners(glue: InnGlue, classified: list[ClassifiedRow]) -> InnGlue:
|
||||
"""Страховка (R-8, design №24): ИНН склеен со строкой, которая по схеме не контрагент (подразделение, договор,
|
||||
OTHER) — ИНН пропадёт молча, поэтому предупреждение и `needs_check`."""
|
||||
types = {item.body.row: item.group_type for item in classified}
|
||||
extra = [
|
||||
f"строка {glue.sources[owner]}: ИНН {inn} отнесён к строке {owner}, но она не контрагент — проверьте"
|
||||
for owner, inn in glue.inns.items()
|
||||
if owner in glue.sources and types.get(owner) != "COUNTERPARTY"
|
||||
]
|
||||
if not extra:
|
||||
return glue
|
||||
return replace(glue, warnings=[*glue.warnings, *extra], ok=False)
|
||||
|
||||
|
||||
class OsvParser:
|
||||
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
|
||||
|
||||
@ -174,6 +188,7 @@ class OsvParser:
|
||||
)
|
||||
except InvalidFormatError as exc:
|
||||
raise _invalid_format(sheet, step, exc) from exc
|
||||
glue = _check_inn_owners(glue, classified)
|
||||
doc = _with_table_account(doc, classified)
|
||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||
checks, check_warnings = run_checks(classified, rules.checks)
|
||||
|
||||
@ -528,3 +528,97 @@ def test_hint_agreeing_with_layout_has_no_conflict_warning(rules: Rules) -> None
|
||||
for glue in (below, above, blank):
|
||||
assert glue.ok
|
||||
assert not has_warning(glue, "проверьте")
|
||||
|
||||
|
||||
# --- Финальное ревью: R-6 … R-10, T2.6 ---
|
||||
|
||||
SCHEMA_DEPARTMENT = [
|
||||
OsvGroup(type="ACCOUNT", name="Счет"),
|
||||
OsvGroup(type="DEPARTMENT", name="Подразделения"),
|
||||
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
|
||||
]
|
||||
|
||||
|
||||
def test_hint_below_unrecognised_attribute_with_contracts_under_it_is_other(rules: Rules) -> None:
|
||||
# R-6 (design #22): родитель — контрагент: «нерез.» с договором под ней — реквизит (OTHER), договор — договор
|
||||
glue = run(
|
||||
rules,
|
||||
r(0, "60", A),
|
||||
r(1, "ООО Нерезидент", A),
|
||||
r(2, "нерез.", A),
|
||||
r(3, "Договор 1", A),
|
||||
hint=BELOW,
|
||||
schema=SCHEMA_WITH_CONTRACTS,
|
||||
)
|
||||
|
||||
assert glue.unpaired == frozenset({3})
|
||||
assert glue.inns == {}
|
||||
assert not glue.ok
|
||||
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (3, 3) # дерево не тронуто: классификатор пропустит OTHER
|
||||
|
||||
|
||||
def test_hint_below_single_child_of_department_is_not_an_attribute(rules: Rules) -> None:
|
||||
# R-6 (design #22): родитель по схеме — подразделение, его единственный ребёнок-лист — контрагент, не реквизит
|
||||
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), hint=BELOW, schema=SCHEMA_DEPARTMENT)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), True)
|
||||
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
|
||||
|
||||
|
||||
def test_hint_below_inn_with_single_contract_of_same_sums_is_glued_without_check(rules: Rules) -> None:
|
||||
# R-6 (design #22): контрагент -> ИНН -> один договор с теми же суммами: ИНН у контрагента, договор остаётся
|
||||
glue = run(
|
||||
rules,
|
||||
r(0, "60", A),
|
||||
r(1, "ООО Альфа", A),
|
||||
r(2, INN_A, A),
|
||||
r(3, "Договор 1", A),
|
||||
hint=BELOW,
|
||||
schema=SCHEMA_WITH_CONTRACTS,
|
||||
)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), True)
|
||||
assert texts(glue) == ["60", "ООО Альфа", "Договор 1"]
|
||||
|
||||
|
||||
def test_hint_above_inn_with_contract_under_counterparty_stays_with_parent_and_is_flagged(rules: Rules) -> None:
|
||||
# R-7 (design #23): подсказка «над», контрагент -> ИНН -> договор: пара — родитель по схеме, H-2 подсвечивает
|
||||
glue = run(
|
||||
rules,
|
||||
r(0, "60", A),
|
||||
r(1, "ООО Альфа", A),
|
||||
r(2, INN_A, A),
|
||||
r(3, "Договор 1", A),
|
||||
hint=ABOVE,
|
||||
schema=SCHEMA_WITH_CONTRACTS,
|
||||
)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
|
||||
assert texts(glue) == ["60", "ООО Альфа", "Договор 1"]
|
||||
assert conflict_warning(glue, 3, 2, "под", "над")
|
||||
|
||||
|
||||
def test_levels_skipping_keep_nearest_surviving_ancestor(rules: Rules) -> None:
|
||||
# R-9 (design #25): outline с пропуском уровней — новый родитель ближайший неудалённый предок (строка ИНН убрана)
|
||||
glue = run(
|
||||
rules,
|
||||
r(0, "60", A),
|
||||
r(1, "Физлицо", A),
|
||||
r(3, "Договор", A),
|
||||
r(1, INN_A, A),
|
||||
r(3, "ООО Альфа", A),
|
||||
)
|
||||
|
||||
assert glue.inns == {5: INN_A}
|
||||
kept = {item.text: glue.levels.parent[item.row] for item in glue.rows}
|
||||
assert kept == {"60": None, "Физлицо": 1, "Договор": 2, "ООО Альфа": 1}
|
||||
|
||||
|
||||
def test_column_mismatch_plus_contradiction_gives_one_warning(rules: Rules) -> None:
|
||||
# H.2: расхождение с колонкой ИНН и противоречие подсказке — предупреждение только о расхождении
|
||||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, INN_B), r(2, INN_A, A), hint=ABOVE)
|
||||
|
||||
assert glue.inns == {}
|
||||
assert not glue.ok
|
||||
assert has_warning(glue, "расходится с ИНН", INN_B)
|
||||
assert not has_warning(glue, "шапка («")
|
||||
|
||||
47
api/tests/unit/parser/test_inn_rows_osv.py
Normal file
47
api/tests/unit/parser/test_inn_rows_osv.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Строки ИНН на уровне листа ОСВ: страховка после классификации (R-8, design №24; change osv-inn-rows-layouts, #35)."""
|
||||
|
||||
import pytest
|
||||
from gridbuilder import L, M, sheet_from_rows
|
||||
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.osv import OsvParser
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
TIMEOUT_SECONDS = 60
|
||||
DETAIL = "Детализация по субсчетам, субконто: Подразделения, Контрагенты"
|
||||
A = (None, None, 760000, 760000, None, None)
|
||||
INN_A = 7701234560
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) -> None:
|
||||
head: list[list[object]] = [
|
||||
["ООО Зерновая"],
|
||||
["Оборотно-сальдовая ведомость по счету 60"],
|
||||
["Период: 9 месяцев 2025 г."],
|
||||
[DETAIL],
|
||||
["Выводимые данные: сумма"],
|
||||
[],
|
||||
["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M("Сальдо на конец периода", 2)],
|
||||
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
]
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
*head,
|
||||
[L(0), "60.01", *A], # 9
|
||||
[L(1), INN_A, *A], # 10
|
||||
[L(1), "ООО Альфа", *A], # 11
|
||||
[L(0), "Итого", *A],
|
||||
]
|
||||
)
|
||||
|
||||
document, warnings, ok = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS))
|
||||
|
||||
assert [(row.row_value, row.group_type) for row in document.rows][1:2] == [("ООО Альфа", "DEPARTMENT")]
|
||||
assert not ok
|
||||
expected = f"строка 10: ИНН {INN_A} отнесён к строке 11, но она не контрагент — проверьте"
|
||||
assert expected in warnings, warnings
|
||||
@ -92,7 +92,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
(прежний порядок §1.3).
|
||||
9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к
|
||||
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки
|
||||
(пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение 10).
|
||||
(пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение владельца 10, H-2).
|
||||
10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32),
|
||||
предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`.
|
||||
|
||||
@ -134,7 +134,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
|
||||
| # | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | Файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия |
|
||||
| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | При ветке глубже схемы контрагент, названный ИНН, с одним договором отдаёт ИНН договору без `needs_check` (защита работает только без подсказки и только когда лист не глубже схемы); файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия |
|
||||
| 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка |
|
||||
| 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке |
|
||||
| 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки |
|
||||
@ -143,11 +143,11 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
| 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` |
|
||||
| 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста |
|
||||
| 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` |
|
||||
| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится |
|
||||
| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: подсказка «под» — родителю (дети — договоры); иначе — родителю, если он по схеме `COUNTERPARTY` (при подсказке «над» — с подсветкой H-2, R-7, design #23), иначе ребёнку («над») | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится |
|
||||
| 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда |
|
||||
| 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isascii() and isdigit()`, не `isdecimal()` (ревью T1.2) | `isdecimal()` пропускает не-ASCII десятичные цифры, а ИНН — только `[0-9]`; `isdigit()` без `isascii()` пропускает «²» | Нет: ИНН с не-ASCII цифрами не встречаются; такая строка даёт `False` и не становится ИНН |
|
||||
| 13 | 13.10.2026 | В `classify.py` локальная переменная `row_data_type` в `_classify_row` переименована в `data_type` (при публичной функции `row_data_type` иначе имя стало бы локальным и вызов упал с `UnboundLocalError`) | Переименование локальной переменной, поведение не меняется | Нет |
|
||||
| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` |
|
||||
| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве); уточнено R-6 (design #22) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` |
|
||||
| 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` |
|
||||
@ -155,3 +155,9 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
| 19 | 13.10.2026 | H-2: направление фактической раскладки определяется по номерам строк пары (строка пары выше строки ИНН — ИНН «под», ниже — «над»), одинаково для пар в дереве и соседей; проверка выполняется только при подсказке и только для склеенных пар с непустым ИНН и без расхождения с колонкой ИНН | Номер строки однозначно отражает порядок на листе при любом пути поиска пары; пары без ИНН, без пары и с колонкой-победителем уже подсвечены своими предупреждениями | Нет: предупреждение лишь добавляется к уже состоявшейся склейке; результат склейки не меняется |
|
||||
| 20 | 13.10.2026 | H-1: в s28 строка «7701234567» после классификации попадает в `rows` как контрагент (обычная строка по правилам классификатора) — тест фиксирует это «как есть» и проверяет её наличие среди контрагентов без ИНН | Спека: строка «классифицируется как обычно»; подгонки парсера нет | Нет: поведение соответствует ожиданию владельца |
|
||||
| 21 | 13.10.2026 | Task 7: в tasks.md добавлены пункты 1.5 (H-2) и 2.2 (s28, H-1) сверх брифа; число golden в CLAUDE.md пересчитано по факту (123: 72 + 23 + 28, ранее значилось 113 = 72 + 22 + 19) | Бриф писался до H-1/H-2; счёт — по `find` | Нет: только документация |
|
||||
| 22 | 13.10.2026 | R-6 / I-1 (design #22): `_unrecognised_attributes` — ребёнок-реквизит только у наименования, чья позиция по схеме COUNTERPARTY; при такой позиции условие «ребёнок — лист» снимается (строка «нерез.» с договорами → OTHER, договоры остаются договорами); позиция по схеме не COUNTERPARTY — ребёнок не реквизит; позиции нет — прежнее условие «лист» | Иначе (регрессия #32) единственный контрагент подразделения с теми же суммами получал «ИНН не распознан» | Контрагент с единственным договором тех же сумм при подсказке «под» без строки реквизита — `needs_check` (как в №9) |
|
||||
| 23 | 13.10.2026 | R-7 / I-2 (design #23): `_owned_by_parent`: подсказка «под» — родитель; иначе родитель, если по схеме COUNTERPARTY; подсказка «над» не уводит ИНН ребёнку-договору | По спеке пара — родитель; противоречие подсказке ловит H-2 (предупреждение, `needs_check`) вместо молчаливой потери ИНН | Подсказка «над» и реальный ИНН над контрагентом, у которого в схеме родитель — контрагент: ИНН уходит родителю с предупреждением, `needs_check` |
|
||||
| 24 | 13.10.2026 | R-8 / I-3 (design #24): страховка в `osv.py` после классификации: ИНН склеен со строкой, не ставшей COUNTERPARTY, — предупреждение «ИНН … отнесён к строке M, но она не контрагент — проверьте», `needs_check` | ИНН иначе пропадает молча (подразделение/договор не хранят ИНН) | Лишнее `needs_check` на редкой раскладке; склейка не отменяется |
|
||||
| 25 | 13.10.2026 | R-9 / M-1 (design #25): `_without` — новый родитель = ближайший неудалённый предок по прежнему `levels.parent`, а не пересчёт `parents()` по уровням | Пропуск уровней outline давал неверного родителя | Нет: уровни (`by_row`) считаются как прежде |
|
||||
| 26 | 13.10.2026 | R-10 / T2.5 (design #26): порядок `_drop_conflicts` после `_check_sums` оставлен; формулировка спеки: «две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары» | Строка, не прошедшая суммы, пары не претендует; сохранение порядка не меняет результаты golden | Две строки ИНН с одной парой, одна из которых не прошла суммы: вторая склеивается |
|
||||
| 27 | 13.10.2026 | H.1 финального ревью отклонено как недостижимое: пустой кандидат бывает только «под, в дереве» и всегда склеивается с родителем, расхождение с колонкой для него невозможно (комментарий в `_result`). H.2: тест на сочетание «расхождение с колонкой + противоречие подсказке» — одно предупреждение о расхождении | Недостижимый код не добавляется | Нет |
|
||||
|
||||
@ -101,12 +101,14 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр
|
||||
дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН
|
||||
(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН:
|
||||
она классифицируется как обычно, в `warnings` — «строка N похожа на ИНН …, контрольная сумма не сходится», документ —
|
||||
`needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
|
||||
она классифицируется как обычно, в `warnings` — «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка
|
||||
оставлена как есть», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
|
||||
«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол.,
|
||||
не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не
|
||||
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); таким ребёнком MUST быть только строка без детей
|
||||
(лист), у строки со своими детьми — подлинное наименование (design #14).
|
||||
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); такой ребёнок бывает только у наименования, которое
|
||||
по схеме — COUNTERPARTY (договоры под ним остаются договорами, design #22); при другой позиции по схеме (подразделение и
|
||||
т. д.) ребёнок — подлинное наименование; если позиции по схеме нет, ребёнком MUST быть только строка без детей (лист,
|
||||
design #14).
|
||||
|
||||
**Пара строки ИНН** (связь в дереве важнее соседства):
|
||||
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
|
||||
@ -114,7 +116,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16);
|
||||
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара —
|
||||
родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки
|
||||
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт);
|
||||
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт). Строка ИНН с детьми под
|
||||
наименованием при подсказке «над» и родителе-контрагенте по схеме остаётся у родителя (дети — договоры), а
|
||||
противоречие подсказке подсвечивается (design #23);
|
||||
- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению);
|
||||
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
|
||||
|
||||
@ -122,7 +126,7 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных
|
||||
блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой
|
||||
ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу
|
||||
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары.
|
||||
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары (design #26).
|
||||
|
||||
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
|
||||
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —
|
||||
@ -136,6 +140,9 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
(«<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся
|
||||
из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт.
|
||||
|
||||
**Страховка после классификации:** ИНН склеен со строкой, которая по схеме не стала `COUNTERPARTY`, — в `warnings` «строка N:
|
||||
ИНН … отнесён к строке M, но она не контрагент — проверьте», документ — `needs_check` (design #24).
|
||||
|
||||
**Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N:
|
||||
ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user