@ -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` — только по контрольным суммам, структуру под сходимость не
|
||||
|
||||
19
README.md
19
README.md
@ -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`
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
484
api/src/parser/inn_rows.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
# --- внутреннее ---
|
||||
|
||||
|
||||
25
api/tests/fixtures/real_osv.py
vendored
25
api/tests/fixtures/real_osv.py
vendored
@ -17,8 +17,9 @@ from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
||||
from src.parser.grid import Sheet
|
||||
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||
from src.parser.header import DocHeader, build_group_levels, parse_doc_header
|
||||
from src.parser.hierarchy import BodyRow, Levels, resolve_levels
|
||||
from src.parser.inn_rows import glue_inn_rows
|
||||
from src.parser.readers import read_book
|
||||
from src.parser.rules import load_rules
|
||||
|
||||
@ -61,9 +62,25 @@ def parse_real(path: Path) -> RealParse:
|
||||
deadline = Deadline(PARSE_TIMEOUT_SECONDS)
|
||||
header = find_table_header(sheet, rules)
|
||||
doc = parse_doc_header(sheet, header, rules)
|
||||
schema, _ = build_group_schema(sheet, header, rules)
|
||||
schema = build_group_levels(sheet, header, rules)
|
||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||
levels = resolve_levels(body, total_row, deadline)
|
||||
rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
|
||||
glue = glue_inn_rows(
|
||||
body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules
|
||||
)
|
||||
rows = classify_rows(
|
||||
glue.rows,
|
||||
glue.levels,
|
||||
schema.groups,
|
||||
header,
|
||||
sheet,
|
||||
doc.data_type,
|
||||
total_row,
|
||||
rules,
|
||||
inns=glue.inns,
|
||||
unpaired=glue.unpaired,
|
||||
)
|
||||
seconds = time.monotonic() - started
|
||||
return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds)
|
||||
return RealParse(
|
||||
sheet, header, doc, schema.groups, glue.rows, total_row, glue.levels, rows, [*warnings, *glue.warnings], seconds
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ---
|
||||
|
||||
624
api/tests/unit/parser/test_inn_rows.py
Normal file
624
api/tests/unit/parser/test_inn_rows.py
Normal 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, "шапка («")
|
||||
47
api/tests/unit/parser/test_inn_rows_osv.py
Normal file
47
api/tests/unit/parser/test_inn_rows_osv.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Строки ИНН на уровне листа ОСВ: страховка после классификации (R-8, design №24; change osv-inn-rows-layouts, #35)."""
|
||||
|
||||
import pytest
|
||||
from gridbuilder import L, M, sheet_from_rows
|
||||
|
||||
from src.parser.deadline import Deadline
|
||||
from src.parser.osv import OsvParser
|
||||
from src.parser.rules import Rules, load_rules
|
||||
|
||||
TIMEOUT_SECONDS = 60
|
||||
DETAIL = "Детализация по субсчетам, субконто: Подразделения, Контрагенты"
|
||||
A = (None, None, 760000, 760000, None, None)
|
||||
INN_A = 7701234560
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rules() -> Rules:
|
||||
return load_rules()
|
||||
|
||||
|
||||
def test_inn_glued_to_row_that_is_not_a_counterparty_needs_check(rules: Rules) -> None:
|
||||
head: list[list[object]] = [
|
||||
["ООО Зерновая"],
|
||||
["Оборотно-сальдовая ведомость по счету 60"],
|
||||
["Период: 9 месяцев 2025 г."],
|
||||
[DETAIL],
|
||||
["Выводимые данные: сумма"],
|
||||
[],
|
||||
["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M("Сальдо на конец периода", 2)],
|
||||
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
|
||||
]
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
*head,
|
||||
[L(0), "60.01", *A], # 9
|
||||
[L(1), INN_A, *A], # 10
|
||||
[L(1), "ООО Альфа", *A], # 11
|
||||
[L(0), "Итого", *A],
|
||||
]
|
||||
)
|
||||
|
||||
document, warnings, ok = OsvParser(rules).parse_sheet(sheet, Deadline(TIMEOUT_SECONDS))
|
||||
|
||||
assert [(row.row_value, row.group_type) for row in document.rows][1:2] == [("ООО Альфа", "DEPARTMENT")]
|
||||
assert not ok
|
||||
expected = f"строка 10: ИНН {INN_A} отнесён к строке 11, но она не контрагент — проверьте"
|
||||
assert expected in warnings, warnings
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
1899
docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md
Normal file
1899
docs/superpowers/plans/2026-10-13-osv-inn-rows-layouts.md
Normal file
File diff suppressed because it is too large
Load Diff
163
openspec/changes/osv-inn-rows-layouts/design.md
Normal file
163
openspec/changes/osv-inn-rows-layouts/design.md
Normal 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: тест на сочетание «расхождение с колонкой + противоречие подсказке» — одно предупреждение о расхождении | Недостижимый код не добавляется | Нет |
|
||||
61
openspec/changes/osv-inn-rows-layouts/proposal.md
Normal file
61
openspec/changes/osv-inn-rows-layouts/proposal.md
Normal 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 измениться.
|
||||
- После выкладки: пересобрать контейнер парсера на стенде; в ядре правок нет.
|
||||
196
openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md
Normal file
196
openspec/changes/osv-inn-rows-layouts/specs/osv-parsing/spec.md
Normal 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`
|
||||
18
openspec/changes/osv-inn-rows-layouts/tasks.md
Normal file
18
openspec/changes/osv-inn-rows-layouts/tasks.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user