ОСВ: строки ИНН рядом с контрагентом — восемь раскладок (#35, #34) #39

Merged
aep merged 12 commits from feat/35-inn-rows-around-counterparty into main 2026-10-13 16:24:46 +03:00
21 changed files with 4028 additions and 239 deletions

View File

@ -56,8 +56,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
- `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок
(`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре.
- `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка
и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `numbers.py` —
`Decimal` и ИНН; `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` —
и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `inn_rows.py` —
строки ИНН рядом с контрагентом (OQ-163); `numbers.py` — `Decimal` и ИНН (контрольная сумма); `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` —
оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла.
- `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера.
@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 113 файлов: 72 реальных + 22 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 19 синтетических) — регрессия, а не
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 123 файла: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 28 синтетических) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

@ -119,6 +119,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` |
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
| `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации |
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (91, 99) — вне тождества строки (`osv_checks.yaml`) |
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
@ -165,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`,
@ -174,11 +176,14 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты,
Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true`
(`header.GroupSchema.positions` держит его позицию для классификатора). 1С выводит ИНН строкой под контрагентом, с теми же
суммами; `classify.glue_inn_attributes` отдаёт ИНН контрагенту (извлечение — как из колонки ИНН, R-24), строку убирает,
её детей поднимает на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». Пустая ячейка (физлицо без ИНН) —
склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или расходится) — строка `OTHER`,
предупреждение с причиной, документ `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`).
(владелец и направление — `GroupSchema.inn_hint`). Подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает
проверку контрольной суммы. Склейку выполняет `inn_rows.py` (OQ-163): ИНН строки отдаётся наименованию (извлечение — как из
колонки ИНН, R-24), строка убирается, её дети переходят на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …».
Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан, направление неизвестно) — строка `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`): январь–конец квартала —
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`

View File

@ -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,7 @@ __all__ = [
"RowGroupType",
"classify_rows",
"collect_body_rows",
"glue_inn_attributes",
"row_data_type",
]
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
@ -81,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
# --- Строки таблицы ---
@ -190,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())
@ -311,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
# --- Классификация ---
@ -339,7 +303,7 @@ def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None
return values if any(value is not None for value in values.values()) else None
def _row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
def row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
"""CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
found = first_match(rules.data_types.row, text)
if found is not None:
@ -368,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
@ -401,36 +363,37 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
code = inherited.account_code_raw
row_data_type = _row_data_type(item.text, context.rules, context.currency_codes)
if row_data_type is not None:
return _Kind("OTHER", None, code, row_data_type), inherited
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` и схеме группировок.
@ -441,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] = []
@ -471,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())

View File

@ -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,7 @@ __all__ = [
"ACCOUNT_NOT_FOUND_WARNING",
"DocHeader",
"GroupSchema",
"InnAttribute",
"InnHint",
"build_group_levels",
"build_group_schema",
"parse_doc_header",
@ -482,87 +482,83 @@ def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
@dataclass(frozen=True)
class InnAttribute:
"""Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой
контрагента, на уровень глубже. Это не уровень аналитики: в `group_schema` контракта его нет."""
class InnHint:
"""Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку
элементов — после группировки-владельца ИНН стоит под наименованием (`below`), перед ней — над (`above`).
С подсказкой контрольная сумма ИНН строки не проверяется (`inn_rows`)."""
name: str
direction: Literal["above", "below"]
@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)

View File

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

484
api/src/parser/inn_rows.py Normal file
View File

@ -0,0 +1,484 @@
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`;
`05-domain-rules.md` §1.3; OQ-130, OQ-162, #35).
Один проход между каскадом иерархии и классификацией. Строка, где написан только ИНН, сопоставляется с наименованием
в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве
(сосед того же родителя) × с суммами / без сумм. Связь в дереве важнее соседства. Склеенная строка уходит из строк и
дерева (её дети — к наименованию), ИНН — наименованию; строка без пары остаётся (классификатор ставит ей OTHER), а
документ уходит на проверку. Классификатор получает чистое дерево.
Без подсказки шапки (`InnHint`) строка — строка ИНН только при верной контрольной сумме; строка из 10/12 цифр с
неверной суммой рядом с наименованием — «похожа на ИНН»: остаётся как есть, предупреждение, `needs_check`.
"""
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal
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
from src.parser.numbers import column_inn, inn_checksum_ok, row_inn
from src.parser.rules import Rules
__all__ = ["InnGlue", "glue_inn_rows"]
Kind = Literal["total", "account", "indicator", "candidate", "name"]
Direction = Literal["above", "below"] # направление листа: строка ИНН над наименованием или под ним
_ZERO = Decimal(0)
_UNKNOWN_DIRECTION = "направление строк ИНН в листе не определено"
@dataclass(frozen=True)
class InnGlue:
"""Результат склейки: строки и дерево без склеенных строк ИНН, ИНН по строкам наименований, строки ИНН без пары
(классификатор ставит им OTHER), предупреждения и признак «всё склеено, похожих на ИНН строк и расхождений нет»."""
rows: list[BodyRow]
levels: Levels
inns: dict[int, str]
unpaired: frozenset[int]
warnings: list[str]
ok: bool
sources: dict[int, int] = field(default_factory=dict) # строка наименования -> строка ИНН, давшая ему ИНН
@dataclass
class _Tree:
"""Строки листа, их дерево и вид: счёт, «Итого», Вал./Кол., строка ИНН или наименование."""
rows: Sequence[BodyRow]
by_row: dict[int, BodyRow]
parent: dict[int, int | None]
children: dict[int | None, list[int]]
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]:
"""Дети строки, кроме Вал./Кол. (они не уровень аналитики)."""
return [kid for kid in self.children.get(row, []) if self.kind[kid] != "indicator"]
def candidates(self, done: "_Pairs") -> list[int]:
return [item.row for item in self.rows if self.kind[item.row] == "candidate" and not done.decided(item.row)]
@dataclass
class _Pairs:
partner: dict[int, int] = field(default_factory=dict) # строка ИНН -> строка наименования
reason: dict[int, str] = field(default_factory=dict) # строка ИНН без пары -> причина
flat: list[int] = field(default_factory=list) # строки ИНН не в дереве (соседи), по порядку строк
def decided(self, row: int) -> bool:
return row in self.partner or row in self.reason
def _label_cell(sheet: Sheet, item: BodyRow) -> Cell:
if item.label_col is None:
return Cell(item.text or None, "text" if item.text else "empty")
return sheet.cell(*sheet.merged_origin(item.row, item.label_col))
def _has_sums(item: BodyRow) -> bool:
return any(value is not None for value in item.sums)
def _same_sums(left: BodyRow, right: BodyRow) -> bool:
"""Шесть сумм равны; пустая ячейка = 0 (в ОСВ 1С это одно и то же)."""
return [value or _ZERO for value in left.sums] == [value or _ZERO for value in right.sums]
def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида строки приходит явно
rows: Sequence[BodyRow],
levels: Levels,
total_row: int | None,
sheet: Sheet,
hint: InnHint | None,
rules: Rules,
) -> _Tree:
currency = frozenset(rules.data_types.currency_codes)
children: dict[int | None, list[int]] = {}
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":
# «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения
for item in rows:
parent = tree.parent[item.row]
if tree.kind[item.row] == "name" and not item.text and parent is not None and tree.kind[parent] == "name":
tree.kind[item.row] = "candidate"
tree.inn[item.row] = None
return tree
def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от всех этих данных
item: BodyRow,
total_row: int | None,
sheet: Sheet,
hint: InnHint | None,
rules: Rules,
currency: frozenset[str],
tree: _Tree,
) -> Kind:
if total_row is not None and item.row >= total_row:
return "total"
if item.is_account:
return "account"
if row_data_type(item.text, rules, currency) is not None:
return "indicator"
found = row_inn(_label_cell(sheet, item), rules)
if found is None:
return "name"
if hint is None and not inn_checksum_ok(found):
tree.lookalike[item.row] = found
return "name"
tree.inn[item.row] = found
return "candidate"
def _depths(tree: _Tree) -> dict[int, int]:
"""Позиция строки в схеме группировок, как у классификатора: счёт и «Итого» — 0, Вал./Кол. — позиция родителя,
прочие — позиция родителя + 1 (строки ИНН считаются уровнем: склейки ещё не было)."""
depth: dict[int, int] = {}
for item in tree.rows:
parent = tree.parent[item.row]
base = 0 if parent is None else depth.get(parent, 0)
kind = tree.kind[item.row]
if kind in {"account", "total"}:
depth[item.row] = 0
elif kind == "indicator":
depth[item.row] = base
else:
depth[item.row] = base + 1
return depth
def _no_extra_level(depth: dict[int, int], schema: Sequence[OsvGroup]) -> bool:
"""Защита «над, в дереве»: схема задаёт группировки и ни одна строка листа не стоит глубже схемы — лишнего уровня
нет, строка ИНН с детьми — наименование по схеме (контрагент, названный ИНН, с одним договором)."""
deepest = len(schema) - 1
return deepest >= 1 and all(value <= deepest for value in depth.values())
def _owned_by_parent(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool:
"""Строка ИНН с детьми под наименованием: ИНН родителя («под», дети — договоры) или ребёнка («над»)? Подсказка
«под» — родителя; иначе «под», если родитель по схеме — контрагент (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"
def _parent_can_own(parent: int, depth: dict[int, int], schema: Sequence[OsvGroup], hint: InnHint | None) -> bool:
"""Строка ИНН без детей под наименованием-родителем: родитель — пара, если подсказка «под», или родитель по схеме —
контрагент, или схема не позволяет сказать (design #15). Иначе строка — сосед наименования
контрагента под подразделением (R-2, design #15)."""
if hint is not None and hint.direction == "below":
return True
position = depth[parent]
return position >= len(schema) or schema[position].type == "COUNTERPARTY"
def _unrecognised_attributes(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None:
"""С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — строка
реквизита с нераспознанным ИНН («нерез.»): без пары, как в #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]]
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 цифр)"
def _pair_in_tree(tree: _Tree, schema: Sequence[OsvGroup], hint: InnHint | None, pairs: _Pairs) -> None:
"""Ребёнок наименования — ИНН под ним (дети строки ИНН, договоры, переходят к наименованию); строка ИНН с детьми —
над единственным ребёнком; иначе — сосед (`pairs.flat`). С детьми и под наименованием — `_owned_by_parent`."""
depth = _depths(tree)
# подсказка в шапке — прямое свидетельство лишнего уровня: защита только без неё (R-3, design #16)
no_extra_level = hint is None and _no_extra_level(depth, schema)
for row in tree.candidates(pairs):
kids = tree.kids(row)
parent = tree.parent[row]
below = parent is not None and tree.kind[parent] == "name"
if below and parent is not None:
below = (
_owned_by_parent(parent, depth, schema, hint) if kids else _parent_can_own(parent, depth, schema, hint)
)
if below and parent is not None:
rivals = [kid for kid in tree.children.get(parent, []) if tree.kind[kid] == "candidate"]
if len(rivals) > 1:
pairs.reason[row] = f"у контрагента в строке {parent} несколько строк ИНН"
else:
pairs.partner[row] = parent
elif kids:
if no_extra_level:
tree.kind[row] = "name"
elif len(kids) > 1:
pairs.reason[row] = f"под ней несколько строк ({len(kids)}), а не одно наименование"
elif tree.kind[kids[0]] != "name":
pairs.reason[row] = "под ней не наименование"
else:
pairs.partner[row] = kids[0]
else:
pairs.flat.append(row)
def _neighbours(tree: _Tree, row: int) -> tuple[int | None, int | None]:
"""Соседние строки-наименования того же родителя: выше и ниже (`None` — соседа нет или он не наименование)."""
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 (
before if before is not None and tree.kind[before] == "name" else None,
after if after is not None and tree.kind[after] == "name" else None,
)
def _block_direction(tree: _Tree, pending: list[int], pairs: _Pairs) -> set[Direction]:
"""Сведения о направлении из блоков: дети одного родителя — несопоставленные строки ИНН и наименования.
Начинается строкой ИНН — над (иначе первой строке ИНН не досталось бы владельца), кончается — под; начинается и
кончается — блок сломан, его строки ИНН без пары; ни то ни другое — сведений нет.
"""
waiting = set(pending)
claimed = set(pairs.partner.values())
found: set[Direction] = set()
for parent in dict.fromkeys(tree.parent[row] for row in pending):
block = [
row
for row in tree.children.get(parent, [])
if row in waiting or (tree.kind[row] == "name" and row not in claimed)
]
starts, ends = block[0] in waiting, block[-1] in waiting
if starts and ends:
for row in block:
if row in waiting:
pairs.reason[row] = "блок строк начинается и заканчивается строкой ИНН — направление не определить"
elif starts:
found.add("above")
elif ends:
found.add("below")
return found
def _pair_flat(tree: _Tree, hint: InnHint | None, pairs: _Pairs) -> None:
"""Строки ИНН не в дереве: с суммами — сосед с теми же суммами; без сумм (или совпали оба соседа) — сосед по
направлению листа: подсказка шапки, иначе единогласные сведения пар по суммам и блоков."""
pending: list[int] = []
evidence: set[Direction] = set()
for row in pairs.flat:
item = tree.by_row[row]
if not _has_sums(item):
pending.append(row)
continue
before, after = _neighbours(tree, row)
sides: list[tuple[int | None, Direction]] = [(before, "below"), (after, "above")]
matches = [(other, side) for other, side in sides if other is not None and _same_sums(tree.by_row[other], item)]
if len(matches) == 1:
other, side = matches[0]
pairs.partner[row] = other
evidence.add(side)
elif matches:
pending.append(row)
else:
pairs.reason[row] = "суммы не совпали ни с одним соседним наименованием"
if pending:
_pair_by_direction(tree, pending, hint, evidence, pairs)
def _pair_by_direction(
tree: _Tree, pending: list[int], hint: InnHint | None, evidence: set[Direction], pairs: _Pairs
) -> None:
"""Строки ИНН без сумм (и с неоднозначными соседями по суммам) — сосед по направлению листа."""
if hint is not None:
direction: Direction | None = hint.direction
else:
evidence |= _block_direction(tree, pending, pairs)
direction = next(iter(evidence)) if len(evidence) == 1 else None
for row in pending:
if row in pairs.reason:
continue
if direction is None:
pairs.reason[row] = _UNKNOWN_DIRECTION
continue
before, after = _neighbours(tree, row)
other = after if direction == "above" else before
if other is None:
where = "под" if direction == "above" else "над"
pairs.reason[row] = f"{where} строкой ИНН нет наименования"
else:
pairs.partner[row] = other
def _check_sums(tree: _Tree, pairs: _Pairs) -> None:
for row, partner in list(pairs.partner.items()):
if _has_sums(tree.by_row[row]) and not _same_sums(tree.by_row[row], tree.by_row[partner]):
del pairs.partner[row]
pairs.reason[row] = f"суммы не равны суммам строки {partner}"
def _drop_conflicts(pairs: _Pairs) -> None:
claimed = Counter(pairs.partner.values())
for row, partner in list(pairs.partner.items()):
if claimed[partner] > 1:
del pairs.partner[row]
pairs.reason[row] = f"на строку {partner} претендуют несколько строк ИНН"
def _near_name(tree: _Tree, row: int) -> bool:
"""Рядом с наименованием: родитель, ребёнок или соседняя строка того же родителя — наименование."""
parent = tree.parent[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)
return any(tree.kind[other] == "name" for other in near)
def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tuple[list[BodyRow], Levels]:
"""Строки и дерево без `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]
lift = 0 if parent is None else lifted[parent] + (1 if parent in removed else 0)
lifted[item.row] = lift
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=parent_of, strategy=levels.strategy)
def _contradicts(row: int, partner: int, hint: InnHint) -> bool:
"""Фактическая раскладка противоречит подсказке шапки: ИНН стоит под наименованием (строка пары выше) при
подсказке «над» или над ним (строка пары ниже) при подсказке «под». Годится для пар в дереве и для соседей."""
actual: Direction = "below" if partner < row else "above"
return actual != hint.direction
def _contradiction(row: int, partner: int, inn: str, hint: InnHint) -> str:
"""Склейка состоялась, но раскладка не та, что указана в шапке (решение владельца 10, H-2): подсветить, проверка."""
where, expected = ("под", "над") if hint.direction == "above" else ("над", "под")
return (
f"строка {row}: ИНН {inn} стоит {where} наименованием в строке {partner}, а шапка («{hint.name}») "
f"указывает {expected} — проверьте"
)
def _result( # noqa: PLR0913, PLR0917 - сборка результата из всех частей прохода
rows: Sequence[BodyRow],
levels: Levels,
tree: _Tree,
pairs: _Pairs,
sheet: Sheet,
inn_col: int | None,
hint: InnHint | None,
rules: Rules,
) -> InnGlue:
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
inn = tree.inn.get(row)
if row in pairs.partner:
partner = pairs.partner[row]
if inn is None:
# пустой кандидат бывает только «под, в дереве» и склеивается с родителем: конфликта с колонкой нет
# (H.1 финального ревью: случай «расхождение с колонкой» для пустого ИНН недостижим)
warnings.append(
f"строка {row}: ИНН пуст{label} — строка отнесена к контрагенту в строке {partner} без ИНН"
)
continue
column = None if inn_col is None else column_inn(sheet.cell(partner, inn_col), rules)
if column is not None and column != inn:
ok = False
warnings.append(
f"строка {row}: ИНН {inn}{label} расходится с ИНН {column} колонки ИНН контрагента в строке "
f"{partner} — оставлен ИНН колонки"
)
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
warnings.append(_contradiction(row, partner, inn, hint))
elif row in pairs.reason:
shown = "" if inn is None else f" {inn}"
warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}")
elif row in tree.lookalike and tree.kind[row] == "name" and _near_name(tree, row):
ok = False
warnings.append(
f"строка {row}: похожа на ИНН {tree.lookalike[row]}, но контрольная сумма не сходится — строка "
"оставлена как есть"
)
kept, kept_levels = _without(rows, levels, set(pairs.partner))
return InnGlue(kept, kept_levels, inns, frozenset(pairs.reason), warnings, ok, sources)
def glue_inn_rows( # noqa: PLR0913 - интерфейс плана: всё, что нужно проходу, приходит явно
rows: Sequence[BodyRow],
levels: Levels,
total_row: int | None,
sheet: Sheet,
*,
inn_col: int | None,
schema: Sequence[OsvGroup],
hint: InnHint | None,
rules: Rules,
) -> InnGlue:
"""Строки ИНН рядом с наименованием → ИНН наименованию (spec «Строки ИНН рядом с контрагентом»).
`levels` — дерево каскада иерархии со строками ИНН; `schema` — группировки шапки (`group_schema`) для защиты «над,
в дереве»; `hint` — подсказка `<группировка>.ИНН` (без неё ИНН строки должен пройти контрольную сумму).
"""
tree = _build(rows, levels, total_row, sheet, hint, rules)
pairs = _Pairs()
_unrecognised_attributes(tree, schema, hint, pairs)
_pair_in_tree(tree, schema, hint, pairs)
_pair_flat(tree, hint, pairs)
_check_sums(tree, pairs)
_drop_conflicts(pairs)
return _result(rows, levels, tree, pairs, sheet, inn_col, hint, rules)

View File

@ -17,7 +17,17 @@ from src.parser.detect import INN_LENGTHS, inn_from_cell
from src.parser.grid import Cell
from src.parser.rules import Rules, source_offset
__all__ = ["MONEY_Q", "MoneyParse", "column_inn", "inn_digits", "inn_only", "split_inn_suffix", "to_money"]
__all__ = [
"MONEY_Q",
"MoneyParse",
"column_inn",
"inn_checksum_ok",
"inn_digits",
"inn_only",
"row_inn",
"split_inn_suffix",
"to_money",
]
MONEY_Q = Decimal("0.01")
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
@ -46,6 +56,15 @@ _MINUS_SIGN = "−"
_SINGLE_DIGIT_RUN = re.compile(r"^\D*([0-9]+(?:[\s\u00a0\u202f][0-9]+)*)\D*$")
_DIGIT_GROUP_SEPARATOR = re.compile(r"[\s\u00a0\u202f]")
# Контрольная сумма ИНН (алгоритм ФНС): весовые коэффициенты контрольных разрядов; остаток от деления на 11, затем на 10
_INN10_WEIGHTS = (2, 4, 10, 3, 5, 9, 4, 6, 8)
_INN12_WEIGHTS_FIRST = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
_INN12_WEIGHTS_SECOND = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
_CHECKSUM_MODULUS = 11
_CHECK_DIGIT_MODULUS = 10
_INN_LEGAL_LENGTH = 10
_INN_PERSON_LENGTH = 12
class MoneyParse(NamedTuple):
value: Decimal | None
@ -177,3 +196,36 @@ def inn_only(text: str, rules: Rules) -> str | None:
if match is not None:
return match.group("inn")
return None
def _check_digit(digits: str, weights: tuple[int, ...]) -> int:
# strict=False намеренно: zip обрезает цифры по длине весов (ИНН длиннее весов на 1–2 символа);
# последние разряды — контрольные, в сумму не входят
total = sum(int(digit) * weight for digit, weight in zip(digits, weights, strict=False))
return total % _CHECKSUM_MODULUS % _CHECK_DIGIT_MODULUS
def inn_checksum_ok(inn: str) -> bool:
"""Контрольная сумма ИНН по алгоритму ФНС: у 10 цифр — последний разряд, у 12 — два последних (#35).
Проверяется только у строки ИНН без подсказки в шапке (`inn_rows`): ИНН колонки и хвоста названия — без проверки.
"""
if not (inn.isascii() and inn.isdigit()): # ASCII-цифры: int() по каждому символу гарантированно сработает
return False
if len(inn) == _INN_LEGAL_LENGTH:
return _check_digit(inn, _INN10_WEIGHTS) == int(inn[-1])
if len(inn) == _INN_PERSON_LENGTH:
first_ok = _check_digit(inn, _INN12_WEIGHTS_FIRST) == int(inn[-2])
second_ok = _check_digit(inn, _INN12_WEIGHTS_SECOND) == int(inn[-1])
return first_ok and second_ok
return False
def row_inn(cell: Cell, rules: Rules) -> str | None:
"""ИНН строки, где написан только он (строка ИНН, `inn_rows`): число — 10/12 цифр или 9/11 с потерянным ведущим
нулём (R-24, #34, как у колонки ИНН); текст — по `inn_only_row` («7701234560», «ИНН 7701234560 / 770101001»)."""
if cell.kind == "number":
return inn_digits(cell)
if cell.kind != "text":
return None
return inn_only(cell.text, rules)

View File

@ -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 InnGlue, glue_inn_rows
from src.parser.rules import Rules
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
@ -125,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`."""
@ -147,23 +162,45 @@ 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
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)
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
# --- внутреннее ---

View File

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

View File

@ -1,10 +1,10 @@
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание —
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
записи фиксирован.
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории
fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
метки времени книги и zip постоянны, порядок записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
@ -69,9 +69,11 @@ class Node:
num_style: NumStyle = "num"
inn_style: InnStyle = "column"
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
blank: bool = False # строка без сумм (строка ИНН без сумм, #35)
twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит
def children(self) -> list["Node"]:
return [kid for kid in self.kids if not kid.indicator]
return [kid for kid in self.kids if not kid.indicator and kid.twin is None]
def six(self) -> Six:
"""Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи)."""
@ -365,7 +367,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
inn_line = Node(node.inn)
_row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True)
next_row += 1
_row_cells(sheet, spec, next_row, (node, depth), node.six())
_row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank)
next_row += 1
total = SheetSpec("")
_row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
@ -628,10 +630,13 @@ def _s17() -> list[SheetSpec]:
def _s18() -> list[SheetSpec]:
"""ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет."""
root = Node(
"60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])]
)
"""ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.
ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35)."""
first = alfa(inn_style="above")
first.inn = "7701234560"
third = gamma(inn_style="above")
third.inn = "5001234561"
root = Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third])])
return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
@ -645,6 +650,58 @@ def _s19() -> list[SheetSpec]:
return [build_osv(spec, root)]
InnLayout = Literal["above_sibling", "above_parent", "below_child", "below_sibling"]
def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node:
"""То же дерево, где ИНН каждого контрагента выведен отдельной строкой в раскладке `layout` (#35): над / под
наименованием, в дереве (родитель / ребёнок) или соседом; `sums` — у строки ИНН те же суммы, иначе строка пустая.
Колонки ИНН нет, в названии ИНН не пишется."""
kids: list[Node] = []
for kid in node.kids:
kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией со строками ИНН
if kid.inn is None or kid.indicator:
kids.append(kid)
continue
inn, kid.inn = kid.inn, None
if layout == "above_sibling":
kids += [Node(inn, twin=kid, blank=not sums), kid]
elif layout == "below_sibling":
kids += [kid, Node(inn, twin=kid, blank=not sums)]
elif layout == "above_parent":
kids.append(Node(inn, [kid], blank=not sums))
else:
kid.kids = [Node(inn, kid.kids, opening=kid.opening, turnover=kid.turnover, blank=not sums)]
kids.append(kid)
node.kids = kids
return node
def _inn_layout_tree() -> Node:
"""Счёт 60 для раскладок строк ИНН (#35): ИНН с верной контрольной суммой (10 и 12 цифр), физлицо без ИНН."""
first = alfa()
first.inn = "7701234560"
third = gamma()
third.inn = "5001234561"
return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])])
def _s28() -> list[SheetSpec]:
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
tree = _inn_layout_tree()
tree.kids[0].kids[0].inn = "7701234567"
root = inn_rows_tree(tree, "above_sibling", sums=False)
return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]:
def build() -> list[SheetSpec]:
root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums)
return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
return build
def _normalized_zip(data: bytes) -> bytes:
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
при каждом `save`, заданное в свойствах значение перезаписывает)."""
@ -713,6 +770,15 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s17_inn_in_name.xlsx": (_s17, to_xlsx),
"s18_inn_row_above.xlsx": (_s18, to_xlsx),
"s19_inn_kpp_cell.xlsx": (_s19, to_xlsx),
"s20_inn_above_sibling_blank.xlsx": (_inn_layout("above_sibling", sums=False), to_xlsx),
"s21_inn_above_sibling_sums.xlsx": (_inn_layout("above_sibling", sums=True), to_xlsx),
"s22_inn_above_parent_blank.xlsx": (_inn_layout("above_parent", sums=False), to_xlsx),
"s23_inn_above_parent_sums.xlsx": (_inn_layout("above_parent", sums=True), to_xlsx),
"s24_inn_below_child_blank.xlsx": (_inn_layout("below_child", sums=False), to_xlsx),
"s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx),
"s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx),
"s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx),
"s28_inn_bad_checksum.xlsx": (_s28, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -731,7 +797,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.",
description="Синтетические ОСВ s01…s28 (xlsx и один xlsb) в указанный каталог.",
)
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv)

View File

@ -26,6 +26,18 @@ def _require_synthetic(synthetic_dir: Path) -> None:
SYNTHETIC_DIR = Path("osv") / "synthetic"
INN_LAYOUT_FILES = (
"s20_inn_above_sibling_blank.xlsx",
"s21_inn_above_sibling_sums.xlsx",
"s22_inn_above_parent_blank.xlsx",
"s23_inn_above_parent_sums.xlsx",
"s24_inn_below_child_blank.xlsx",
"s25_inn_below_child_sums.xlsx",
"s26_inn_below_sibling_sums.xlsx",
"s27_inn_below_sibling_blank.xlsx",
)
INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx"
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
"s02_no_outline_indent.xlsx",
@ -46,6 +58,8 @@ EXPECTED_NAMES = [
"s17_inn_in_name.xlsx",
"s18_inn_row_above.xlsx",
"s19_inn_kpp_cell.xlsx",
*INN_LAYOUT_FILES,
INN_BAD_CHECKSUM_FILE,
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -109,6 +123,10 @@ EXPECTED: dict[str, Expect] = {
"s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False),
# колонка «ИНН/КПП»
"s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False),
# восемь раскладок строки ИНН (#35)
**{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES},
# ИНН с неверной контрольной суммой: строка остаётся строкой, документ на проверку
INN_BAD_CHECKSUM_FILE: Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
}
@ -180,7 +198,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s19 (s13 — xlsb; допустима деградация: нет только его)."""
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s28 (s13 — xlsb; допустима деградация: нет только его)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present]
@ -524,12 +542,62 @@ def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None:
doc = one_document(outcome)
assert not [row for row in doc.rows if row.group_type == "OTHER"]
assert not [row for row in doc.rows if row.row_value in {"7701234567", "5001234567"}]
assert not [row for row in doc.rows if row.row_value in {"7701234560", "5001234561"}]
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {ALFA: "7701234567", BETA: None, "Иванов Иван": None, GAMMA: "5001234567"}
assert any("ИНН 7701234567" in warning for warning in warnings_of(outcome))
assert counterparties == {ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"}
assert any("ИНН 7701234560" in warning for warning in warnings_of(outcome))
INN_LAYOUT_COUNTERPARTIES = {
ALFA: "7701234560",
BETA: "7707654321",
"Иванов Иван": None,
GAMMA: "5001234561",
"ИП Сидоров": "500100732259",
}
@pytest.mark.parametrize("name", INN_LAYOUT_FILES)
def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: str) -> None:
"""#35: в каждой из восьми раскладок строки ИНН у каждого контрагента — ИНН его строки, строк ИНН в `rows` нет."""
outcome = parse_named(fixtures_dir, name)
doc = one_document(outcome)
assert outcome.status is ResultStatus.DONE, warnings_of(outcome)
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == INN_LAYOUT_COUNTERPARTIES
assert not [row for row in doc.rows if row.group_type == "OTHER"]
inns = {inn for inn in INN_LAYOUT_COUNTERPARTIES.values() if inn}
assert not [row for row in doc.rows if row.row_value in inns]
def test_s28_bad_checksum_inn_row_stays_a_row_and_needs_check(fixtures_dir: Path) -> None:
"""Опечатка в ИНН ООО Альфа: «7701234567» остаётся обычной строкой, у Альфы ИНН нет, предупреждение, проверка;
остальные контрагенты получают свои ИНН (направление «над» — по блоку 60.02)."""
outcome = parse_named(fixtures_dir, INN_BAD_CHECKSUM_FILE)
doc = one_document(outcome)
assert outcome.status is ResultStatus.NEEDS_CHECK
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties.pop("7701234567") is None # обычная строка: классификатор принял её за контрагента
assert counterparties == {**INN_LAYOUT_COUNTERPARTIES, ALFA: None}
assert any(
"похожа на ИНН 7701234567" in warning and "контрольная сумма не сходится" in warning
for warning in warnings_of(outcome)
)
# решение 20: предупреждение названо для строки 13, строка остаётся COUNTERPARTY без ИНН, в OTHER не уходит
assert any(f"строка {TYPO_ROW}: похожа на ИНН 7701234567" in warning for warning in warnings_of(outcome))
typo_rows = [row for row in doc.rows if row.row_number == TYPO_ROW]
assert [(row.row_value, row.group_type, row.counterparty_inn) for row in typo_rows] == [
("7701234567", "COUNTERPARTY", None)
]
assert not [row for row in doc.rows if row.group_type == "OTHER"]
def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None:

View File

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

View File

@ -0,0 +1,624 @@
"""Строки ИНН рядом с контрагентом (spec «Строки ИНН рядом с контрагентом», change `osv-inn-rows-layouts`, #35).
Тесты модуля `inn_rows` на деревьях, собранных вручную: строка листа — (уровень, подпись, шесть сумм, ИНН колонки).
Подпись — колонка 1, колонка ИНН — 2, суммы — 3…8; уровни — как у стратегии outline, родители — `hierarchy.parents`.
"""
from decimal import Decimal
import pytest
from gridbuilder import sheet_from_rows
from src.contract.result import OsvGroup
from src.parser.classify import ACCOUNT_RE
from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels, parents
from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.rules import Rules, load_rules
Sums = tuple[object, object, object, object, object, object]
NO: Sums = (None, None, None, None, None, None)
A: Sums = (None, None, 100, 40, 60, None)
B: Sums = (10, None, None, 30, None, 20)
INN_A = "7701234560"
INN_B = "7707654321"
INN_12 = "500100732259"
BAD = "7701234567" # контрольная сумма не сходится
LABEL_COL = 1
INN_COL = 2
SCHEMA = [OsvGroup(type="ACCOUNT", name="Счет"), OsvGroup(type="COUNTERPARTY", name="Контрагенты")]
SCHEMA_WITH_CONTRACTS = [*SCHEMA, OsvGroup(type="CONTRACT", name="Договоры")]
BELOW = InnHint("Контрагенты.ИНН", "below")
ABOVE = InnHint("Контрагенты.ИНН", "above")
Row = tuple[int, object, Sums, object]
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def r(level: int, label: object, sums: Sums = NO, inn: object = None) -> Row:
return level, label, sums, inn
def run(rules: Rules, *rows: Row, hint: InnHint | None = None, schema: list[OsvGroup] | None = None) -> InnGlue:
sheet = sheet_from_rows([[label, inn, *sums] for _, label, sums, inn in rows])
body: list[BodyRow] = []
for number, (level, _, sums, _) in enumerate(rows, start=1):
text = sheet.text_at(number, LABEL_COL)
account = ACCOUNT_RE.match(text)
body.append(
BodyRow(
row=number,
text=text,
is_account=account is not None,
account_rank=None if account is None else account.group(1).count(".") + 1,
bold=False,
indent=0,
outline=level,
sums=tuple(None if value is None else Decimal(str(value)) for value in sums),
label_col=LABEL_COL,
)
)
by_row = {item.row: level for item, (level, *_) in zip(body, rows, strict=True)}
levels = Levels(by_row=by_row, parent=parents(body, by_row), strategy="outline")
return glue_inn_rows(body, levels, None, sheet, inn_col=INN_COL, schema=schema or SCHEMA, hint=hint, rules=rules)
def texts(glue: InnGlue) -> list[str]:
return [item.text for item in glue.rows]
def has_warning(glue: InnGlue, *parts: str) -> bool:
return any(all(part in warning for part in parts) for warning in glue.warnings)
# --- ИНН под наименованием, в дереве ---
def test_inn_below_in_tree_with_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {2: INN_A}
assert (glue.unpaired, glue.ok) == (frozenset(), True)
assert has_warning(glue, "строка 3", INN_A, "отнесён к контрагенту в строке 2")
def test_inn_below_in_tree_without_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_12))
assert glue.inns == {2: INN_12}
assert glue.ok
def test_children_of_inn_row_move_up_to_counterparty(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
def test_inn_with_contracts_under_counterparty_belongs_to_parent(rules: Rules) -> None:
# Без подсказки: родитель по схеме — контрагент, значит ИНН его, а дети строки ИНН — договоры
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(2, INN_A, A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.inns == {2: INN_A}
assert texts(glue) == ["60", "ООО Альфа", "Договор № 1"]
def test_inn_between_department_and_name_is_above(rules: Rules) -> None:
# Родитель по схеме — подразделение: строка ИНН стоит над наименованием
schema = [
OsvGroup(type="ACCOUNT", name="Счет"),
OsvGroup(type="DEPARTMENT", name="Подразделения"),
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
]
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A, A), r(3, "ООО Альфа", A), schema=schema)
assert glue.inns == {4: INN_A}
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 2)
def test_two_inn_rows_under_one_name_stay_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A), r(2, INN_B))
assert glue.unpaired == frozenset({3, 4})
assert not glue.ok
assert has_warning(glue, "строка 3", "несколько строк ИНН")
def test_inn_row_with_other_sums_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, B))
assert glue.unpaired == frozenset({3})
assert glue.inns == {}
assert has_warning(glue, "строка 3", "суммы не равны")
# --- ИНН над наименованием, в дереве ---
def test_inn_above_in_tree_with_sums_is_glued_and_name_moves_up(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {3: INN_A}
assert (glue.levels.by_row[3], glue.levels.parent[3]) == (1, 1)
def test_inn_above_in_tree_without_sums_is_glued(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A))
assert glue.inns == {3: INN_A}
assert glue.ok
def test_inn_above_with_two_children_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(2, "ООО Альфа", A), r(2, "ООО Бета", B))
assert glue.unpaired == frozenset({2})
assert texts(glue) == ["60", INN_A, "ООО Альфа", "ООО Бета"]
assert has_warning(glue, "строка 2", "несколько")
def test_no_extra_level_keeps_inn_named_counterparty(rules: Rules) -> None:
# Схема «Контрагенты, Договоры», глубже схемы строк нет: «7701234560» — контрагент с одним договором, не строка ИНН
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "Договор № 1", A), schema=SCHEMA_WITH_CONTRACTS)
assert texts(glue) == ["60", INN_A, "Договор № 1"]
assert (glue.inns, glue.unpaired, glue.warnings, glue.ok) == ({}, frozenset(), [], True)
def test_extra_level_allows_inn_above_with_contract_schema(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, INN_A, A),
r(2, "ООО Альфа", A),
r(3, "Договор № 1", A),
schema=SCHEMA_WITH_CONTRACTS,
)
assert glue.inns == {3: INN_A}
assert (glue.levels.by_row[4], glue.levels.parent[4]) == (2, 3)
# --- Распознавание и контрольная сумма ---
@pytest.mark.parametrize(
("label", "inn"),
[
(Decimal(7701234560), INN_A),
(123456788, "0123456788"), # 9 цифр числом (#34)
("ИНН 7701234560", INN_A),
("ИНН: 7701234560 / 770101001", INN_A),
(INN_12, INN_12),
],
)
def test_inn_row_forms(rules: Rules, label: object, inn: str) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, label, A))
assert glue.inns == {2: inn}
def test_number_with_lost_leading_zero_is_restored(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Адыгея", A), r(2, 105012345, A), hint=BELOW)
assert glue.inns == {2: "0105012345"}
def test_bad_checksum_without_hint_is_lookalike(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A))
assert texts(glue) == ["60", "ООО Альфа", BAD]
assert (glue.inns, glue.unpaired, glue.ok) == ({}, frozenset(), False)
assert has_warning(glue, "строка 3", "похожа на ИНН", "контрольная сумма не сходится")
def test_lone_number_named_row_without_names_around_is_silent(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, BAD, A))
assert (glue.warnings, glue.ok) == ([], True)
def test_hint_skips_checksum(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, BAD, A), hint=BELOW)
assert glue.inns == {2: BAD}
assert has_warning(glue, f"ИНН {BAD} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
def test_hint_below_empty_label_is_glued_without_inn(rules: Rules) -> None:
# R-1 #32: физлицо — 1С выводит строку реквизита с пустой ячейкой
glue = run(rules, r(0, "60", A), r(1, "Иванов Иван", A), r(2, None, A), hint=BELOW)
assert texts(glue) == ["60", "Иванов Иван"]
assert (glue.inns, glue.ok) == ({}, True)
assert has_warning(
glue, "строка 3: ИНН пуст («Контрагенты.ИНН») — строка отнесена к контрагенту в строке 2 без ИНН"
)
def test_hint_below_unrecognised_attribute_stays_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Нерезидент", A), r(2, "нерез.", A), hint=BELOW)
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "не распознан")
def test_hint_below_real_name_with_own_inn_row_is_not_an_attribute(rules: Rules) -> None:
# R-1 T2.1: единственный ребёнок подразделения с теми же суммами — наименование, у него своя строка ИНН
schema = [
OsvGroup(type="ACCOUNT", name="Счет"),
OsvGroup(type="DEPARTMENT", name="Подразделения"),
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
]
glue = run(
rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, "ООО Альфа", A), r(3, INN_A, A), hint=BELOW, schema=schema
)
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), True)
def test_childless_inn_next_to_counterparty_under_department_is_not_paired_with_department(rules: Rules) -> None:
# R-2 T2.2 / T2.9: родитель-подразделение — не пара; ИНН без детей уходит соседу-контрагенту (блок «над»)
schema = [
OsvGroup(type="ACCOUNT", name="Счет"),
OsvGroup(type="DEPARTMENT", name="Подразделения"),
OsvGroup(type="COUNTERPARTY", name="Контрагенты"),
]
glue = run(rules, r(0, "60", A), r(1, "БП Аренда", A), r(2, INN_A), r(2, "ООО Альфа", A), schema=schema)
assert glue.inns == {4: INN_A}
assert glue.unpaired == frozenset()
assert glue.ok
assert texts(glue) == ["60", "БП Аренда", "ООО Альфа"]
def test_hint_above_disables_no_extra_level_guard(rules: Rules) -> None:
# R-3 T2.3: подсказка «над» — свидетельство лишнего уровня; защита работает только без подсказки
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE, schema=SCHEMA_WITH_CONTRACTS)
assert texts(glue) == ["60", "ООО Альфа"]
assert (glue.inns, glue.unpaired) == ({3: INN_A}, frozenset())
def test_column_inn_wins_over_inn_row(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_B), r(2, INN_A, A))
assert texts(glue) == ["60", "ООО Альфа"]
assert glue.inns == {}
assert not glue.ok
assert has_warning(glue, "строка 3", "расходится", INN_B)
def test_same_inn_in_column_and_row_is_fine(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A, inn=INN_A), r(2, INN_A, A))
assert (glue.inns, glue.ok) == ({2: INN_A}, True)
# --- Не в дереве: соседи того же родителя ---
def test_inn_above_sibling_without_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B), r(1, "ООО Бета", B))
assert texts(glue) == ["60", "ООО Альфа", "ООО Бета"]
assert glue.inns == {3: INN_A, 5: INN_B}
assert glue.ok
def test_inn_below_sibling_without_sums_goes_to_own_counterparty(rules: Rules) -> None:
# Дефект main: ИНН первого контрагента доставался второму
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B), r(1, INN_B))
assert glue.inns == {2: INN_A, 4: INN_B}
assert glue.ok
def test_inn_above_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Бета", B))
assert glue.inns == {3: INN_A, 5: INN_B}
def test_inn_below_sibling_with_sums(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), r(1, INN_B, B))
assert glue.inns == {2: INN_A, 4: INN_B}
def test_sums_match_no_neighbour(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_B, B), r(1, "ООО Гамма", A))
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "суммы не совпали")
def test_both_neighbours_match_sums_need_direction(rules: Rules) -> None:
rows = (r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", A))
assert run(rules, *rows).unpaired == frozenset({3})
assert run(rules, *rows, hint=ABOVE).inns == {4: INN_A}
def test_ambiguous_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), r(1, "ООО Бета", B))
assert glue.unpaired == frozenset({3})
assert not glue.ok
assert has_warning(glue, "строка 3", "направление")
def test_direction_from_other_block(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, "ООО Альфа", A),
r(2, INN_A),
r(2, "Иванов Иван", B), # блок 60.01 «имя, ИНН, имя» направление не даёт
r(1, "60.02", B),
r(2, "ООО Гамма", B),
r(2, INN_B), # блок 60.02 кончается строкой ИНН — ИНН под наименованием
)
assert glue.inns == {3: INN_A, 7: INN_B}
def test_direction_from_sum_pair(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "ООО Альфа", A),
r(1, INN_A, A),
r(1, "ООО Бета", B),
r(1, INN_B),
r(1, "Иванов Иван", B),
)
assert glue.inns == {2: INN_A, 4: INN_B}
def test_contradicting_blocks_leave_inn_unpaired(rules: Rules) -> None:
glue = run(
rules,
r(0, "60", A),
r(1, "60.01", A),
r(2, INN_A),
r(2, "ООО Альфа", A), # начинается строкой ИНН — над
r(1, "60.02", B),
r(2, "ООО Бета", B),
r(2, INN_B), # кончается строкой ИНН — под
)
assert glue.unpaired == frozenset({3, 7})
def test_broken_block_leaves_inn_unpaired(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A), r(1, "ООО Альфа", A), r(1, INN_B))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "строка 2", "начинается и заканчивается")
def test_hint_direction_wins_over_blocks(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, BAD), r(1, "ООО Бета", B), hint=ABOVE)
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))
assert glue.unpaired == frozenset({2, 4})
assert has_warning(glue, "претендуют")
def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=ABOVE)
assert glue.unpaired == frozenset({3})
assert has_warning(glue, "строка 3", "нет наименования")
# --- Раскладка противоречит подсказке шапки: склеить, но подсветить (H-2, design #10) ---
def conflict_warning(glue: InnGlue, row: int, partner: int, where: str, expected: str) -> bool:
return has_warning(
glue,
f"строка {row}: ИНН",
f"стоит {where} наименованием в строке {partner}",
f"указывает {expected} — проверьте",
)
def test_hint_above_inn_child_of_name_is_glued_with_conflict_warning(rules: Rules) -> None:
# T2.12: подсказка «над», строка ИНН без детей под родителем-контрагентом — склеена с родителем, но подсвечена
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=ABOVE)
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
assert has_warning(glue, f"ИНН {INN_A} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
assert conflict_warning(glue, 3, 2, "под", "над")
assert has_warning(glue, "шапка («Контрагенты.ИНН»)")
def test_hint_below_inn_parent_of_name_is_glued_with_conflict_warning(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=BELOW)
assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), False)
assert conflict_warning(glue, 2, 3, "над", "под")
def test_hint_above_sibling_below_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), hint=ABOVE)
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
assert conflict_warning(glue, 3, 2, "под", "над")
def test_hint_below_sibling_above_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None:
glue = run(rules, r(0, "60", A), r(1, "ООО Бета", B), r(1, INN_A, A), r(1, "ООО Альфа", A), hint=BELOW)
assert (glue.inns, glue.unpaired, glue.ok) == ({4: INN_A}, frozenset(), False)
assert conflict_warning(glue, 3, 4, "над", "под")
def test_hint_agreeing_with_layout_has_no_conflict_warning(rules: Rules) -> None:
below = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=BELOW)
above = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE)
blank = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=BELOW)
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

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

View File

@ -7,7 +7,8 @@ import pytest
from src.parser import detect
from src.parser.grid import EMPTY_CELL, Cell
from src.parser.numbers import MONEY_Q, MoneyParse, inn_digits, to_money
from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money
from src.parser.rules import Rules, load_rules
MAX_WARNING_LENGTH = 100
@ -237,3 +238,51 @@ def test_only_extraction_restores_lost_leading_zero() -> None:
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
assert detect.inn_from_cell(text("ИНН 352819549855")) is None
# --- Контрольная сумма ИНН и ИНН строки (#35) ---
@pytest.mark.parametrize(
("inn", "ok"),
[
("7701234560", True),
("7707654321", True),
("2370012036", True),
("0123456788", True),
("500100732259", True),
("7701234567", False),
("5001234567", False),
("500100732258", False),
("770123456", False),
("77012345601", False),
("77012345a0", False),
("770123456²", False),
],
)
def test_inn_checksum_ok(inn: str, ok: bool) -> None:
assert inn_checksum_ok(inn) is ok
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
@pytest.mark.parametrize(
("cell", "expected"),
[
(num(7701234560), "7701234560"),
(num(123456788), "0123456788"), # 9 цифр числом — потерянный ведущий ноль (R-24, #34)
(num(50010073225), "050010073225"), # 11 цифр числом
(text("7701234560"), "7701234560"),
(text("ИНН 7701234560"), "7701234560"),
(text("ИНН: 7701234560 / 770101001"), "7701234560"),
(text("123456788"), None), # текст из 9 цифр нулём не дополняется
(text("ООО Альфа 7701234560"), None), # не только ИНН
(num(Decimal("7701234560.5")), None),
(EMPTY_CELL, None),
],
)
def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None:
assert row_inn(cell, rules) == expected

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,163 @@
## Context
Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs/context/05-domain-rules.md` §1.1 (реквизит
`<группировка>.ИНН`, OQ-162) и §1.3 (ИНН контрагента: колонка → строка ИНН → хвост названия; OQ-100, OQ-130); новое правило
заносится в §1.3 и новым OQ в `07-open-questions.md`.
Восемь раскладок (ИНН над / под наименованием) × (в дереве / не в дереве) × (с суммами / без сумм). Поведение main на
синтетике без подсказки в шапке:
| Раскладка | main |
|---|---|
| над, не в дереве, без сумм | склеивается (OQ-130) |
| над, не в дереве, с суммами | ИНН — отдельный контрагент, `needs_check` |
| над, в дереве, с суммами | ИНН вместо наименования, `done` |
| над, в дереве, без сумм | `needs_check` |
| под, в дереве, с суммами | ИНН теряется, `done` (с подсказкой в шапке — склеивается, #32) |
| под, в дереве, без сумм | `needs_check` |
| под, не в дереве, с суммами | ИНН — отдельный контрагент, `needs_check` |
| под, не в дереве, без сумм | **ИНН первого контрагента у второго, `done`** |
Проверено при проектировании: стратегии иерархии `outline`, `indent`, `account_pattern` ставят строку ИНН (с суммами и
без) на тот же уровень, что и наименования (OQ-130 отключался), то есть дерево до склейки корректно; стратегия `sums` не
проверялась. Контрольная сумма ИНН на golden: реальные ИНН (≈ 5 852) верны, кроме трёх (физлица: Цимлянский, ОСВ 76,
`611801003692`; Новопетровская `233370449085`; НПЗ `614002279560`), вся синтетика (`5001234567`, `7701234567`,
`7812345678` и др.) неверна.
## Decisions
Решения владельца в brainstorming 13.10.2026:
1. **Подход A — один проход по дереву.** Новый модуль `api/src/parser/inn_rows.py` работает между `resolve_levels` и
`classify_rows`. Он заменяет `_glue_inn_rows` (OQ-130, до иерархии) и `glue_inn_attributes` (#32, после
классификации). Классификатор получает дерево без строк ИНН и ИНН для строк-наименований.
2. **Контрольная сумма.** Без подсказки в шапке строка — строка ИНН только при верной контрольной сумме ИНН (алгоритм ФНС:
10 цифр — весовые коэффициенты 2, 4, 10, 3, 5, 9, 4, 6, 8; 12 цифр — два контрольных разряда, 7, 2, 4, 10, 3, 5, 9, 4,
6, 8 и 3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8), во всех восьми раскладках, включая прежнюю OQ-130. С подсказкой
`<группировка>.ИНН` контрольная сумма не проверяется (файл банка #32 не меняется).
3. **Неверная контрольная сумма.** Строка из 10/12 цифр (по `inn_only_row`) рядом с наименованием, не прошедшая
контрольную сумму без подсказки, — не строка ИНН: классифицируется как обычно, предупреждение «строка N похожа на ИНН
…, контрольная сумма не сходится», документ `needs_check`.
4. **Ведущий ноль (#34).** Числовая ячейка строки ИНН из 9/11 цифр дополняется нулём слева (как R-24 для колонки),
контрольная сумма проверяется после дополнения.
5. **«Над, в дереве»** — склейка только с единственным ребёнком; несколько детей — не склеивать, `needs_check`.
6. **Направление для соседей** (предложено в дизайне, не оспорено): пары с суммами — по совпадению сумм; без сумм — по
направлению документа: порядок в шапке → пары по суммам → однозначные блоки. Противоречие или нет однозначных блоков —
не склеивать, `needs_check`.
7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32).
8. **Строка ИНН — только ИНН**: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП» (`inn_only_row`); счёт строкой ИНН не
бывает.
9. **Синтетика для отказа «неверная контрольная сумма»** (решение владельца 13.10.2026, H-1): «синтетика для отказа
«неверная контрольная сумма» — да». Файл `s28_inn_bad_checksum.xlsx`: раскладка `s20` («над, не в дереве, без сумм»),
у ООО Альфа строка ИНН `7701234567`; ожидание — строка остаётся обычной, ИНН у Альфы нет, предупреждение «похожа на ИНН»,
`needs_check`, остальные контрагенты получают свои ИНН (направление — по блоку 60.02).
10. **Противоречие раскладки и подсказки шапки** (решение владельца 13.10.2026, H-2): «Противоречие раскладки и подсказки
шапки — СКЛЕИВАТЬ, НО ПОДСВЕЧИВАТЬ». Пара найдена, но ИНН стоит не там, где указывает подсказка `<группировка>.ИНН`
(в дереве или соседом по суммам) — склейка как обычно, отдельное предупреждение «строка N: ИНН … стоит <под/над>
наименованием в строке M, а шапка («…») указывает <над/под> — проверьте», `ok = False`, `needs_check`. Закрывает T2.12.
### Алгоритм `inn_rows.py`
Вход: строки `BodyRow`, `Levels` (уровень и родитель), подсказка шапки (`InnHint`: есть ли `<группировка>.ИНН` и
направление по порядку элементов), схема группировок `group_schema` (для защиты, п. «Защита» ниже), словари. Выход: строки и
`Levels` без склеенных строк ИНН, ИНН для строк-наименований (`row → inn`), множество строк ИНН без пары (классификатор
ставит им `OTHER`), предупреждения, признак «всё склеено».
1. **Кандидаты.** Строка ИНН — не счёт, не «Итого», не строка Вал./Кол.; подпись совпадает с `inn_only_row` (числовая
ячейка из 9/11 цифр — с ведущим нулём); без подсказки ИНН проходит контрольную сумму (иначе п. 3 решений). С
подсказкой пустая подпись строки-ребёнка наименования (только положение «под, в дереве») — тоже кандидат, «ИНН не
указан» (R-1 #32).
2. **Наименование** — строка не счёт, не «Итого», не Вал./Кол., не кандидат.
3. **Положение кандидата** (по `Levels`):
- есть дети → **над, в дереве**: пара — единственный ребёнок-наименование; он и его поддерево поднимаются на уровень
вверх, родителем становится родитель кандидата;
- родитель — наименование → **под, в дереве**: пара — родитель; дети кандидата переходят к родителю;
- иначе (родитель — счёт или корень, детей нет) → **не в дереве**: пара — сосед того же родителя, непосредственно
выше или ниже.
Связь в дереве важнее соседства.
4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе
пары нет.
5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа.
6. **Направление документа** (лист ОСВ): подсказка шапки (п. 1), если она есть; иначе сведения пп. 2–3 вместе:
1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над;
2. пары соседей, найденные по суммам (п. 5, один совпавший сосед);
3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не
сопоставленных; начинается с I и не кончается I — над; кончается I и не начинается I — под; начинается и кончается I
— блок сломан (его кандидаты без пары); ни то ни другое — нет сведений.
Сведения пп. 2–3 противоречат друг другу или их нет — направление неизвестно.
7. **Соседи без сумм**: пара — соседнее наименование по направлению (над → следующий сосед, под → предыдущий).
Направление неизвестно — пары нет.
8. **Конфликты**: два кандидата претендуют на одно наименование — оба без пары. ИНН в колонке ИНН у наименования отличается
от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает
(прежний порядок §1.3).
9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки
(пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение владельца 10, H-2).
10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32),
предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`.
**Защита «над, в дереве».** Контрагент, названный ИНН, с одним договором неотличим по форме от строки ИНН над
контрагентом. Проверка — по листу: если `group_schema` содержит больше одной группировки и ни одна строка листа (кроме
Вал./Кол.) не стоит глубже схемы (позиция — число не-счётных предков после последнего счёта + 1 — не больше
`len(group_schema) − 1`), лишнего уровня в листе нет: кандидаты «над, в дереве» не склеиваются, без предупреждения
классифицируются по схеме. Схема из одного ACCOUNT — проверки нет, по пп. 1–10.
**Нераспознанный реквизит при подсказке «под».** С подсказкой «под» (#32) единственный ребёнок наименования с теми же
суммами, подпись которого не ИНН и не пуста («нерез.»), — строка реквизита с нераспознанным ИНН: без пары, `OTHER`,
«ИНН не распознан», `needs_check` (поведение #32 сохраняется).
### Изменения в коде
- `inn_rows.py` — новый модуль (пп. 1–10); контрольная сумма ИНН — `numbers.py` (`inn_checksum_ok`).
- `header.py`: `InnAttribute` и позиции-реквизиты удаляются; `GroupSchema` получает подсказку (`inn_hint`: владелец и
направление); `<группировка>.ИНН` распознаётся и **перед** группировкой-владельцем (сейчас только после).
- `classify.py`: удаляются `_glue_inn_rows`, `glue_inn_attributes`, `_not_glued_reason`, поля `is_inn_attribute`,
`attribute_inn`; `_classify_row` берёт ИНН строки из результата `inn_rows` вместо `inn_above` и ставит `OTHER`
строкам без пары.
- `hierarchy.py`: поле `BodyRow.inn_above` удаляется.
- `osv.py`: вызов `inn_rows` между `resolve_levels` и `classify_rows`; признак «всё склеено» и предупреждения — в статус и
`warnings`.
### Тесты
- Юнит-тесты `inn_rows.py` на деревьях вручную: распознавание (10/12, число/текст, «ИНН …», «ИНН/КПП», ведущий ноль),
контрольная сумма с подсказкой и без, все отказы пп. 4–8 и 10, защита «над, в дереве», направление (шапка, суммы,
блоки, противоречие, нет сведений, сломанный блок), расхождение с колонкой, R-1.
- Синтетика `s20`…`s27` (по одной на раскладку, ИНН с верной контрольной суммой, шапка без подсказки), golden — после
ручной проверки: ИНН у всех контрагентов, итоги сходятся, `done`.
- `s18`: ИНН с верной контрольной суммой (только в `s18`, общие узлы генератора не меняются).
- Сравнение результатов main и ветки на всех файлах fixtures и синтетике; ожидаемые отличия — `s18`, новые `s20`…`s27`,
файлы со строкой ИНН с неверной контрольной суммой; Новопетровская без изменений. Каждое отличие согласуется с
владельцем до `make golden-update`.
## Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 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` вместо склейки |
| 5 | 13.10.2026 | Подсказка шапки задаёт направление сильнее пар по суммам; пары по суммам склеиваются независимо от направления | Суммы однозначно указывают пару; шапка — явная настройка отчёта | Противоречие шапки и сумм не поднимается как ошибка |
| 6 | 13.10.2026 | Суммы сравниваются с `None` = 0 (в #32 — точное равенство кортежей) | Пустая ячейка и 0 в ОСВ 1С значат одно и то же | Пара по суммам у строк, различающихся только пустотой ячейки; на Новопетровской не влияет (проверяется golden) |
| 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` (при подсказке «над» — с подсветкой 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): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве); уточнено 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` |
| 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) и напомнит |
| 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

@ -0,0 +1,61 @@
## Why
Требование владельца (13.10.2026, RSHB/arm-tabular-parser#35): ОСВ, где ИНН контрагента выведен **отдельной строкой**
рядом с наименованием, должна разбираться во всех восьми раскладках: (ИНН над / под наименованием) × (в дереве / не в
дереве) × (с суммами / без сумм). Сейчас парсер знает две частные раскладки: «над, не в дереве, без сумм» (OQ-130,
`_glue_inn_rows`) и «под, в дереве, с суммами» только при подсказке «Контрагенты.ИНН» в шапке (OQ-162, #32,
`glue_inn_attributes`). Остальные шесть дают задвоение контрагентов, ИНН вместо наименования или потерю ИНН, а «под, не в
дереве, без сумм» — **дефект main**: ИНН первого контрагента молча достаётся второму, документ `done`.
Входит RSHB/arm-tabular-parser#34: числовая ячейка строки ИНН из 9/11 цифр (потерянный ведущий ноль, R-24) не
распознаётся как ИНН.
Срез: доработка Среза 2a (парсер ОСВ, `09-delivery-plan.md`). Репозитории: tabular-parser (код и эта спека), arm-ka-docs
(`05-domain-rules.md` §1.1, §1.3, новый OQ), arm-ka-fixtures (синтетика и golden). Таблицы DBML не затрагиваются
(`osv_entry.counterparty_inn` заполняет ядро из контракта, контракт не меняется).
## What Changes
- Новый модуль `api/src/parser/inn_rows.py`: один проход по дереву строк между построением иерархии и классификацией.
Находит строки ИНН, сопоставляет каждую с её контрагентом, убирает склеенную строку из дерева (дети переходят к
контрагенту), отдаёт классификатору чистое дерево и ИНН для строк контрагентов.
- Строка ИНН распознаётся по содержимому (`inn_only_row` в `osv_headers.yaml`, 10/12 цифр; число из 9/11 цифр — с
ведущим нулём, #34). Без подсказки в шапке ИНН MUST пройти проверку контрольной суммы; строка из 10/12 цифр рядом с
контрагентом с неверной контрольной суммой — не строка ИНН, предупреждение и `needs_check`.
- Подсказка в шапке — элемент `<группировка>.ИНН` (OQ-162): снимает проверку контрольной суммы и задаёт направление
порядком («Контрагенты, Контрагенты.ИНН» — под; «Контрагенты.ИНН, Контрагенты» — над; второй порядок сейчас не
распознаётся).
- Сопоставление: связь в дереве (строка ИНН — родитель единственного ребёнка или ребёнок наименования) важнее соседства;
соседи — по равенству сумм или по направлению документа (шапка → пары по суммам → однозначные блоки). Неоднозначность
— строка не склеивается, `group_type = OTHER`, предупреждение с причиной, `needs_check`.
- Удаляются `_glue_inn_rows` (OQ-130), `glue_inn_attributes` и тип `InnAttribute` в схеме классификатора (#32); поле
`BodyRow.inn_above` заменяется ИНН из соседней строки.
- Синтетика `s20`…`s27` (по одной на раскладку); `s18` получает ИНН с верной контрольной суммой.
- Требование «Классификация строк» спеки `osv-parsing` переписывается целиком: текущая версия устарела («ИНН только из
колонки», R-17) и не отражает OQ-100, OQ-130, OQ-162.
## Non-goals
- Контракт результата (`counterparty_inn`, `rows`, `warnings`) не меняется, `CONTRACT_VERSION` прежний.
- ИНН в тексте названия (OQ-100) и в колонке ИНН (R-15, R-24, «ИНН/КПП») — прежние правила, порядок приоритета прежний:
колонка → строка ИНН → хвост названия.
- Проверка контрольной суммы ИНН из колонки и из хвоста названия не вводится.
- Реквизиты, кроме ИНН (`Контрагенты.КПП` и др.), строками не склеиваются.
- Follow-ups #36 (флейк интеграционного теста O-1, дубли `extra` O-2) — не здесь.
## Capabilities
### New Capabilities
### Modified Capabilities
- `osv-parsing`: требование «Шапка документа и схема группировок» (подсказка `<группировка>.ИНН` в обоих порядках);
требование «Классификация строк» (переписано целиком: ИНН из колонки, строки ИНН, хвоста названия); новое требование
«Строки ИНН рядом с контрагентом».
## Impact
- Документы с ИНН строкой, у которых контрольная сумма ИНН неверна и подсказки в шапке нет, переходят из `done` в
`needs_check` (решение владельца). Какие реальные файлы fixtures затронуты, покажет сравнение main и ветки на всех
файлах; каждое отличие golden согласуется с владельцем до `make golden-update`.
- Файл банка Новопетровской (#32, подсказка в шапке) MUST NOT измениться.
- После выкладки: пересобрать контейнер парсера на стенде; в ядре правок нет.

View File

@ -0,0 +1,196 @@
## MODIFIED Requirements
### Requirement: Шапка документа и схема группировок
Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (первая непустая текстовая строка, trim),
`account_code_raw` (после «по счету»/«по счёту», как в файле: «60», «58.03»), `period_text` (после «за» до «г.»/конца),
`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
`has_inn = true`. Элемент вида `<группировка>.ИНН` (суффикс после последней точки — по словарю `service_columns.inn`,
префикс — имя группировки `COUNTERPARTY` этой же шапки; вариант 2 — элемент списка «субконто: …», вариант 1 — строка блока
заголовка) MUST NOT попадать в `group_schema`: группировка-владелец получает `has_inn = true`, а элемент служит
**подсказкой** для строк ИНН (требование «Строки ИНН рядом с контрагентом»): стоит после владельца — ИНН под
наименованием, перед владельцем — над. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по
`rules/periods.yaml` (§1.5, `ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме
обязательных по контракту, — `null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и
предупреждение (контракт требует строку).
#### Scenario: Вариант 1 с тремя строками группировок
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** `group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)]`, `header_variant = 1`,
`account_code_raw = "62"`, `period_text = "3 квартал 2024 г."`, `period_quarter = "Q3"`, `is_cumulative = false`,
`data_type = "BU"`, `currency = "RUB"`
#### Scenario: ОСВ по субсчёту и годовой период
- **WHEN** разбираются `rb/2024-Q3/сч 583_3 кв 2024.xlsx` и `rb/2024-FY/ОСВ_66 за 2024.xls`
- **THEN** у первого `account_code_raw = "58.03"`; у второго `period_quarter = "Q4"`, `is_cumulative = true`
#### Scenario: Вариант 2
- **WHEN** разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто:
Контрагенты, Договоры», заголовком «Субконто»
- **THEN** `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = "Q1"`
#### Scenario: Реквизит ИНН после группировки
- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` (шапка «субконто: Контрагенты,
Контрагенты.ИНН»)
- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН под наименованием»
#### Scenario: Реквизит ИНН перед группировкой
- **WHEN** в синтетической ОСВ шапка «субконто: Контрагенты.ИНН, Контрагенты»
- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН над наименованием»
### Requirement: Классификация строк
Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с
контрагентом»). Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр
учитывается, IGNORECASE нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или** 1–3
заглавные буквы кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название:
`^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой
пробел или его отсутствие, R-18; цифры — только 0–9; название после запятой может занимать несколько строк ячейки — M-1);
`account_code_raw` — код как в файле, без названия после запятой. Не-счёт — группировка из `group_schema` по позиции
(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; строка ИНН без пары — OTHER;
последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL.
Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` —
исходный текст; OQ-100), а `counterparty_inn` — первым найденным из:
1. колонки ИНН: цифры текстовой ячейки («ИНН 352819549855» → «352819549855», R-15), склеенная «ИНН/КПП» (`inn_kpp_cell`)
→ ИНН; результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр (ИНН регионов 01–09 с потерянным
ведущим нулём) дополняется нулём слева (R-24);
2. строки ИНН, склеенной с этой строкой (требование «Строки ИНН рядом с контрагентом»);
3. конца текста названия: 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»
(`counterparty_inn_suffix`; «Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»).
Контрольная сумма ИНН из колонки и из названия MUST NOT проверяться. Строки «Вал.», «Кол.», трёхбуквенного кода валюты
SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST NOT занимать позицию в `group_schema` и MUST NOT
наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки, R-19); их суммы остаются в
шести полях. Остальные строки получают `data_type` шапки. Все строки, включая TOTAL, SHALL присутствовать в `rows`
(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро); склеенные строки ИНН в `rows` MUST NOT попадать.
#### Scenario: Подразделение, контрагент с ИНН, физлицо
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
«Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
#### Scenario: ИНН в конце названия
- **WHEN** разбирается синтетическая ОСВ `s17` (ИНН в тексте названия с разными разделителями)
- **THEN** у контрагентов `counterparty_name` без ИНН, `counterparty_inn` — ИНН из текста, `row_value` — исходный текст
#### Scenario: «ИНН/КПП» в одной ячейке колонки ИНН
- **WHEN** разбирается синтетическая ОСВ `s19`
- **THEN** `counterparty_inn` — 10 цифр до «/»
#### Scenario: Название субсчёта в строке счёта
- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
#### Scenario: Буквенный субсчёт
- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
#### Scenario: Строки Вал./Кол.
- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`,
`account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done`
## ADDED Requirements
### Requirement: Строки ИНН рядом с контрагентом
Между построением иерархии и классификацией парсер SHALL найти строки ИНН и сопоставить каждую с её строкой-наименованием
в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве
(сосед того же родителя) × с суммами / без сумм.
**Строка ИНН** — не счёт, не «Итого», не строка Вал./Кол., подпись которой целиком — ИНН по словарю `inn_only_row`
(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр
дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН
(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН:
она классифицируется как обычно, в `warnings` — «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка
оставлена как есть», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол.,
не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); такой ребёнок бывает только у наименования, которое
по схеме — COUNTERPARTY (договоры под ним остаются договорами, design #22); при другой позиции по схеме (подразделение и
т. д.) ребёнок — подлинное наименование; если позиции по схеме нет, ребёнком MUST быть только строка без детей (лист,
design #14).
**Пара строки ИНН** (связь в дереве важнее соседства):
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
`group_schema` больше одной группировки, ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы и в шапке нет
подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16);
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара —
родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт). Строка ИНН с детьми под
наименованием при подсказке «над» и родителе-контрагенте по схеме остаётся у родителя (дети — договоры), а
противоречие подсказке подсвечивается (design #23);
- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению);
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление
листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных
блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой
ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары (design #26).
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —
«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся,
в `warnings` — расхождение, документ — `needs_check`.
**Противоречие подсказке шапки:** если пара найдена, но фактическая раскладка противоречит направлению подсказки
`<группировка>.ИНН` (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него по суммам; подсказка «под»,
а строка ИНН — родитель наименования или сосед выше него по суммам), строка ИНН MUST склеиваться как обычно, а в
`warnings` MUST быть отдельное предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка
(«<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся
из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт.
**Страховка после классификации:** ИНН склеен со строкой, которая по схеме не стала `COUNTERPARTY`, — в `warnings` «строка N:
ИНН … отнесён к строке M, но она не контрагент — проверьте», документ — `needs_check` (design #24).
**Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N:
ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться.
#### Scenario: Восемь раскладок без подсказки
- **WHEN** разбираются синтетические ОСВ `s20`…`s27` (по одной на раскладку; ИНН с верной контрольной суммой; шапка без
`Контрагенты.ИНН`)
- **THEN** у каждого контрагента `counterparty_inn` — ИНН его строки ИНН, строк ИНН в `rows` нет, контрагентов столько же,
сколько наименований, контрольные суммы сходятся, статус `done`
#### Scenario: ИНН под наименованием не уходит следующему контрагенту
- **WHEN** в синтетической ОСВ `s27` (ИНН под наименованием, не в дереве, без сумм) идут «ООО Альфа», ИНН Альфы,
«ООО Бета», ИНН Беты
- **THEN** ИНН Альфы — у «ООО Альфа», ИНН Беты — у «ООО Бета»
#### Scenario: ИНН над наименованием (OQ-130)
- **WHEN** разбирается синтетическая ОСВ `s18` (строка ИНН без сумм над наименованием, ИНН с верной контрольной суммой)
- **THEN** ИНН — у контрагента под строкой, строк ИНН в `rows` нет, статус `done`
#### Scenario: Реквизит «Контрагенты.ИНН» (OQ-162)
- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls`
- **THEN** результат совпадает с golden до изменения: 713 из 716 контрагентов с ИНН (включая ИНН с неверной контрольной
суммой), 3 физлица без ИНН, статус `done`
#### Scenario: Неверная контрольная сумма без подсказки
- **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится)
- **THEN** строка не склеивается, в `warnings` — «похожа на ИНН, контрольная сумма не сходится», статус `needs_check`
#### Scenario: Опечатка в ИНН среди верных (s28)
- **WHEN** разбирается синтетическая ОСВ `s28` (раскладка `s20`, но у ООО Альфа строка ИНН «7701234567» с неверной
контрольной суммой; шапка без подсказки)
- **THEN** строка «7701234567» остаётся обычной строкой, у ООО Альфа `counterparty_inn` нет, в `warnings` — «строка N
похожа на ИНН 7701234567, но контрольная сумма не сходится», остальные контрагенты получают свои ИНН (направление «над» —
по блоку 60.02), статус `needs_check`
#### Scenario: Раскладка противоречит подсказке шапки
- **WHEN** в шапке `Контрагенты.ИНН` («над»), а строка ИНН без детей стоит под родителем-контрагентом (или наоборот:
подсказка «под», строка ИНН — родитель наименования)
- **THEN** строка ИНН склеена с этим наименованием, в `warnings` — предупреждение «стоит под (над) наименованием … а шапка
… указывает над (под) — проверьте», статус `needs_check`
#### Scenario: Ведущий ноль у числовой строки ИНН
- **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму)
- **THEN** `counterparty_inn = "0123456788"`
#### Scenario: Неоднозначное направление
- **WHEN** под счётом блок «наименование, ИНН без сумм, наименование» и других сведений о направлении нет
- **THEN** строка ИНН без пары, `group_type = OTHER`, предупреждение «направление неизвестно», статус `needs_check`
#### Scenario: Несколько детей у строки ИНН
- **WHEN** у строки ИНН в дереве два ребёнка-наименования
- **THEN** строка ИНН без пары, `group_type = OTHER`, статус `needs_check`

View File

@ -0,0 +1,18 @@
## 1. Код (tabular-parser)
- [x] 1.1 Контрольная сумма ИНН и ИНН строки (`numbers.py`)
- [x] 1.2 Модуль `inn_rows.py`: склейка в дереве, контрольная сумма, защита «над, в дереве»
- [x] 1.3 Модуль `inn_rows.py`: соседи по суммам и направление листа
- [x] 1.4 Подключение в `osv.py`, подсказка шапки в обоих порядках, удаление `_glue_inn_rows` и `glue_inn_attributes`
- [x] 1.5 H-2: предупреждение о противоречии раскладки подсказке шапки (`needs_check`)
## 2. Данные (fixtures)
- [x] 2.1 Синтетика `s20`–`s27`, `s18` с верными ИНН
- [x] 2.2 Синтетика `s28` (H-1: неверная контрольная сумма среди верных ИНН)
- [x] 2.3 Сравнение main и ветки на всех fixtures, golden после согласования с владельцем
## 3. Документация
- [x] 3.1 docs: `05-domain-rules.md` §1.1, §1.3 (с H-1 и H-2), OQ-163
- [x] 3.2 README и CLAUDE.md tabular-parser