Compare commits
No commits in common. "ad1a6ec10e6e08feb579c1583b248b0a7a4a309b" and "a2744b3e404f6534cbc36b25c9cf6c81356d7059" have entirely different histories.
ad1a6ec10e
...
a2744b3e40
@ -56,8 +56,8 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
- `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок
|
- `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок
|
||||||
(`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре.
|
(`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре.
|
||||||
- `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка
|
- `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка
|
||||||
и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `inn_rows.py` —
|
и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `numbers.py` —
|
||||||
строки ИНН рядом с контрагентом (OQ-163); `numbers.py` — `Decimal` и ИНН (контрольная сумма); `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` —
|
`Decimal` и ИНН; `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` —
|
||||||
оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла.
|
оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла.
|
||||||
- `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера.
|
- `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера.
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
||||||
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
||||||
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
||||||
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 123 файла: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 28 синтетических) — регрессия, а не
|
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 113 файлов: 72 реальных + 22 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 19 синтетических) — регрессия, а не
|
||||||
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
||||||
проверяют property-тест и ручная сверка «Итого».
|
проверяют property-тест и ручная сверка «Итого».
|
||||||
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
||||||
|
|||||||
19
README.md
19
README.md
@ -119,7 +119,6 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
|
| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
|
||||||
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` |
|
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` |
|
||||||
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
||||||
| `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации |
|
|
||||||
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
||||||
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (91, 99) — вне тождества строки (`osv_checks.yaml`) |
|
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (91, 99) — вне тождества строки (`osv_checks.yaml`) |
|
||||||
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
|
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
|
||||||
@ -166,9 +165,8 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
|
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
|
||||||
|
|
||||||
**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
|
**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
|
||||||
строки ИНН рядом с наименованием (любая из восьми раскладок, `inn_rows.py`, OQ-163: такая строка в `rows` не попадает, в
|
строки без сумм прямо над названием на том же уровне оформления (такая строка в `rows` не попадает, в
|
||||||
`metrics.warnings` — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием»
|
`metrics.warnings` — «ИНН … над строкой … отнесён к ней»), затем из конца текста названия («Полиграф, 3528315710»,
|
||||||
заменено), затем из конца текста названия («Полиграф, 3528315710»,
|
|
||||||
«ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН
|
«ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН
|
||||||
отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк
|
отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк
|
||||||
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
|
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
|
||||||
@ -176,14 +174,11 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
|
|
||||||
**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты,
|
**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты,
|
||||||
Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true`
|
Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true`
|
||||||
(владелец и направление — `GroupSchema.inn_hint`). Подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает
|
(`header.GroupSchema.positions` держит его позицию для классификатора). 1С выводит ИНН строкой под контрагентом, с теми же
|
||||||
проверку контрольной суммы. Склейку выполняет `inn_rows.py` (OQ-163): ИНН строки отдаётся наименованию (извлечение — как из
|
суммами; `classify.glue_inn_attributes` отдаёт ИНН контрагенту (извлечение — как из колонки ИНН, R-24), строку убирает,
|
||||||
колонки ИНН, R-24), строка убирается, её дети переходят на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …».
|
её детей поднимает на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …». Пустая ячейка (физлицо без ИНН) —
|
||||||
Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан, направление неизвестно) — строка `OTHER`,
|
склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан или расходится) — строка `OTHER`,
|
||||||
предупреждение с причиной, документ `needs_check`; ИНН строки, расходящийся с ИНН колонки у наименования, не применяется —
|
предупреждение с причиной, документ `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`).
|
||||||
остаётся ИНН колонки, предупреждение, `needs_check`; строка из 10/12 цифр с неверной контрольной суммой (без подсказки) остаётся
|
|
||||||
обычной строкой, «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка оставлена как есть», `needs_check`; раскладка, противоречащая
|
|
||||||
подсказке шапки, склеивается с отдельным предупреждением и `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`).
|
|
||||||
|
|
||||||
**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала —
|
**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала —
|
||||||
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
||||||
|
|||||||
@ -7,10 +7,9 @@
|
|||||||
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
|
счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
|
||||||
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
|
«Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
|
||||||
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
|
2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
|
||||||
нераспознанные колонки таблицы (`extra`).
|
нераспознанные колонки таблицы (`extra`);
|
||||||
|
3. `glue_inn_attributes` — строки реквизита «Контрагенты.ИНН» под контрагентом отдают ИНН ему и уходят из строк
|
||||||
ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`): между шагами 1 и 2
|
(OQ-162).
|
||||||
стоит проход склейки строк ИНН (#35).
|
|
||||||
|
|
||||||
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
|
||||||
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
|
||||||
@ -19,9 +18,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Sequence
|
||||||
from collections.abc import Set as AbstractSet
|
from dataclasses import dataclass, replace
|
||||||
from dataclasses import dataclass
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@ -30,8 +28,9 @@ from src.contract.result import OsvGroup
|
|||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
|
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.grid import Sheet, column_letter
|
||||||
|
from src.parser.header import InnAttribute
|
||||||
from src.parser.hierarchy import BodyRow, Levels
|
from src.parser.hierarchy import BodyRow, Levels
|
||||||
from src.parser.numbers import column_inn, split_inn_suffix, to_money
|
from src.parser.numbers import column_inn, inn_only, split_inn_suffix, to_money
|
||||||
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -42,7 +41,7 @@ __all__ = [
|
|||||||
"RowGroupType",
|
"RowGroupType",
|
||||||
"classify_rows",
|
"classify_rows",
|
||||||
"collect_body_rows",
|
"collect_body_rows",
|
||||||
"row_data_type",
|
"glue_inn_attributes",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
|
||||||
@ -82,6 +81,9 @@ class ClassifiedRow:
|
|||||||
counterparty_inn: str | None
|
counterparty_inn: str | None
|
||||||
data_type: DataType | None
|
data_type: DataType | None
|
||||||
extra: dict[str, str | None] | None
|
extra: dict[str, str | None] | None
|
||||||
|
# Строка реквизита «Контрагенты.ИНН» (OQ-162) и ИНН из её ячейки; склеивает `glue_inn_attributes`
|
||||||
|
is_inn_attribute: bool = False
|
||||||
|
attribute_inn: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Строки таблицы ---
|
# --- Строки таблицы ---
|
||||||
@ -188,6 +190,40 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu
|
|||||||
return tuple(sums.values())
|
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:
|
def _first_sum_col(header: TableHeader) -> int:
|
||||||
return min(header.columns.indexes().values())
|
return min(header.columns.indexes().values())
|
||||||
|
|
||||||
@ -275,7 +311,7 @@ def collect_body_rows(
|
|||||||
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||||
deadline.check("суммы строк")
|
deadline.check("суммы строк")
|
||||||
body.append(_body_row(sheet, row, header, label_cols, warnings))
|
body.append(_body_row(sheet, row, header, label_cols, warnings))
|
||||||
return body, total_row, warnings
|
return _glue_inn_rows(body, rules, warnings), total_row, warnings
|
||||||
|
|
||||||
|
|
||||||
# --- Классификация ---
|
# --- Классификация ---
|
||||||
@ -303,7 +339,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
|
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`."""
|
"""CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
|
||||||
found = first_match(rules.data_types.row, text)
|
found = first_match(rules.data_types.row, text)
|
||||||
if found is not None:
|
if found is not None:
|
||||||
@ -332,24 +368,26 @@ class _Kind:
|
|||||||
data_type: DataType | None
|
data_type: DataType | None
|
||||||
counterparty_name: str | None = None
|
counterparty_name: str | None = None
|
||||||
counterparty_inn: str | None = None
|
counterparty_inn: str | None = None
|
||||||
|
is_inn_attribute: bool = False
|
||||||
|
attribute_inn: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _Context:
|
class _Context:
|
||||||
schema: Sequence[OsvGroup]
|
schema: Sequence[OsvGroup | InnAttribute]
|
||||||
header: TableHeader
|
header: TableHeader
|
||||||
sheet: Sheet
|
sheet: Sheet
|
||||||
doc_data_type: DataType | None
|
doc_data_type: DataType | None
|
||||||
rules: Rules
|
rules: Rules
|
||||||
currency_codes: frozenset[str]
|
currency_codes: frozenset[str]
|
||||||
inns: Mapping[int, str]
|
|
||||||
unpaired: AbstractSet[int]
|
|
||||||
|
|
||||||
|
|
||||||
def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]:
|
def _schema_group(schema: Sequence[OsvGroup | InnAttribute], position: int) -> tuple[GroupType, str | None]:
|
||||||
if position >= len(schema):
|
if position >= len(schema):
|
||||||
return "OTHER", None
|
return "OTHER", None
|
||||||
group = schema[position]
|
group = schema[position]
|
||||||
|
if isinstance(group, InnAttribute):
|
||||||
|
return "OTHER", group.name
|
||||||
return group.type, group.name
|
return group.type, group.name
|
||||||
|
|
||||||
|
|
||||||
@ -363,37 +401,36 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
|
|||||||
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
|
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
|
||||||
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
|
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
|
||||||
code = inherited.account_code_raw
|
code = inherited.account_code_raw
|
||||||
data_type = row_data_type(item.text, context.rules, context.currency_codes)
|
row_data_type = _row_data_type(item.text, context.rules, context.currency_codes)
|
||||||
if data_type is not None:
|
if row_data_type is not None:
|
||||||
return _Kind("OTHER", None, code, data_type), inherited
|
return _Kind("OTHER", None, code, row_data_type), inherited
|
||||||
if item.row in context.unpaired:
|
|
||||||
# Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
|
|
||||||
return _Kind("OTHER", None, code, doc_data_type), inherited
|
|
||||||
position = inherited.depth + 1
|
position = inherited.depth + 1
|
||||||
group_type, name = _schema_group(context.schema, position)
|
group_type, name = _schema_group(context.schema, position)
|
||||||
branch = _Branch(code, 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":
|
if group_type != "COUNTERPARTY":
|
||||||
return _Kind(group_type, name, code, doc_data_type), branch
|
return _Kind(group_type, name, code, doc_data_type), branch
|
||||||
inn_col = context.header.inn_col
|
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)
|
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
|
||||||
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
|
# ИНН: колонка ИНН, затем строка без сумм над названием (OQ-130), затем хвост самого названия (OQ-100)
|
||||||
counterparty, in_name = split_inn_suffix(item.text, context.rules)
|
counterparty, in_name = split_inn_suffix(item.text, context.rules)
|
||||||
inn = column or context.inns.get(item.row) or in_name
|
inn = column or item.inn_above or in_name
|
||||||
return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch
|
return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch
|
||||||
|
|
||||||
|
|
||||||
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
||||||
rows: Sequence[BodyRow],
|
rows: Sequence[BodyRow],
|
||||||
levels: Levels,
|
levels: Levels,
|
||||||
schema: Sequence[OsvGroup],
|
schema: Sequence[OsvGroup | InnAttribute],
|
||||||
header: TableHeader,
|
header: TableHeader,
|
||||||
sheet: Sheet,
|
sheet: Sheet,
|
||||||
doc_data_type: DataType | None,
|
doc_data_type: DataType | None,
|
||||||
total_row: int | None,
|
total_row: int | None,
|
||||||
rules: Rules,
|
rules: Rules,
|
||||||
*,
|
|
||||||
inns: Mapping[int, str] | None = None,
|
|
||||||
unpaired: AbstractSet[int] = frozenset(),
|
|
||||||
) -> list[ClassifiedRow]:
|
) -> list[ClassifiedRow]:
|
||||||
"""Классификация строк по дереву `levels` и схеме группировок.
|
"""Классификация строк по дереву `levels` и схеме группировок.
|
||||||
|
|
||||||
@ -404,11 +441,9 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
||||||
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
|
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
|
||||||
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
|
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
|
||||||
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`.
|
- позиция реквизита ИНН (`InnAttribute`, `GroupSchema.positions`) — OTHER с `is_inn_attribute` и ИНН ячейки.
|
||||||
"""
|
"""
|
||||||
context = _Context(
|
context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes))
|
||||||
schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired
|
|
||||||
)
|
|
||||||
extra_keys = _extra_keys(sheet, header, rows)
|
extra_keys = _extra_keys(sheet, header, rows)
|
||||||
branches: dict[int, _Branch] = {}
|
branches: dict[int, _Branch] = {}
|
||||||
result: list[ClassifiedRow] = []
|
result: list[ClassifiedRow] = []
|
||||||
@ -436,6 +471,82 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
counterparty_inn=kind.counterparty_inn,
|
counterparty_inn=kind.counterparty_inn,
|
||||||
data_type=kind.data_type,
|
data_type=kind.data_type,
|
||||||
extra=_extra(sheet, item.row, extra_keys),
|
extra=_extra(sheet, item.row, extra_keys),
|
||||||
|
is_inn_attribute=kind.is_inn_attribute,
|
||||||
|
attribute_inn=kind.attribute_inn,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result
|
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 calendar
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable, Sequence
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Literal, get_args
|
from typing import Any, Literal, get_args
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ __all__ = [
|
|||||||
"ACCOUNT_NOT_FOUND_WARNING",
|
"ACCOUNT_NOT_FOUND_WARNING",
|
||||||
"DocHeader",
|
"DocHeader",
|
||||||
"GroupSchema",
|
"GroupSchema",
|
||||||
"InnHint",
|
"InnAttribute",
|
||||||
"build_group_levels",
|
"build_group_levels",
|
||||||
"build_group_schema",
|
"build_group_schema",
|
||||||
"parse_doc_header",
|
"parse_doc_header",
|
||||||
@ -482,83 +482,87 @@ def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class InnHint:
|
class InnAttribute:
|
||||||
"""Подсказка шапки для строк ИНН (OQ-162, #35): элемент `<группировка>.ИНН` как в файле и направление по порядку
|
"""Реквизит ИНН группировки контрагентов («Контрагенты.ИНН», OQ-162): 1С выводит его строкой под строкой
|
||||||
элементов — после группировки-владельца ИНН стоит под наименованием (`below`), перед ней — над (`above`).
|
контрагента, на уровень глубже. Это не уровень аналитики: в `group_schema` контракта его нет."""
|
||||||
|
|
||||||
С подсказкой контрольная сумма ИНН строки не проверяется (`inn_rows`)."""
|
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
direction: Literal["above", "below"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GroupSchema:
|
class GroupSchema:
|
||||||
"""Группировки контракта (`groups`), вариант шапки и подсказка для строк ИНН (`<группировка>.ИНН`, OQ-162, #35)."""
|
"""Группировки контракта (`groups`) и позиции строк таблицы под счётом (`positions`).
|
||||||
|
|
||||||
|
`positions[i]` — группировка строки на глубине i (0 — ACCOUNT) или реквизит ИНН, который глубину занимает,
|
||||||
|
а группировкой не является. Без реквизитов `positions` совпадает с `groups`.
|
||||||
|
"""
|
||||||
|
|
||||||
groups: list[OsvGroup]
|
groups: list[OsvGroup]
|
||||||
|
positions: list[OsvGroup | InnAttribute]
|
||||||
variant: HeaderVariant
|
variant: HeaderVariant
|
||||||
inn_hint: InnHint | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _attribute_owner(item: str, rules: Rules) -> str | None:
|
def _inn_owner(item: str, groups: list[OsvGroup], rules: Rules) -> int | None:
|
||||||
"""Имя группировки-владельца, если `item` — реквизит ИНН («Контрагенты.ИНН» → «контрагенты»); иначе `None`."""
|
"""Индекс группировки контрагентов, чей реквизит ИНН — `item` («Контрагенты.ИНН»); иначе `None`."""
|
||||||
owner, separator, attribute = item.rpartition(_ATTRIBUTE_SEPARATOR)
|
owner, separator, attribute = item.rpartition(_ATTRIBUTE_SEPARATOR)
|
||||||
if not separator or _find(rules.headers.service_columns["inn"], attribute) is None:
|
if not separator or _find(rules.headers.service_columns["inn"], attribute) is None:
|
||||||
return None
|
return None
|
||||||
return owner.strip().casefold()
|
owner = owner.strip().casefold()
|
||||||
|
for index, group in enumerate(groups):
|
||||||
|
if group.type == "COUNTERPARTY" and (group.name or "").casefold() == owner:
|
||||||
def _inn_hint(items: Sequence[str], rules: Rules) -> InnHint | None:
|
return index
|
||||||
"""Подсказка строк ИНН по перечню группировок шапки (без счёта): первый реквизит `<группировка>.ИНН`, чей владелец —
|
|
||||||
группировка контрагентов того же перечня. Стоит после владельца — ИНН под наименованием, перед — над."""
|
|
||||||
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _owns_hint(item: str, hint: InnHint | None, rules: Rules) -> bool:
|
def _attach_inn_attribute(item: str, groups: list[OsvGroup], slots: list[int | InnAttribute], rules: Rules) -> bool:
|
||||||
return hint is not None and _attribute_owner(hint.name, rules) == item.strip().casefold()
|
"""`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 _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema:
|
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] = []
|
groups: list[OsvGroup] = []
|
||||||
for index, (row, label) in enumerate(zip(header.group_label_rows, labels, strict=True)):
|
slots: list[int | InnAttribute] = []
|
||||||
if index > 0 and hint is not None and label == hint.name:
|
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):
|
||||||
continue
|
continue
|
||||||
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
|
# Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
|
||||||
group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
|
group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
|
||||||
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=_row_has_inn(sheet, row, rules)))
|
||||||
groups.append(_group(group_type, label, has_inn=has_inn))
|
slots.append(len(groups) - 1)
|
||||||
if not groups:
|
if not groups:
|
||||||
groups = [_group("ACCOUNT")]
|
groups, slots = [_group("ACCOUNT")], [0]
|
||||||
return GroupSchema(groups, 1, hint)
|
return GroupSchema(groups, _positions(groups, slots), 1)
|
||||||
|
|
||||||
|
|
||||||
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
|
def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema:
|
||||||
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
|
||||||
hint = _inn_hint(items, rules)
|
slots: list[int | InnAttribute] = [0]
|
||||||
inn_pending = header.inn_col is not None
|
inn_pending = header.inn_col is not None
|
||||||
for item in items:
|
for item in items:
|
||||||
if hint is not None and item == hint.name:
|
if _attach_inn_attribute(item, groups, slots, rules):
|
||||||
continue
|
continue
|
||||||
group_type = rules.groups.classify(item)
|
group_type = rules.groups.classify(item)
|
||||||
has_inn = inn_pending and group_type == "COUNTERPARTY"
|
has_inn = inn_pending and group_type == "COUNTERPARTY"
|
||||||
inn_pending = inn_pending and not has_inn
|
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))
|
groups.append(_group(group_type, item, has_inn=has_inn))
|
||||||
return GroupSchema(groups, 2, hint)
|
slots.append(len(groups) - 1)
|
||||||
|
return GroupSchema(groups, _positions(groups, slots), 2)
|
||||||
|
|
||||||
|
|
||||||
def build_group_levels(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSchema:
|
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)
|
items = _subkonto_items(_head_cells(sheet, header), rules)
|
||||||
if items is None:
|
if items is None:
|
||||||
return _variant1_groups(sheet, header, rules)
|
return _variant1_groups(sheet, header, rules)
|
||||||
|
|||||||
@ -59,6 +59,7 @@ class BodyRow:
|
|||||||
outline: int
|
outline: int
|
||||||
sums: tuple[Decimal | None, ...]
|
sums: tuple[Decimal | None, ...]
|
||||||
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
|
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
|
||||||
|
inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@ -1,484 +0,0 @@
|
|||||||
"""Строки ИНН рядом с контрагентом (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,17 +17,7 @@ from src.parser.detect import INN_LENGTHS, inn_from_cell
|
|||||||
from src.parser.grid import Cell
|
from src.parser.grid import Cell
|
||||||
from src.parser.rules import Rules, source_offset
|
from src.parser.rules import Rules, source_offset
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["MONEY_Q", "MoneyParse", "column_inn", "inn_digits", "inn_only", "split_inn_suffix", "to_money"]
|
||||||
"MONEY_Q",
|
|
||||||
"MoneyParse",
|
|
||||||
"column_inn",
|
|
||||||
"inn_checksum_ok",
|
|
||||||
"inn_digits",
|
|
||||||
"inn_only",
|
|
||||||
"row_inn",
|
|
||||||
"split_inn_suffix",
|
|
||||||
"to_money",
|
|
||||||
]
|
|
||||||
|
|
||||||
MONEY_Q = Decimal("0.01")
|
MONEY_Q = Decimal("0.01")
|
||||||
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
|
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
|
||||||
@ -56,15 +46,6 @@ _MINUS_SIGN = "−"
|
|||||||
_SINGLE_DIGIT_RUN = re.compile(r"^\D*([0-9]+(?:[\s\u00a0\u202f][0-9]+)*)\D*$")
|
_SINGLE_DIGIT_RUN = re.compile(r"^\D*([0-9]+(?:[\s\u00a0\u202f][0-9]+)*)\D*$")
|
||||||
_DIGIT_GROUP_SEPARATOR = re.compile(r"[\s\u00a0\u202f]")
|
_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):
|
class MoneyParse(NamedTuple):
|
||||||
value: Decimal | None
|
value: Decimal | None
|
||||||
@ -196,36 +177,3 @@ def inn_only(text: str, rules: Rules) -> str | None:
|
|||||||
if match is not None:
|
if match is not None:
|
||||||
return match.group("inn")
|
return match.group("inn")
|
||||||
return None
|
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,14 +12,13 @@ from dataclasses import replace
|
|||||||
from src.contract import OsvDocument
|
from src.contract import OsvDocument
|
||||||
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
|
||||||
from src.parser.checks import run_checks
|
from src.parser.checks import run_checks
|
||||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
|
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows, glue_inn_attributes
|
||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.detect import TableHeader, find_table_header
|
from src.parser.detect import TableHeader, find_table_header
|
||||||
from src.parser.errors import InvalidFormatError
|
from src.parser.errors import InvalidFormatError
|
||||||
from src.parser.grid import Sheet, column_letter
|
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.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
|
||||||
from src.parser.hierarchy import resolve_levels
|
from src.parser.hierarchy import resolve_levels
|
||||||
from src.parser.inn_rows import InnGlue, glue_inn_rows
|
|
||||||
from src.parser.rules import Rules
|
from src.parser.rules import Rules
|
||||||
|
|
||||||
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
|
__all__ = ["OsvParser", "data_rows", "sheet_line", "unique"]
|
||||||
@ -126,20 +125,6 @@ def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> Invalid
|
|||||||
return InvalidFormatError(f"лист «{sheet.name}», шаг «{step}»: {reason}")
|
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:
|
class OsvParser:
|
||||||
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
|
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
|
||||||
|
|
||||||
@ -162,45 +147,23 @@ class OsvParser:
|
|||||||
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||||
step = "иерархия строк"
|
step = "иерархия строк"
|
||||||
levels = resolve_levels(body, total_row, deadline)
|
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 = "классификация строк"
|
step = "классификация строк"
|
||||||
classified = classify_rows(
|
classified = classify_rows(body, levels, schema.positions, header, sheet, doc.data_type, total_row, rules)
|
||||||
glue.rows,
|
classified, inn_warnings, inn_glued = glue_inn_attributes(classified)
|
||||||
glue.levels,
|
|
||||||
schema.groups,
|
|
||||||
header,
|
|
||||||
sheet,
|
|
||||||
doc.data_type,
|
|
||||||
total_row,
|
|
||||||
rules,
|
|
||||||
inns=glue.inns,
|
|
||||||
unpaired=glue.unpaired,
|
|
||||||
)
|
|
||||||
except InvalidFormatError as exc:
|
except InvalidFormatError as exc:
|
||||||
raise _invalid_format(sheet, step, exc) from exc
|
raise _invalid_format(sheet, step, exc) from exc
|
||||||
glue = _check_inn_owners(glue, classified)
|
|
||||||
doc = _with_table_account(doc, classified)
|
doc = _with_table_account(doc, classified)
|
||||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||||
checks, check_warnings = run_checks(classified, rules.checks)
|
checks, check_warnings = run_checks(classified, rules.checks)
|
||||||
document = self._document(
|
document = self._document(
|
||||||
sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks
|
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, *glue.warnings]
|
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings, *inn_warnings]
|
||||||
if not any(item.group_type != "TOTAL" for item in classified):
|
if not any(item.group_type != "TOTAL" for item in classified):
|
||||||
warnings.append(NO_DATA_ROWS_WARNING)
|
warnings.append(NO_DATA_ROWS_WARNING)
|
||||||
warnings.extend(check_warnings)
|
warnings.extend(check_warnings)
|
||||||
# Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check
|
# Несклеенный реквизит «Контрагенты.ИНН» (OQ-162) — строка, которую нужно проверить глазами: needs_check
|
||||||
return document, unique(warnings), _is_ok(checks) and glue.ok
|
return document, unique(warnings), _is_ok(checks) and inn_glued
|
||||||
|
|
||||||
# --- внутреннее ---
|
# --- внутреннее ---
|
||||||
|
|
||||||
|
|||||||
25
api/tests/fixtures/real_osv.py
vendored
25
api/tests/fixtures/real_osv.py
vendored
@ -17,9 +17,8 @@ from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows
|
|||||||
from src.parser.deadline import Deadline
|
from src.parser.deadline import Deadline
|
||||||
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
||||||
from src.parser.grid import Sheet
|
from src.parser.grid import Sheet
|
||||||
from src.parser.header import DocHeader, build_group_levels, parse_doc_header
|
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
|
||||||
from src.parser.hierarchy import BodyRow, Levels, resolve_levels
|
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.readers import read_book
|
||||||
from src.parser.rules import load_rules
|
from src.parser.rules import load_rules
|
||||||
|
|
||||||
@ -62,25 +61,9 @@ def parse_real(path: Path) -> RealParse:
|
|||||||
deadline = Deadline(PARSE_TIMEOUT_SECONDS)
|
deadline = Deadline(PARSE_TIMEOUT_SECONDS)
|
||||||
header = find_table_header(sheet, rules)
|
header = find_table_header(sheet, rules)
|
||||||
doc = parse_doc_header(sheet, header, rules)
|
doc = parse_doc_header(sheet, header, rules)
|
||||||
schema = build_group_levels(sheet, header, rules)
|
schema, _ = build_group_schema(sheet, header, rules)
|
||||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||||
levels = resolve_levels(body, total_row, deadline)
|
levels = resolve_levels(body, total_row, deadline)
|
||||||
glue = glue_inn_rows(
|
rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
|
||||||
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
|
seconds = time.monotonic() - started
|
||||||
return RealParse(
|
return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds)
|
||||||
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/`.
|
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
||||||
|
|
||||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||||||
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание —
|
||||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории
|
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
|
||||||
fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
|
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
|
||||||
метки времени книги и zip постоянны, порядок записи фиксирован.
|
записи фиксирован.
|
||||||
|
|
||||||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||||||
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
||||||
@ -69,11 +69,9 @@ class Node:
|
|||||||
num_style: NumStyle = "num"
|
num_style: NumStyle = "num"
|
||||||
inn_style: InnStyle = "column"
|
inn_style: InnStyle = "column"
|
||||||
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
|
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
|
||||||
blank: bool = False # строка без сумм (строка ИНН без сумм, #35)
|
|
||||||
twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит
|
|
||||||
|
|
||||||
def children(self) -> list["Node"]:
|
def children(self) -> list["Node"]:
|
||||||
return [kid for kid in self.kids if not kid.indicator and kid.twin is None]
|
return [kid for kid in self.kids if not kid.indicator]
|
||||||
|
|
||||||
def six(self) -> Six:
|
def six(self) -> Six:
|
||||||
"""Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи)."""
|
"""Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи)."""
|
||||||
@ -367,7 +365,7 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
|
|||||||
inn_line = Node(node.inn)
|
inn_line = Node(node.inn)
|
||||||
_row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True)
|
_row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True)
|
||||||
next_row += 1
|
next_row += 1
|
||||||
_row_cells(sheet, spec, next_row, (node, depth), (node.twin or node).six(), blank=node.blank)
|
_row_cells(sheet, spec, next_row, (node, depth), node.six())
|
||||||
next_row += 1
|
next_row += 1
|
||||||
total = SheetSpec("")
|
total = SheetSpec("")
|
||||||
_row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
|
_row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
|
||||||
@ -630,13 +628,10 @@ def _s17() -> list[SheetSpec]:
|
|||||||
|
|
||||||
|
|
||||||
def _s18() -> list[SheetSpec]:
|
def _s18() -> list[SheetSpec]:
|
||||||
"""ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.
|
"""ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет."""
|
||||||
ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35)."""
|
root = Node(
|
||||||
first = alfa(inn_style="above")
|
"60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(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)]
|
return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
|
||||||
|
|
||||||
|
|
||||||
@ -650,58 +645,6 @@ def _s19() -> list[SheetSpec]:
|
|||||||
return [build_osv(spec, root)]
|
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:
|
def _normalized_zip(data: bytes) -> bytes:
|
||||||
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
|
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
|
||||||
при каждом `save`, заданное в свойствах значение перезаписывает)."""
|
при каждом `save`, заданное в свойствах значение перезаписывает)."""
|
||||||
@ -770,15 +713,6 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
|
|||||||
"s17_inn_in_name.xlsx": (_s17, to_xlsx),
|
"s17_inn_in_name.xlsx": (_s17, to_xlsx),
|
||||||
"s18_inn_row_above.xlsx": (_s18, to_xlsx),
|
"s18_inn_row_above.xlsx": (_s18, to_xlsx),
|
||||||
"s19_inn_kpp_cell.xlsx": (_s19, 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)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -797,7 +731,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="python -m tests.synthetic.generate",
|
prog="python -m tests.synthetic.generate",
|
||||||
description="Синтетические ОСВ s01…s28 (xlsx и один xlsb) в указанный каталог.",
|
description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.",
|
||||||
)
|
)
|
||||||
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@ -26,18 +26,6 @@ def _require_synthetic(synthetic_dir: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
SYNTHETIC_DIR = Path("osv") / "synthetic"
|
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 = [
|
EXPECTED_NAMES = [
|
||||||
"s01_variant2_header.xlsx",
|
"s01_variant2_header.xlsx",
|
||||||
"s02_no_outline_indent.xlsx",
|
"s02_no_outline_indent.xlsx",
|
||||||
@ -58,8 +46,6 @@ EXPECTED_NAMES = [
|
|||||||
"s17_inn_in_name.xlsx",
|
"s17_inn_in_name.xlsx",
|
||||||
"s18_inn_row_above.xlsx",
|
"s18_inn_row_above.xlsx",
|
||||||
"s19_inn_kpp_cell.xlsx",
|
"s19_inn_kpp_cell.xlsx",
|
||||||
*INN_LAYOUT_FILES,
|
|
||||||
INN_BAD_CHECKSUM_FILE,
|
|
||||||
]
|
]
|
||||||
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
||||||
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
||||||
@ -123,10 +109,6 @@ EXPECTED: dict[str, Expect] = {
|
|||||||
"s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False),
|
"s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False),
|
||||||
# колонка «ИНН/КПП»
|
# колонка «ИНН/КПП»
|
||||||
"s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", 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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -198,7 +180,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
|
|||||||
|
|
||||||
|
|
||||||
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
|
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
|
||||||
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s28 (s13 — xlsb; допустима деградация: нет только его)."""
|
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s19 (s13 — xlsb; допустима деградация: нет только его)."""
|
||||||
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
|
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]
|
assert present == [name for name in EXPECTED_NAMES if name in present]
|
||||||
missing = [name for name in EXPECTED_NAMES if name not in present]
|
missing = [name for name in EXPECTED_NAMES if name not in present]
|
||||||
@ -542,62 +524,12 @@ def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None:
|
|||||||
doc = one_document(outcome)
|
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.group_type == "OTHER"]
|
||||||
assert not [row for row in doc.rows if row.row_value in {"7701234560", "5001234561"}]
|
assert not [row for row in doc.rows if row.row_value in {"7701234567", "5001234567"}]
|
||||||
counterparties = {
|
counterparties = {
|
||||||
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
|
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
|
||||||
}
|
}
|
||||||
assert counterparties == {ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"}
|
assert counterparties == {ALFA: "7701234567", BETA: None, "Иванов Иван": None, GAMMA: "5001234567"}
|
||||||
assert any("ИНН 7701234560" in warning for warning in warnings_of(outcome))
|
assert any("ИНН 7701234567" 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:
|
def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None:
|
||||||
|
|||||||
@ -17,9 +17,8 @@ from src.parser.deadline import Deadline
|
|||||||
from src.parser.detect import find_table_header
|
from src.parser.detect import find_table_header
|
||||||
from src.parser.errors import ParseTimeoutError
|
from src.parser.errors import ParseTimeoutError
|
||||||
from src.parser.grid import Sheet
|
from src.parser.grid import Sheet
|
||||||
from src.parser.header import build_group_levels
|
from src.parser.header import build_group_schema
|
||||||
from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_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
|
from src.parser.rules import Rules, load_rules
|
||||||
|
|
||||||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||||||
@ -61,25 +60,11 @@ def parse(
|
|||||||
) -> Parsed:
|
) -> Parsed:
|
||||||
deadline = deadline or Deadline(TIMEOUT_SECONDS)
|
deadline = deadline or Deadline(TIMEOUT_SECONDS)
|
||||||
header = find_table_header(sheet, rules)
|
header = find_table_header(sheet, rules)
|
||||||
schema = build_group_levels(sheet, header, rules)
|
schema, _ = build_group_schema(sheet, header, rules)
|
||||||
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
|
||||||
levels = resolve_levels(body, total_row, deadline)
|
levels = resolve_levels(body, total_row, deadline)
|
||||||
glue = glue_inn_rows(
|
rows = classify_rows(body, levels, schema, header, sheet, doc_data_type, total_row, rules)
|
||||||
body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules
|
return Parsed(rows, warnings, levels, total_row)
|
||||||
)
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры ---
|
# --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры ---
|
||||||
@ -629,7 +614,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:
|
def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
r(0, "60", S100),
|
r(0, "60", S100),
|
||||||
r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна)
|
r(1, "7701234567"), # ИНН строкой выше названия, без сумм
|
||||||
r(1, "ООО Поставщик", S100),
|
r(1, "ООО Поставщик", S100),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -639,67 +624,44 @@ def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
|
|||||||
supplier = parsed.rows[1]
|
supplier = parsed.rows[1]
|
||||||
assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == (
|
assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == (
|
||||||
"ООО Поставщик",
|
"ООО Поставщик",
|
||||||
"7701234560",
|
"7701234567",
|
||||||
ACCOUNT_ROW,
|
ACCOUNT_ROW,
|
||||||
)
|
)
|
||||||
assert any("7701234560" in warning for warning in parsed.warnings), parsed.warnings
|
assert any("ИНН" in warning and "7701234567" in warning for warning in parsed.warnings), parsed.warnings
|
||||||
|
|
||||||
|
|
||||||
def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None:
|
def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None:
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
r(0, "60", S100),
|
r(0, "60", S100),
|
||||||
r(1, "ИНН 7701234560 / 770101001"),
|
r(1, "ИНН 7701234567 / 770101001"),
|
||||||
r(1, "ООО Поставщик", S100),
|
r(1, "ООО Поставщик", S100),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560"
|
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234567"
|
||||||
|
|
||||||
|
|
||||||
def test_inn_row_above_in_tree_without_extra_level_is_not_glued(rules: Rules) -> None:
|
def test_inn_row_above_on_other_level_is_not_glued(rules: Rules) -> None:
|
||||||
# Схема «Контрагенты, Договоры», глубже схемы строк нет — «7701234560» контрагент, «ООО Поставщик» под ним (защита)
|
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
r(0, "60", S100),
|
r(0, "60", S100),
|
||||||
r(1, "7701234560", S100),
|
r(1, "7701234567"),
|
||||||
r(2, "ООО Поставщик", S100),
|
r(2, "ООО Поставщик", S100),
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = parse(sheet, rules)
|
parsed = parse(sheet, rules)
|
||||||
|
|
||||||
assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"]
|
assert [row.body.text for row in parsed.rows] == ["60", "7701234567", "ООО Поставщик"]
|
||||||
assert parsed.one("ООО Поставщик").counterparty_inn is None
|
assert parsed.one("ООО Поставщик").counterparty_inn is None
|
||||||
|
|
||||||
|
|
||||||
def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None:
|
def test_inn_row_above_before_row_without_sums_is_not_glued(rules: Rules) -> None:
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
r(0, "60", S100),
|
r(0, "60", S100),
|
||||||
r(1, "7701234560"),
|
r(1, "7701234567"),
|
||||||
r(1, "ООО Поставщик"),
|
r(1, "ООО Поставщик"),
|
||||||
r(1, "ООО Другой", S100),
|
r(1, "ООО Другой", S100),
|
||||||
)
|
)
|
||||||
|
|
||||||
parsed = parse(sheet, rules)
|
assert "7701234567" in [row.body.text for row in parse(sheet, rules).rows]
|
||||||
|
|
||||||
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 ---
|
# --- TOTAL ---
|
||||||
|
|||||||
@ -1,624 +0,0 @@
|
|||||||
"""Строки ИНН рядом с контрагентом (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, "шапка («")
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
"""Строки ИНН на уровне листа ОСВ: страховка после классификации (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,84 +211,3 @@ def test_variant1_inn_attribute_row_is_glued(rules: Rules) -> None:
|
|||||||
assert ok
|
assert ok
|
||||||
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
|
assert _values(document) == ["60.01", "А2 БИЗНЕС ООО", "Итого"]
|
||||||
assert document.rows[1].counterparty_inn == "2370012036"
|
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,8 +7,7 @@ import pytest
|
|||||||
|
|
||||||
from src.parser import detect
|
from src.parser import detect
|
||||||
from src.parser.grid import EMPTY_CELL, Cell
|
from src.parser.grid import EMPTY_CELL, Cell
|
||||||
from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money
|
from src.parser.numbers import MONEY_Q, MoneyParse, inn_digits, to_money
|
||||||
from src.parser.rules import Rules, load_rules
|
|
||||||
|
|
||||||
MAX_WARNING_LENGTH = 100
|
MAX_WARNING_LENGTH = 100
|
||||||
|
|
||||||
@ -238,51 +237,3 @@ def test_only_extraction_restores_lost_leading_zero() -> None:
|
|||||||
|
|
||||||
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
|
def test_detector_rule_stays_strict_for_prefixed_text() -> None:
|
||||||
assert detect.inn_from_cell(text("ИНН 352819549855")) is 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
|
|
||||||
|
|||||||
@ -47,14 +47,6 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
|||||||
7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32).
|
7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32).
|
||||||
8. **Строка ИНН — только ИНН**: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП» (`inn_only_row`); счёт строкой ИНН не
|
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`
|
### Алгоритм `inn_rows.py`
|
||||||
|
|
||||||
@ -78,7 +70,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
|||||||
4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе
|
4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе
|
||||||
пары нет.
|
пары нет.
|
||||||
5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа.
|
5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа.
|
||||||
6. **Направление документа** (лист ОСВ): подсказка шапки (п. 1), если она есть; иначе сведения пп. 2–3 вместе:
|
6. **Направление документа** (лист ОСВ), первое определённое:
|
||||||
1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над;
|
1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над;
|
||||||
2. пары соседей, найденные по суммам (п. 5, один совпавший сосед);
|
2. пары соседей, найденные по суммам (п. 5, один совпавший сосед);
|
||||||
3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не
|
3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не
|
||||||
@ -91,8 +83,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
|||||||
от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает
|
от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает
|
||||||
(прежний порядок §1.3).
|
(прежний порядок §1.3).
|
||||||
9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к
|
9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к
|
||||||
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки
|
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»).
|
||||||
(пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение владельца 10, H-2).
|
|
||||||
10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32),
|
10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32),
|
||||||
предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`.
|
предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`.
|
||||||
|
|
||||||
@ -134,7 +125,7 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
|||||||
|
|
||||||
| # | Дата | Решение | Обоснование | Цена ошибки |
|
| # | Дата | Решение | Обоснование | Цена ошибки |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | При ветке глубже схемы контрагент, названный ИНН, с одним договором отдаёт ИНН договору без `needs_check` (защита работает только без подсказки и только когда лист не глубже схемы); файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия |
|
| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | Файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия |
|
||||||
| 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка |
|
| 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка |
|
||||||
| 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке |
|
| 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке |
|
||||||
| 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки |
|
| 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки |
|
||||||
@ -143,21 +134,4 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
|||||||
| 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` |
|
| 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` |
|
||||||
| 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста |
|
| 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста |
|
||||||
| 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` |
|
| 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` не ставится |
|
| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: решает подсказка; без неё — «под» (ИНН родителю, дети — договоры), если родитель по схеме `COUNTERPARTY`, иначе «над» | Иначе строка реквизита с договорами под ней (#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: тест на сочетание «расхождение с колонкой + противоречие подсказке» — одно предупреждение о расхождении | Недостижимый код не добавляется | Нет |
|
|
||||||
|
|||||||
@ -101,48 +101,33 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
|||||||
(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр
|
(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр
|
||||||
дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН
|
дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН
|
||||||
(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН:
|
(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН:
|
||||||
она классифицируется как обычно, в `warnings` — «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка
|
она классифицируется как обычно, в `warnings` — «строка N похожа на ИНН …, контрольная сумма не сходится», документ —
|
||||||
оставлена как есть», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
|
`needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
|
||||||
«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол.,
|
«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол.,
|
||||||
не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не
|
не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не
|
||||||
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); такой ребёнок бывает только у наименования, которое
|
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»).
|
||||||
по схеме — COUNTERPARTY (договоры под ним остаются договорами, design #22); при другой позиции по схеме (подразделение и
|
|
||||||
т. д.) ребёнок — подлинное наименование; если позиции по схеме нет, ребёнком MUST быть только строка без детей (лист,
|
|
||||||
design #14).
|
|
||||||
|
|
||||||
**Пара строки ИНН** (связь в дереве важнее соседства):
|
**Пара строки ИНН** (связь в дереве важнее соседства):
|
||||||
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
|
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
|
||||||
`group_schema` больше одной группировки, ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы и в шапке нет
|
`group_schema` больше одной группировки и ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы, строка ИНН не
|
||||||
подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16);
|
склеивается и без предупреждения классифицируется по схеме;
|
||||||
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара —
|
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Если у
|
||||||
родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки
|
такой строки ИНН есть дети, пара — родитель только при подсказке «под» или (без подсказки) если родитель по схеме —
|
||||||
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт). Строка ИНН с детьми под
|
COUNTERPARTY; иначе действует правило «есть дети»;
|
||||||
наименованием при подсказке «над» и родителе-контрагенте по схеме остаётся у родителя (дети — договоры), а
|
|
||||||
противоречие подсказке подсвечивается (design #23);
|
|
||||||
- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению);
|
- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению);
|
||||||
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
|
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
|
||||||
|
|
||||||
Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление
|
Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление
|
||||||
листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных
|
листа** — первое определённое из: порядок `<группировка>.ИНН` в шапке; пары соседей, найденные по суммам; однозначные блоки
|
||||||
блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой
|
детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой ИНН —
|
||||||
ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу
|
под; начинается и кончается — блок сломан, его строки ИНН без пары). Противоречие сведений или их отсутствие — направление
|
||||||
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары (design #26).
|
неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой — обе без пары.
|
||||||
|
|
||||||
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
|
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
|
||||||
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —
|
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —
|
||||||
«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся,
|
«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся,
|
||||||
в `warnings` — расхождение, документ — `needs_check`.
|
в `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:
|
**Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N:
|
||||||
ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться.
|
ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться.
|
||||||
|
|
||||||
@ -170,19 +155,6 @@ design #14).
|
|||||||
- **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится)
|
- **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится)
|
||||||
- **THEN** строка не склеивается, в `warnings` — «похожа на ИНН, контрольная сумма не сходится», статус `needs_check`
|
- **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: Ведущий ноль у числовой строки ИНН
|
#### Scenario: Ведущий ноль у числовой строки ИНН
|
||||||
- **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму)
|
- **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму)
|
||||||
- **THEN** `counterparty_inn = "0123456788"`
|
- **THEN** `counterparty_inn = "0123456788"`
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
## 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