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:
commit
ae1e872e3e
@ -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` — только по контрольным суммам, структуру под сходимость не
|
||||
|
||||
10
README.md
10
README.md
@ -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`), а
|
||||
|
||||
@ -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`) |
|
||||
|
||||
@ -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 за …»: код счёта — сразу после совпадения
|
||||
|
||||
@ -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): всё, что нужно классификатору, приходит явно
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 ---
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user