ОСВ: правки финального ревью строк ИНН — реквизит по схеме, подсказка «над», страховка владельца, родитель после склейки (#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:
Andrey Epifancev 2026-10-13 16:20:39 +04:00
parent 79ffe602f6
commit ad1a6ec10e
7 changed files with 229 additions and 33 deletions

View File

@ -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`): январь–конец квартала —

View File

@ -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)

View File

@ -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)

View File

@ -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, "шапка («")

View 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

View File

@ -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: тест на сочетание «расхождение с колонкой + противоречие подсказке» — одно предупреждение о расхождении | Недостижимый код не добавляется | Нет |

View File

@ -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 подгоняться.