Merge pull request 'feat(osv): ИНН из названия контрагента, ИНН строкой выше, ИНН/КПП в ячейке (#9)' (#15) from feat/9-inn-in-text into feat/11-dkb-formats

Reviewed-on: #15
This commit is contained in:
aep 2026-10-06 13:00:36 +03:00
commit ae1e872e3e
14 changed files with 415 additions and 36 deletions

View File

@ -68,7 +68,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 88 файлов: 72 реальных + 16 синтетических) — регрессия, а не
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 91 файл: 72 реальных + 19 синтетических) — регрессия, а не
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
проверяют property-тест и ручная сверка «Итого».
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не

View File

@ -164,6 +164,14 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
строки без сумм прямо над названием на том же уровне оформления (такая строка в `rows` не попадает, в
`metrics.warnings` — «ИНН … над строкой … отнесён к ней»), затем из конца текста названия («Полиграф, 3528315710»,
«ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН
отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
`inn_kpp_cell`.
**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала —
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
@ -261,7 +269,7 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки —
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check`
завершается кодом 1.
Каталог `fixtures/osv/synthetic/` — 16 синтетических файлов `s01`–`s16` (генератор — `tests/synthetic/generate.py`):
Каталог `fixtures/osv/synthetic/` — 19 синтетических файлов `s01`–`s19` (генератор — `tests/synthetic/generate.py`):
форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.).
Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml`
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а

View File

@ -10,7 +10,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
| Файл | Ключи | Что описывает |
|---|---|---|
| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |

View File

@ -57,6 +57,20 @@ total_row_secondary:
column_captions:
- '^(код|наименование)$'
# ИНН в тексте строки контрагента (OQ-100): в конце текста, 10 или 12 цифр после запятой, скобки, «/», пробела или
# метки «ИНН» (дефисы нормализация превращает в пробелы), необязательный «/КПП» из 9 цифр. Группы: tail — отрезаемый хвост
# (разделитель, метка, ИНН), inn — цифры ИНН; название контрагента — текст до tail
counterparty_inn_suffix:
- '(?P<tail>[\s,;(/]+(инн\s*[:№]?\s*)?\(?(?P<inn>\d{12}|\d{10})(\s*/\s*\d{9})?\)?)\s*$'
# Строка, где есть только ИНН («7701234567», «ИНН 7701234567 / 770101001»): ИНН строкой выше названия контрагента (OQ-130)
inn_only_row:
- '^(инн\s*[:№]?\s*)?\(?(?P<inn>\d{12}|\d{10})(\s*/\s*\d{9})?\)?$'
# Ячейка колонки ИНН со склеенным «ИНН/КПП»: «7701234567 / 770101001»
inn_kpp_cell:
- '^(?P<inn>\d{12}|\d{10})\s*/\s*\d{9}$'
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
header_markers:
# «по счету 60 за …»: код счёта — сразу после совпадения

View File

@ -17,7 +17,7 @@
import re
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import dataclass, replace
from decimal import Decimal
from typing import Literal
@ -27,7 +27,7 @@ from src.parser.deadline import Deadline
from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
from src.parser.grid import Sheet, column_letter
from src.parser.hierarchy import BodyRow, Levels
from src.parser.numbers import inn_digits, 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
__all__ = [
@ -183,6 +183,40 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu
return tuple(sums.values())
def _has_sums(item: BodyRow) -> bool:
return any(value is not None for value in item.sums)
def _glue_inn_rows(body: list[BodyRow], rules: Rules, warnings: list[str]) -> list[BodyRow]:
"""Строка с одним ИНН и без сумм над строкой с суммами того же уровня оформления (outline и отступ) — ИНН
контрагента под ней (OQ-130): из строк она убирается, ИНН переходит к следующей строке (`inn_above`).
Ничего иного такая строка не значит, а оставшись в таблице, стала бы OTHER и потеряла ИНН. Другой уровень
оформления или отсутствие сумм у следующей строки — строка остаётся как есть.
"""
glued: list[BodyRow] = []
pending: BodyRow | None = None
for item in body:
if pending is not None:
same_level = (pending.outline, pending.indent) == (item.outline, item.indent)
if same_level and _has_sums(item) and not item.is_account:
inn = inn_only(pending.text, rules)
warnings.append(
f"строка {pending.row}: ИНН {inn} над строкой {item.row} отнесён к ней как к контрагенту"
)
item = replace(item, inn_above=inn) # noqa: PLW2901 - строка меняется, а не подменяется другой
else:
glued.append(pending)
pending = None
if not _has_sums(item) and not item.is_account and inn_only(item.text, rules) is not None:
pending = item
continue
glued.append(item)
if pending is not None:
glued.append(pending)
return glued
def _first_sum_col(header: TableHeader) -> int:
return min(header.columns.indexes().values())
@ -270,7 +304,7 @@ def collect_body_rows(
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check("суммы строк")
body.append(_body_row(sheet, row, header, label_cols, warnings))
return body, total_row, warnings
return _glue_inn_rows(body, rules, warnings), total_row, warnings
# --- Классификация ---
@ -364,8 +398,11 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple
if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, doc_data_type), branch
inn_col = context.header.inn_col
inn = None if inn_col is None else inn_digits(context.sheet.cell(item.row, inn_col))
return _Kind(group_type, name, code, doc_data_type, counterparty_name=item.text, counterparty_inn=inn), branch
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
# ИНН: колонка ИНН, затем строка без сумм над названием (OQ-130), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules)
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
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно

View File

@ -6,7 +6,6 @@
пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
"""
import bisect
import re
from collections.abc import Iterable
from dataclasses import dataclass
@ -16,7 +15,7 @@ from src.contract.common import DataType
from src.contract.result import OsvGroup
from src.parser.detect import TableHeader
from src.parser.grid import Sheet
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, normalize_text
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset
__all__ = ["ACCOUNT_NOT_FOUND_WARNING", "DocHeader", "build_group_schema", "parse_doc_header"]
@ -70,21 +69,9 @@ class _HeadCell:
# --- Текст ---
def _source_offset(text: str, normalized_offset: int) -> int:
"""Смещение в исходном тексте, с которого начинается хвост нормализованного текста с позиции `normalized_offset`.
Позиции `Match` относятся к `normalize_text(text)` (нижний регистр, схлопнутые пробелы), а в результат нужен
исходный текст («3 квартал 2024 г.», не «3 квартал 2024 г.» в нижнем регистре). Нормализация префикса — префикс
нормализации целого, поэтому длина нормализованного префикса монотонна по длине исходного.
"""
# Пробелы сразу после совпадения (`\s+` в метке) нормализация префикса отбрасывает — не считаем их в позиции
target = len(normalize_text(text)[:normalized_offset].rstrip())
return bisect.bisect_left(range(len(text) + 1), target, key=lambda size: len(normalize_text(text[:size])))
def _tail(text: str, match: re.Match[str]) -> str:
"""Исходный текст после совпадения (позиции `match` — в нормализованном тексте)."""
return text[_source_offset(text, match.end()) :].strip()
return text[source_offset(text, match.end()) :].strip()
def _find(patterns: Iterable[Pattern], text: str) -> re.Match[str] | None:
@ -159,11 +146,11 @@ def _cut_month_range(tail: str, rules: Rules) -> str | None:
found = rules.periods.range.search(tail)
if found is None:
return None
stop = _source_offset(tail, found.end())
stop = source_offset(tail, found.end())
rest = tail[stop:]
mark = rules.periods.end.search(rest)
if mark is not None and mark.start() == 0:
stop += _source_offset(rest, mark.end())
stop += source_offset(rest, mark.end())
return tail[:stop].strip()
@ -181,7 +168,7 @@ def _cut_period(tail: str, rules: Rules) -> str:
end = rules.periods.end.search(tail[start:])
if end is None:
break
start += _source_offset(tail[start:], end.end())
start += source_offset(tail[start:], end.end())
candidate = tail[:start].strip()
if rules.periods.match(candidate) is not None:
return candidate

View File

@ -59,6 +59,7 @@ class BodyRow:
outline: int
sums: tuple[Decimal | None, ...]
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130)
@dataclass(frozen=True)

View File

@ -15,14 +15,17 @@ from typing import NamedTuple
from src.parser.detect import INN_LENGTHS, inn_from_cell
from src.parser.grid import Cell
from src.parser.rules import Rules, source_offset
__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
__all__ = ["MONEY_Q", "MoneyParse", "column_inn", "inn_digits", "inn_only", "split_inn_suffix", "to_money"]
MONEY_Q = Decimal("0.01")
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
# извлечении из выбранной колонки ИНН, ruling R-24)
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
_ZERO = Decimal("0.00")
# Что остаётся на конце названия после отрезанного хвоста с ИНН: знаки-разделители («ООО Альфа (», «ООО Альфа,»)
_NAME_TRAILING = " ,;(/:-–—"
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
# числа с гигантской экспонентой само падает с InvalidOperation
@ -137,3 +140,40 @@ def _restored_leading_zero(cell: Cell) -> str | None:
return None
digits = str(int(value))
return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None
def column_inn(cell: Cell, rules: Rules) -> str | None:
"""ИНН из ячейки колонки ИНН: `inn_digits`, а склеенное «ИНН/КПП» («7701234567 / 770101001») — по `inn_kpp_cell`."""
found = inn_digits(cell)
if found is not None or cell.kind != "text":
return found
for pattern in rules.headers.inn_kpp_cell:
match = pattern.search(cell.text)
if match is not None:
return match.group("inn")
return None
def split_inn_suffix(text: str, rules: Rules) -> tuple[str, str | None]:
"""Название контрагента и ИНН из текста вида «Полиграф, 3528315710» / «ООО Альфа (ИНН 7701234567)».
ИНН — только в конце текста (`counterparty_inn_suffix`); название — текст до хвоста без разделителей по краям.
Нет ИНН (или от названия ничего не осталось) — текст как есть и `None`.
"""
for pattern in rules.headers.counterparty_inn_suffix:
match = pattern.search(text)
if match is None:
continue
name = text[: source_offset(text, match.start("tail"))].strip().rstrip(_NAME_TRAILING).rstrip()
if name:
return name, match.group("inn")
return text, None
def inn_only(text: str, rules: Rules) -> str | None:
"""ИНН строки, где написан только он (`inn_only_row`); иначе `None`."""
for pattern in rules.headers.inn_only_row:
match = pattern.search(text)
if match is not None:
return match.group("inn")
return None

View File

@ -9,6 +9,7 @@
неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа.
"""
import bisect
import re
from collections.abc import Iterable
from dataclasses import dataclass
@ -34,6 +35,7 @@ __all__ = [
"first_match",
"load_rules",
"normalize_text",
"source_offset",
]
DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules"
@ -67,6 +69,18 @@ def normalize_text(s: str) -> str:
return _SEPARATORS.sub(" ", lowered).strip()
def source_offset(text: str, normalized_offset: int) -> int:
"""Смещение в исходном тексте, с которого начинается хвост нормализованного текста с позиции `normalized_offset`.
Позиции `Match` относятся к `normalize_text(text)` (нижний регистр, схлопнутые пробелы), а в результат нужен
исходный текст («3 квартал 2024 г.», не «3 квартал 2024 г.» в нижнем регистре). Нормализация префикса — префикс
нормализации целого, поэтому длина нормализованного префикса монотонна по длине исходного.
"""
# Пробелы сразу после совпадения (`\s+` в метке) нормализация префикса отбрасывает — не считаем их в позиции
target = len(normalize_text(text)[:normalized_offset].rstrip())
return bisect.bisect_left(range(len(text) + 1), target, key=lambda size: len(normalize_text(text[:size])))
class Pattern:
"""Регулярное выражение словаря: `search` применяется к `normalize_text(text)`, регистр не учитывается."""
@ -138,6 +152,9 @@ class OsvHeaderRules(_Model):
total_row: list[Pattern]
total_row_secondary: list[Pattern]
column_captions: list[Pattern]
counterparty_inn_suffix: list[Pattern]
inn_only_row: list[Pattern]
inn_kpp_cell: list[Pattern]
header_markers: dict[HeaderMarker, list[Pattern]]
@model_validator(mode="after")
@ -151,6 +168,15 @@ class OsvHeaderRules(_Model):
raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
if "inn" not in self.service_columns:
raise ValueError("service_columns: нет обязательного ключа 'inn'")
for field_name, groups in (
("counterparty_inn_suffix", {"tail", "inn"}),
("inn_only_row", {"inn"}),
("inn_kpp_cell", {"inn"}),
):
for pattern in getattr(self, field_name):
missing_groups = groups - pattern.group_names
if missing_groups:
raise ValueError(f"{field_name}: в паттерне {pattern.source!r} нет групп {sorted(missing_groups)}")
return self

View File

@ -1,7 +1,8 @@
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
ОСВ (R-22); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание —
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
записи фиксирован.
@ -43,6 +44,9 @@ Pair = tuple[Money, Money]
Six = tuple[D, D, D, D, D, D]
Hierarchy = Literal["outline", "indent", "spaces", "none"]
NumStyle = Literal["num", "text", "text_nbsp", "text_narrow"]
# Как ИНН контрагента записан в файле: отдельной колонкой, в тексте названия (разные разделители), отдельной строкой
# над названием, склеенным «ИНН/КПП» в ячейке колонки ИНН
InnStyle = Literal["column", "comma", "paren_label", "label", "space", "above", "kpp_cell"]
# --- Дерево ведомости ---
@ -63,6 +67,7 @@ class Node:
turnover: Pair = (None, None)
indicator: bool = False
num_style: NumStyle = "num"
inn_style: InnStyle = "column"
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
def children(self) -> list["Node"]:
@ -200,6 +205,7 @@ class OsvSpec:
detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2)
org_name: str | None = ORG # None — в шапке нет строки с названием организации
group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная)
labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
@ -239,7 +245,7 @@ def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_labe
sheet.put(label_row + 1, 1, sub_label)
service_row = label_row + 1 if below else top + 1
if spec.inn_col is not None:
sheet.put(service_row, spec.inn_col, "ИНН")
sheet.put(service_row, spec.inn_col, spec.inn_header)
if spec.kpp_col is not None:
sheet.put(service_row, spec.kpp_col, "КПП")
for key, col, width in _groups(spec):
@ -275,19 +281,38 @@ def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int:
return nxt
def _row_cells(sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six) -> None:
_INN_IN_NAME = {
"comma": "{name}, {inn}",
"paren_label": "{name} (ИНН {inn})",
"label": "{name} ИНН: {inn}",
"space": "{name} {inn}",
}
def _name_with_inn(node: Node) -> str:
"""Название строки; ИНН дописан в текст, если так задан `inn_style` (иначе — только название)."""
template = _INN_IN_NAME.get(node.inn_style)
return node.text if template is None or not node.inn else template.format(name=node.text, inn=node.inn)
def _row_cells( # noqa: PLR0913 - строка листа описывается полностью одним вызовом
sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six, *, blank: bool = False
) -> None:
"""Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
node, depth = entry
mode = spec.hierarchy
text = INDENT_STEP * depth + node.text if mode == "spaces" else node.text
shown = _name_with_inn(node)
text = INDENT_STEP * depth + shown if mode == "spaces" else shown
bold = spec.bold_accounts and bool(node.children())
sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0)
if mode == "outline" and depth:
sheet.outline[row] = depth
if spec.inn_col is not None and node.inn:
sheet.put(row, spec.inn_col, node.inn)
if spec.inn_col is not None and node.inn and node.inn_style in ("column", "kpp_cell"):
sheet.put(row, spec.inn_col, f"{node.inn}/{node.kpp}" if node.inn_style == "kpp_cell" else node.inn)
if spec.kpp_col is not None and node.kpp:
sheet.put(row, spec.kpp_col, node.kpp)
if blank:
return # строка без сумм (ИНН над названием)
values = list(six)
values[4] += node.closing_debit_delta
for key, col, width in _groups(spec):
@ -335,6 +360,11 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
if spec.repeat_header_after == index:
# Печать по страницам: пустая строка и копия блока заголовка
next_row = _header_rows(sheet, spec, next_row + 1, "Счет", "Контрагенты")
if node.inn_style == "above" and node.inn:
# ИНН отдельной строкой над названием, без сумм, на том же уровне оформления
inn_line = Node(node.inn)
_row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True)
next_row += 1
_row_cells(sheet, spec, next_row, (node, depth), node.six())
next_row += 1
total = SheetSpec("")
@ -586,6 +616,35 @@ _CORE_PART = "docProps/core.xml"
_FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode()
def _s17() -> list[SheetSpec]:
"""ИНН в тексте названия контрагента (OQ-100): запятая, «(ИНН …)», «ИНН: …», пробел; колонки ИНН нет."""
first = alfa(inn_style="comma")
second = beta(inn_style="paren_label")
third = gamma(inn_style="label")
fourth = sidorov()
fourth.inn_style = "space"
root = Node("60", [Node("60.01", [first, second, ivanov()]), Node("60.02", [third, fourth])])
return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
def _s18() -> list[SheetSpec]:
"""ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет."""
root = Node(
"60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])]
)
return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
def _s19() -> list[SheetSpec]:
"""Колонка «ИНН/КПП» со склеенным значением «7701234567/770101001» в одной ячейке (OQ-100)."""
items = [alfa(inn_style="kpp_cell"), beta(inn_style="kpp_cell"), ivanov()]
for node in items:
node.kpp = KPP.get(node.text)
root = Node("60", [Node("60.01", items), Node("60.02", [gamma(), delta()])])
spec = OsvSpec(period="3 квартал 2025 г.", inn_col=5, inn_header="ИНН/КПП", first_sum_col=6)
return [build_osv(spec, root)]
def _normalized_zip(data: bytes) -> bytes:
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
при каждом `save`, заданное в свойствах значение перезаписывает)."""
@ -651,6 +710,9 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
"s14_statement_forms.xlsx": (_s14, to_xlsx),
"s15_group_title_synonyms.xlsx": (_s15, to_xlsx),
"s16_header_synonyms.xlsx": (_s16, to_xlsx),
"s17_inn_in_name.xlsx": (_s17, to_xlsx),
"s18_inn_row_above.xlsx": (_s18, to_xlsx),
"s19_inn_kpp_cell.xlsx": (_s19, to_xlsx),
}
FILE_NAMES = tuple(FILES)
@ -669,7 +731,7 @@ def generate_all(directory: Path) -> list[Path]:
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s16 (xlsx и один xlsb) в указанный каталог.",
description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.",
)
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv)

View File

@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s16_with_one_xlsb(tmp_path: Path) -> None:
def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)

View File

@ -43,6 +43,9 @@ EXPECTED_NAMES = [
"s14_statement_forms.xlsx",
"s15_group_title_synonyms.xlsx",
"s16_header_synonyms.xlsx",
"s17_inn_in_name.xlsx",
"s18_inn_row_above.xlsx",
"s19_inn_kpp_cell.xlsx",
]
XLSB_NAME = "s13_no_outline_indent.xlsb"
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
@ -100,6 +103,12 @@ EXPECTED: dict[str, Expect] = {
"s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
# шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2
"s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 2)),
# ИНН в тексте названия, колонки ИНН нет
"s17_inn_in_name.xlsx": Expect("outline", 2025, "Q1", True),
# ИНН строкой над названием
"s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False),
# колонка «ИНН/КПП»
"s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False),
}
@ -172,7 +181,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s16 (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())
assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present]
@ -483,3 +492,55 @@ def test_s16_header_without_organization_year_only_period_and_currency_note(fixt
("OTHER", "Валюта"),
]
assert outcome.status is ResultStatus.DONE
def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> None:
"""OQ-100: ИНН из текста названия («, », «(ИНН …)», «ИНН: …», пробел) — в `counterparty_inn`, имя без ИНН."""
doc = one_document(parse_named(fixtures_dir, "s17_inn_in_name.xlsx"))
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {
ALFA: "7701234567",
BETA: "7707654321",
GAMMA: "5001234567",
"ИП Сидоров": "500100732259",
"Иванов Иван": None,
}
alfa = next(row for row in doc.rows if row.counterparty_name == ALFA)
assert alfa.row_value == "ООО Альфа, 7701234567" # исходный текст не меняется
assert doc.columns is not None
assert "inn" not in doc.columns
def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None:
"""OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента."""
outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx")
doc = one_document(outcome)
assert not [row for row in doc.rows if row.group_type == "OTHER"]
assert not [row for row in doc.rows if row.row_value in {"7701234567", "5001234567"}]
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {ALFA: "7701234567", BETA: None, "Иванов Иван": None, GAMMA: "5001234567"}
assert any("ИНН 7701234567" in warning for warning in warnings_of(outcome))
def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None:
"""«ИНН/КПП» в одной ячейке: в `counterparty_inn` — только ИНН; колонка распознана как колонка ИНН."""
doc = one_document(parse_named(fixtures_dir, "s19_inn_kpp_cell.xlsx"))
counterparties = {
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
}
assert counterparties == {
ALFA: "7701234567",
BETA: "7707654321",
"Иванов Иван": None,
GAMMA: "5001234567",
"ООО Дельта": "7812345678",
}
assert doc.columns is not None
assert doc.columns["inn"] == "E"

View File

@ -532,6 +532,138 @@ def test_header_repeat_is_skipped(rules: Rules) -> None:
assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW
# --- ИНН в тексте строки контрагента (issue #9, OQ-100, OQ-130) ---
def _inn_sheet(name: str, sums: Sums = S100) -> Sheet:
return osv(r(0, "60", sums), r(1, name, sums))
@pytest.mark.parametrize(
("text", "name", "inn"),
[
("ООО Поставщик, 7701234567", "ООО Поставщик", "7701234567"),
("ООО Поставщик,7701234567", "ООО Поставщик", "7701234567"),
("ООО Поставщик (7701234567)", "ООО Поставщик", "7701234567"),
("ООО Поставщик (ИНН 7701234567)", "ООО Поставщик", "7701234567"),
("ООО Поставщик ИНН 7701234567", "ООО Поставщик", "7701234567"),
("ООО Поставщик ИНН: 7701234567", "ООО Поставщик", "7701234567"),
("ООО Поставщик - ИНН 7701234567", "ООО Поставщик", "7701234567"),
("ООО Поставщик 7701234567", "ООО Поставщик", "7701234567"),
("ООО Поставщик / 7701234567", "ООО Поставщик", "7701234567"),
("ИП Сидоров, 500100732259", "ИП Сидоров", "500100732259"),
("ООО Поставщик, 7701234567/770101001", "ООО Поставщик", "7701234567"),
("Альянс ООО (Тамбов) 6829133099", "Альянс ООО (Тамбов)", "6829133099"),
],
)
def test_inn_is_extracted_from_counterparty_name(rules: Rules, text: str, name: str, inn: str) -> None:
parsed = parse(_inn_sheet(text), rules)
row = parsed.rows[1]
assert row.group_type == "COUNTERPARTY"
assert (row.counterparty_name, row.counterparty_inn) == (name, inn)
assert row.body.text == text # исходный текст строки сохраняется (row_value)
@pytest.mark.parametrize(
"text",
[
"ООО Поставщик 12345", # не 10/12 цифр
"ООО Поставщик 12345678901", # 11 цифр
"ООО Поставщик 1234567890123", # 13 цифр
"ООО Поставщик 7701234567 филиал", # ИНН не в конце
"ООО Поставщик№7701234567", # без разделителя
"7701234567", # весь текст — число: имени нет
],
)
def test_inn_is_not_extracted_from_foreign_text(rules: Rules, text: str) -> None:
row = parse(_inn_sheet(text), rules).rows[1]
assert (row.counterparty_name, row.counterparty_inn) == (text, None)
def test_inn_column_wins_over_inn_in_name_and_name_is_still_cleaned(rules: Rules) -> None:
sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик, 7701234567", S100, inn="7707654321"))
row = parse(sheet, rules).rows[1]
assert (row.counterparty_name, row.counterparty_inn) == ("ООО Поставщик", "7707654321")
def test_inn_in_contract_name_is_not_extracted(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(2, "Договор № 1, 7701234567", S100),
)
contract = parse(sheet, rules).rows[2]
assert contract.group_type == "CONTRACT"
assert (contract.counterparty_name, contract.counterparty_inn) == (None, None)
def test_inn_with_kpp_in_one_column_cell_gives_inn(rules: Rules) -> None:
sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567 / 770101001"))
row = parse(sheet, rules).rows[1]
assert (row.counterparty_name, row.counterparty_inn) == ("ООО Поставщик", "7701234567")
def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234567"), # ИНН строкой выше названия, без сумм
r(1, "ООО Поставщик", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик"]
supplier = parsed.rows[1]
assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == (
"ООО Поставщик",
"7701234567",
ACCOUNT_ROW,
)
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:
sheet = osv(
r(0, "60", S100),
r(1, "ИНН 7701234567 / 770101001"),
r(1, "ООО Поставщик", S100),
)
assert parse(sheet, rules).rows[1].counterparty_inn == "7701234567"
def test_inn_row_above_on_other_level_is_not_glued(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234567"),
r(2, "ООО Поставщик", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "7701234567", "ООО Поставщик"]
assert parsed.one("ООО Поставщик").counterparty_inn is None
def test_inn_row_above_before_row_without_sums_is_not_glued(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(1, "7701234567"),
r(1, "ООО Поставщик"),
r(1, "ООО Другой", S100),
)
assert "7701234567" in [row.body.text for row in parse(sheet, rules).rows]
# --- TOTAL ---

View File

@ -236,6 +236,17 @@ def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None:
load_rules(target)
def test_inn_pattern_without_named_group_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["counterparty_inn_suffix"] = [r"\d{10}"]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*counterparty_inn_suffix"):
load_rules(target)
def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"