Merge pull request 'feat(osv): форматы пакета ДКБ — период-диапазон, Итого по Σ уровня 0, «Итого развернутое», шапка ОСВ 63 (#11)' (#13) from feat/11-dkb-formats into main

Reviewed-on: #13
This commit is contained in:
aep 2026-10-06 13:04:21 +03:00
commit 5cb8e9c003
25 changed files with 1146 additions and 78 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

@ -158,6 +158,24 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка
сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень».
**«Итого».** Основная итоговая строка — первая TOTAL листа; она сверяется с первым корнем ACCOUNT либо, если корней
уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0
(OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая
строка сразу за «Итого» («Итого развернутое», `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: ядро такой документ к версии не привязывает).
### Словари и как добавить синоним
Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
@ -251,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,10 +10,10 @@ 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`, `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` | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
| `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

@ -7,6 +7,11 @@ header:
- ['\bну\b|налогов', NU]
- ['\bсумм', SUM]
# Тип данных отдельной ячейкой шапки без метки «Выводимые данные:» («Бухгалтерский» в строке «Бухгалтерский | период»):
# ячейка целиком — название типа; ищется, только если метки нет. НУ отдельной ячейкой в файлах не встречался.
standalone_header:
- ['^бухгалтерск\w*(\s+учет\w*)?$', BU]
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
row:
- ['^вал\.?$', CURRENCY]

View File

@ -49,6 +49,28 @@ service_columns:
total_row:
- '^итого\b'
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
total_row_secondary:
- '^итого\s+развернут'
# Подписи колонок под названием группировки («Счет» -> «Код» | «Наименование»): это не группировки блока заголовка
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

@ -25,3 +25,16 @@ year: '(?<!\d)(19|20)\d{2}(?!\d)'
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
# без совпадения период идёт до конца строки
end: '\bг\.|\bгода?\b'
# Названия месяцев в любом падеже; имя группы — номер месяца (m01 … m12): по нему код узнаёт месяц без списка слов
months: '(?P<m01>январ)|(?P<m02>феврал)|(?P<m03>март)|(?P<m04>апрел)|(?P<m05>ма[йя])|(?P<m06>июн)|(?P<m07>июл)|(?P<m08>август)|(?P<m09>сентябр)|(?P<m10>октябр)|(?P<m11>ноябр)|(?P<m12>декабр)'
# Диапазон месяцев с годом конца: «Октябрь 2024 г. - Сентябрь 2025 г.», «с октября 2024 по сентябрь 2025 г.»,
# «Апрель - Июнь 2025 г.». Группы: m1/y1 — месяц и год начала (год может отсутствовать), m2/y2 — месяц и год конца.
# Диапазон от января без года начала («январь-март 2025 г.») берут правила выше; здесь он не учитывается (код).
range: '(?P<m1>(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P<y1>(19|20)\d{2})?\s*(г\.?|года?)?\s*(по\s+|до\s+)?(?P<m2>(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P<y2>(19|20)\d{2})'
# Период отдельной ячейкой без метки «за»/«Период:» («9 месяцев 2025 г.» справа в строке «Бухгалтерский | период»):
# ячейка целиком — период с годом; ищется, только если периода по меткам нет
standalone:
- '^(\d{1,2}|[ivx]+)\s*(й|я|ый|ой)?\s*(квартал\w*|кв\b\.?|мес\w*)\s*(19|20)\d{2}\s*(г\b\.?|года?)?$'

View File

@ -9,8 +9,11 @@
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0); корня нет (пустая ОСВ, R-13) — «Итого»
должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`.
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
@ -111,7 +114,8 @@ def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
return next((item for item in reversed(rows) if item.group_type == "TOTAL"), None)
"""Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит)."""
return next((item for item in rows if item.group_type == "TOTAL"), None)
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
@ -121,10 +125,28 @@ def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
)
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
"""«Итого» = первая корневая строка ACCOUNT по шести колонкам (±0.01).
def _level_zero(rows: Sequence[ClassifiedRow]) -> list[ClassifiedRow]:
"""Строки уровня 0 без родителя, кроме «Итого» и строк-показателей, в порядке листа."""
return [
item
for item in rows
if item.level == _ROOT_LEVEL and item.parent is None and item.group_type != "TOTAL" and not _is_indicator(item)
]
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13).
def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]:
totals = [_ZERO] * len(SUM_FIELDS)
for item in rows:
for index, value in enumerate(_values(item)):
totals[index] += value
return tuple(totals)
def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
"""«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме.
"""
total = _total(rows)
if total is None:
@ -132,7 +154,12 @@ def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
root = _root(rows)
if root is None:
return all(abs(value) <= TOLERANCE for value in _values(total))
return not _differs(_values(total), _values(root))
if not _differs(_values(total), _values(root)):
return True
roots = _level_zero(rows)
# Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски, что у родителя и детей (R-21): 1С показывает «Итого»
# свёрнутым сальдо, а субсчета — развёрнутым. Обороты по-прежнему по колонкам.
return len(roots) > 1 and _sums_match(_values(total), _sum_of(roots))
def _rows_text(rows: Iterable[int]) -> str:
@ -150,7 +177,11 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
return TOTAL_NOT_FOUND_WARNING
if root is None:
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
level_zero = _level_zero(rows)
if len(level_zero) > 1:
return f"{message} и ≠ сумме {len(level_zero)} строк уровня 0"
return message
def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]:

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__ = [
@ -49,6 +49,10 @@ __all__ = [
# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
_SEGMENT_SEPARATOR = "."
# Сдвиг на одну колонку текста в «индентах» строки: больше любого отступа ячейки
LABEL_COLUMN_INDENT_STEP = 1000
# Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных
MIN_ROWS_WITH_TOTAL = 2
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
DEADLINE_CHECK_EVERY_ROWS = 500
# Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта
@ -112,6 +116,8 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого»
перед ней; без обычной «Итого» вторая строка служит единственной итоговой.
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
"""
@ -119,14 +125,41 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
return None
outline_floor = min(sheet.outline(row) for row in rows)
indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
only_secondary: int | None = None
for row in reversed(rows):
text = sheet.text_at(row, header.text_col)
if not any(pattern.search(text) for pattern in rules.headers.total_row):
continue
_, indent = _text_cell_style(sheet, row, header.text_col)
is_root = sheet.outline(row) == outline_floor and indent == indent_floor
return row if is_root else None
return None
if sheet.outline(row) != outline_floor or indent != indent_floor:
return only_secondary
if _is_secondary_total(text, rules):
only_secondary = only_secondary or row
continue
return row
return only_secondary
def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool:
"""Последняя строка таблицы — одни суммы, левее колонок сумм пусто (1С без подписи «Итого»); над ней есть данные."""
if len(rows) < MIN_ROWS_WITH_TOTAL:
return False
first_sum = _first_sum_col(header)
return all(col >= first_sum for col, _ in sheet.row_cells(rows[-1]))
def _is_secondary_total(text: str, rules: Rules) -> bool:
return any(pattern.search(text) for pattern in rules.headers.total_row_secondary)
def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int:
"""Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет)."""
last = total
for row in rows[rows.index(total) + 1 :]:
if not _is_secondary_total(sheet.text_at(row, header.text_col), rules):
break
last = row
return last
def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None:
@ -150,10 +183,80 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu
return tuple(sums.values())
def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str]) -> BodyRow:
text = sheet.text_at(row, header.text_col)
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())
def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple[int, ...]:
"""Колонки текста строки: колонка текста таблицы и колонки правее неё (левее сумм, кроме ИНН), в которых текст стоит
вместо пустой ячейки колонки текста.
Так выглядит «Счет: Код / Наименование» с контрагентами правее кода: уровень строки задаёт колонка, в которой
стоит её текст. У обычных таблиц текст всегда в колонке текста — вторых колонок нет.
"""
first_sum = _first_sum_col(header)
found: set[int] = set()
for row in rows:
if sheet.text_at(row, header.text_col):
continue
found.update(
col
for col, _ in sheet.row_cells(row)
if header.text_col < col < first_sum and col != header.inn_col and col <= header.right_edge
)
return (header.text_col, *sorted(found))
def _label_of(sheet: Sheet, row: int, label_cols: Sequence[int]) -> tuple[int, str]:
"""Колонка и текст строки: первая непустая колонка текста слева направо (без текста — колонка текста таблицы)."""
for col in label_cols:
text = sheet.text_at(row, col)
if text:
return col, text
return label_cols[0], ""
def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str]) -> BodyRow:
label_col, text = _label_of(sheet, row, label_cols)
account = ACCOUNT_RE.match(text)
bold, indent = _text_cell_style(sheet, row, header.text_col)
bold, indent = _text_cell_style(sheet, row, label_col)
# Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже
# любого отступа ячейки
indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP
return BodyRow(
row=row,
text=text,
@ -163,6 +266,7 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str])
indent=indent,
outline=sheet.outline(row),
sums=_sums(sheet, row, header.columns, warnings),
label_col=label_col,
)
@ -179,21 +283,28 @@ def collect_body_rows(
) -> tuple[list[BodyRow], int | None, list[str]]:
"""Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5).
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5);
исключение — вторые итоговые строки («Итого развернутое») сразу за «Итого»: они остаются в таблице как TOTAL.
"""
warnings: list[str] = []
if header.columns.collapsed:
warnings.append(_collapsed_warning(header.columns))
rows = _table_rows(sheet, header, rules, deadline)
total_row = _find_total(sheet, header, rows, rules)
if total_row is None and _is_unlabelled_total(sheet, header, rows):
total_row = rows[-1]
warnings.append(
f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)"
)
if total_row is not None:
rows = rows[: rows.index(total_row) + 1]
rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1]
label_cols = _label_cols(sheet, header, rows)
body: list[BodyRow] = []
for index, row in enumerate(rows, start=1):
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
deadline.check("суммы строк")
body.append(_body_row(sheet, row, header, warnings))
return body, total_row, warnings
body.append(_body_row(sheet, row, header, label_cols, warnings))
return _glue_inn_rows(body, rules, warnings), total_row, warnings
# --- Классификация ---
@ -206,8 +317,11 @@ def _extra_keys(sheet: Sheet, header: TableHeader, rows: Sequence[BodyRow]) -> d
Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)»
(Review Focus 1); заголовок без повторов — как в файле.
"""
label_cols = {item.label_col for item in rows}
used = {
col: title for col, title in header.extra_cols.items() if any(sheet.text_at(item.row, col) for item in rows)
col: title
for col, title in header.extra_cols.items()
if col not in label_cols and any(sheet.text_at(item.row, col) for item in rows)
}
titles = list(used.values())
return {col: f"{title} ({column_letter(col)})" if titles.count(title) > 1 else title for col, title in used.items()}
@ -284,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): всё, что нужно классификатору, приходит явно
@ -300,8 +417,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
) -> list[ClassifiedRow]:
"""Классификация строк по дереву `levels` и схеме группировок.
- «Итого» (`total_row`) — TOTAL, уровень 0 без родителя; в `rows` остаётся (# ASSUMPTION(OQ-01): хранить ли
«Итого», решает ядро);
- «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя;
в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро);
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
группировки, а вид сумм родителя; позицию в схеме она не занимает;
@ -313,7 +430,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
branches: dict[int, _Branch] = {}
result: list[ClassifiedRow] = []
for item in rows:
if item.row == total_row:
if total_row is not None and item.row >= total_row:
# Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал)
# ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
level, parent = 0, None

View File

@ -392,11 +392,13 @@ def _block_title(sheet: Sheet, first_row: int, last_row: int, col: int) -> str |
return None
def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int) -> list[int]:
def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int, rules: Rules) -> list[int]:
"""Строки блока, где в колонке текста начинается название группировки; подписи колонок («Код») — не группировки."""
labels: list[int] = []
for row in range(first_row, last_row + 1):
origin_row, origin_col = sheet.merged_origin(row, text_col)
if origin_row == row and sheet.cell(origin_row, origin_col).text:
text = sheet.cell(origin_row, origin_col).text
if origin_row == row and text and _search(rules.headers.column_captions, _normalized(text)) is None:
labels.append(row)
return labels
@ -464,7 +466,7 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No
inn_col=inn_col,
right_edge=right_edge,
extra_cols=dict(sorted(extra_cols.items())),
group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col),
group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col, rules),
)

View File

@ -6,22 +6,23 @@
пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
"""
import bisect
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any, Literal
from typing import Any, Literal, get_args
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, Pattern, Rules, first_match, normalize_text
from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset
__all__ = ["DocHeader", "build_group_schema", "parse_doc_header"]
__all__ = ["ACCOUNT_NOT_FOUND_WARNING", "DocHeader", "build_group_schema", "parse_doc_header"]
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
QUARTERS: tuple[Quarter, ...] = get_args(Quarter)
HeaderVariant = Literal[1, 2]
ACCOUNT_NOT_FOUND_WARNING = "код счёта (после «по счету») в шапке не найден"
# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
_ACCOUNT_CODE = re.compile(r"\d[^\s,;]*")
@ -68,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:
@ -147,18 +136,39 @@ class _PeriodHit:
text: str
def _cut_month_range(tail: str, rules: Rules) -> str | None:
"""Период — диапазон месяцев с годами («Октябрь 2024 г. - Сентябрь 2025 г.»): до года конца и «г.» после него.
Первое «г.» диапазона (после года начала) период не заканчивает: «Октябрь 2024 г.» — лишь его начало.
"""
if rules.periods.month_range(tail) is None:
return None
found = rules.periods.range.search(tail)
if found is None:
return None
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())
return tail[:stop].strip()
def _cut_period(tail: str, rules: Rules) -> str:
"""Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт.
Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно
начало не узнано, период — весь хвост до конца строки.
"""
ranged = _cut_month_range(tail, rules)
if ranged is not None:
return ranged
start = 0
while start < len(tail):
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
@ -185,22 +195,65 @@ def _is_recognised(text: str, rules: Rules) -> bool:
return rules.periods.match(text) is not None or rules.periods.year.search(text) is not None
def _standalone_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None:
"""Период отдельной ячейкой без метки: ячейка целиком — «9 месяцев 2025 г.» (словарь `periods.standalone`)."""
cell = next((cell for cell in cells if _find(rules.periods.standalone, cell.text) is not None), None)
return None if cell is None else _PeriodHit(cell, cell.text)
def _find_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None:
"""Строка периода: сначала с опознанным периодом, затем строка названия ведомости или метка с двоеточием."""
"""Строка периода: сначала с опознанным периодом по метке, затем период отдельной ячейкой, затем строка названия
ведомости или метка с двоеточием.
"""
hits = _period_candidates(cells, rules)
recognised = next((hit for hit in hits if _is_recognised(hit.text, rules)), None)
if recognised is not None:
return recognised
standalone = _standalone_period(cells, rules)
if standalone is not None:
return standalone
return next((hit for hit in hits if _is_title(hit.cell, rules) or _LABEL_SEPARATOR in hit.cell.text), None)
def _organization(cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None) -> _HeadCell | None:
"""Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода или меткой валюты."""
taken = [cell for cell in (None if period is None else period.cell, *_currency_cells(cells, rules)) if cell]
return next(
def _standalone_data_type(cells: list[_HeadCell], rules: Rules) -> tuple[_HeadCell, DataType] | None:
"""Тип данных отдельной ячейкой без метки: ячейка целиком — «Бухгалтерский» (словарь `standalone_header`)."""
for cell in cells:
found = first_match(rules.data_types.standalone_header, cell.text)
if found is not None:
return cell, found
return None
def _title_tail_organization(cells: list[_HeadCell], rules: Rules) -> str | None:
"""Организация в названии ведомости: «Оборотно-сальдовая ведомость НОВОРОСМЕТАЛЛ ООО» -> «НОВОРОСМЕТАЛЛ ООО».
Хвост без метки счёта и периода («по счету 60 за …» — не организация); пустой хвост — `None`.
"""
markers = rules.headers.header_markers
for cell in cells:
match = _find(rules.headers.sheet_markers, cell.text)
if match is None:
continue
tail = _tail(cell.text, match).strip(" ,;:")
if tail and _find([*markers["account"], *markers["period"]], tail) is None:
return tail
return None
def _organization(
cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None
) -> str | None:
"""Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода, меткой валюты или типом данных; иначе
хвост названия ведомости.
"""
taken = [
cell for cell in (None if period is None else period.cell, data_type, *_currency_cells(cells, rules)) if cell
]
found = next(
(cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)),
None,
)
return _title_tail_organization(cells, rules) if found is None else found.text
def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
@ -252,7 +305,47 @@ class _Period:
is_cumulative: bool | None
_MONTHS_IN_QUARTER = 3
def _quarter_of_end(month: int) -> Quarter | None:
"""Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц)."""
if month % _MONTHS_IN_QUARTER:
return None
return QUARTERS[month // _MONTHS_IN_QUARTER - 1]
def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Period:
"""Период-диапазон месяцев: с января по конец квартала — нарастающий, ровно квартал — квартальный; остальное
(скользящие 12 месяцев, полгода с апреля) — квартал и признак не определены: год — год конца диапазона.
Скользящий период нельзя выдать ни за квартальный, ни за нарастающий: ядро привяжет его к версии как не тот
документ. `null` + предупреждение оставляют решение аналитику (OQ-131).
"""
quarter = _quarter_of_end(span.end_month)
same_year = span.start_year == span.end_year
if quarter is not None and same_year and span.start_month == 1:
return _Period(span.end_year, quarter, True)
if quarter is not None and same_year and span.months == _MONTHS_IN_QUARTER:
return _Period(span.end_year, quarter, False)
if span.start_month != 1 or not same_year:
warnings.append(
f"период «{text}»: скользящий период {span.months} мес. "
f"(с {span.start_month:02d}.{span.start_year} по {span.end_month:02d}.{span.end_year}), не с начала года: "
"квартал и признак нарастающего итога не определены"
)
else:
warnings.append(
f"период «{text}»: конец диапазона не совпадает с концом квартала: "
"квартал и признак нарастающего итога не определены"
)
return _Period(span.end_year, None, None)
def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
span = rules.periods.month_range(text)
if span is not None:
return _month_range_period(span, text, warnings)
rule = rules.periods.match(text)
year = rules.periods.year.search(text)
if rule is None:
@ -271,6 +364,9 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> DataType | None:
value = _labelled_value(sheet, cells, rules.headers.header_markers["data_type"])
if value is None:
standalone = _standalone_data_type(cells, rules)
if standalone is not None:
return standalone[1]
warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден")
return None
data_type = first_match(rules.data_types.header, value)
@ -299,12 +395,13 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead
warnings: list[str] = []
period_hit = _find_period(cells, rules)
organization = _organization(cells, rules, period_hit)
standalone_type = _standalone_data_type(cells, rules)
organization = _organization(cells, rules, period_hit, None if standalone_type is None else standalone_type[0])
if organization is None:
warnings.append("наименование организации в шапке не найдено")
account = _account_code(cells, rules)
if account is None:
warnings.append("код счёта (после «по счету») в шапке не найден")
warnings.append(ACCOUNT_NOT_FOUND_WARNING)
period_text = None if period_hit is None else period_hit.text
if period_text is None:
warnings.append("период (после «за» или «Период:») в шапке не найден")
@ -316,7 +413,7 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead
currency = _parse_currency(sheet, cells, rules, warnings)
variant: HeaderVariant = 1 if _subkonto_items(cells, rules) is None else 2
return DocHeader(
organization_name="" if organization is None else organization.text,
organization_name="" if organization is None else organization,
account_code_raw=account or "",
period_text=period_text or "",
period_year=period.year,

View File

@ -58,6 +58,8 @@ class BodyRow:
indent: int # alignment.indent или число ведущих пробелов (единицы разные — сравниваются только ранги)
outline: int
sums: tuple[Decimal | None, ...]
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130)
@dataclass(frozen=True)
@ -268,11 +270,14 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int
def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
«Итого» (`total_row`) в стратегии не передаётся и получает уровень 0: это контрольная сумма документа, а не
ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`.
«Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это
контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное
дерево корней `TRIVIAL_STRATEGY`.
"""
body = [item for item in rows if item.row != total_row]
pinned = {} if total_row is None else {total_row: ROOT_LEVEL}
# «Итого» и вторые итоговые строки за ней («Итого развернутое») — не данные: уровень 0 без участия стратегий
totals = [] if total_row is None else [item for item in rows if item.row >= total_row]
body = [item for item in rows if total_row is None or item.row < total_row]
pinned = {item.row: ROOT_LEVEL for item in totals}
if len(body) < MIN_ROWS_FOR_CASCADE:
by_row = {item.row: ROOT_LEVEL for item in rows}
return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)

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

@ -15,7 +15,8 @@
"""
import time
from collections.abc import Callable, Iterable
from collections.abc import Callable, Iterable, Sequence
from dataclasses import replace
from loguru import logger
@ -28,7 +29,7 @@ from src.parser.deadline import Deadline
from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
from src.parser.grid import Sheet, column_letter
from src.parser.header import DocHeader, build_group_schema, parse_doc_header
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header
from src.parser.hierarchy import resolve_levels
from src.parser.readers import read_book
from src.parser.readers._common import one_line_reason
@ -98,6 +99,23 @@ def _row(item: ClassifiedRow) -> OsvRow:
)
def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHeader:
"""Код счёта шапки, которой нет («по счету …» в названии нет), — код первой строки-счёта таблицы.
«Счет: Код / Наименование»: код стоит в строке таблицы, а не в названии ведомости. Предупреждение «код счёта в
шапке не найден» заменяется пометкой, откуда код взят.
"""
if doc.account_code_raw:
return doc
accounts = (item.account_code_raw for item in rows if item.group_type == "ACCOUNT")
code = next((code for code in accounts if code), None)
if code is None:
return doc
warnings = [warning for warning in doc.warnings if warning != ACCOUNT_NOT_FOUND_WARNING]
warnings.append(f"код счёта «{code}» взят из первой строки-счёта таблицы: в шапке «по счету …» нет")
return replace(doc, account_code_raw=code, warnings=tuple(warnings))
def _is_ok(checks: OsvChecks) -> bool:
return (
bool(checks.total_row_matches_root)
@ -185,6 +203,7 @@ class OsvParser:
classified = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
except InvalidFormatError as exc:
raise _invalid_format(sheet, step, exc) from exc
doc = _with_table_account(doc, classified)
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
checks, check_warnings = run_checks(classified)
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)

View File

@ -9,6 +9,7 @@
неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа.
"""
import bisect
import re
from collections.abc import Iterable
from dataclasses import dataclass
@ -24,6 +25,7 @@ from src.contract.common import DataType
__all__ = [
"DEFAULT_RULES_DIR",
"DataTypeRules",
"MonthRange",
"OsvGroupRules",
"OsvHeaderRules",
"Pattern",
@ -33,6 +35,7 @@ __all__ = [
"first_match",
"load_rules",
"normalize_text",
"source_offset",
]
DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules"
@ -66,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)`, регистр не учитывается."""
@ -97,6 +112,11 @@ class Pattern:
"""Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
return self._regex.search(normalized)
@property
def group_names(self) -> frozenset[str]:
"""Имена именованных групп паттерна: словарь требует их у записей месяцев и диапазона."""
return frozenset(self._regex.groupindex)
def __repr__(self) -> str:
return f"Pattern({self.source!r})"
@ -130,6 +150,11 @@ class OsvHeaderRules(_Model):
credit: list[Pattern]
service_columns: dict[str, list[Pattern]]
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")
@ -143,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
@ -162,10 +196,68 @@ class PeriodRule(_Model):
is_cumulative: bool
_MONTHS_IN_YEAR = 12
_RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"})
_MONTH_GROUPS = frozenset(f"m{number:02d}" for number in range(1, _MONTHS_IN_YEAR + 1))
@dataclass(frozen=True)
class MonthRange:
"""Диапазон месяцев периода: «Октябрь 2024 г. – Сентябрь 2025 г.» -> (10, 2024, 9, 2025)."""
start_month: int
start_year: int
end_month: int
end_year: int
@property
def months(self) -> int:
"""Число месяцев диапазона включительно: октябрь 2024 – сентябрь 2025 = 12."""
return (self.end_year - self.start_year) * _MONTHS_IN_YEAR + self.end_month - self.start_month + 1
class PeriodRules(_Model):
rules: list[PeriodRule]
year: Pattern
end: Pattern
# Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2
months: Pattern
range: Pattern
standalone: list[Pattern]
@model_validator(mode="after")
def _check_month_patterns(self) -> "PeriodRules":
if self.months.group_names != _MONTH_GROUPS:
raise ValueError("months: ожидаются именованные группы m01…m12 (по одной на месяц)")
missing = _RANGE_GROUPS - self.range.group_names
if missing:
raise ValueError(f"range: нет обязательных именованных групп {sorted(missing)}")
return self
def month_number(self, text: str) -> int | None:
"""Номер месяца по названию в любом падеже («октября» -> 10); не месяц — `None`."""
found = self.months.search(text)
return None if found is None or found.lastgroup is None else int(found.lastgroup.removeprefix("m"))
def month_range(self, text: str) -> MonthRange | None:
"""Диапазон месяцев с годом конца: начало без года — в том же году, а если месяц начала позже месяца конца
(октябрь – сентябрь) — в предыдущем.
Диапазон, который начинается с января и не называет год начала («январь-март 2025 г.»), словарь периодов
разбирает своими правилами (`match`), поэтому здесь это `None`.
"""
found = self.range.search(text)
if found is None:
return None
start, end = self.month_number(found.group("m1")), self.month_number(found.group("m2"))
if start is None or end is None:
return None
end_year = int(found.group("y2"))
year_text = found.group("y1")
if year_text is None and start == 1:
return None
start_year = int(year_text) if year_text is not None else end_year - (1 if start > end else 0)
return MonthRange(start, start_year, end, end_year)
def match(self, text: str) -> PeriodRule | None:
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
@ -177,6 +269,7 @@ class PeriodRules(_Model):
class DataTypeRules(_Model):
header: list[tuple[Pattern, DataType]]
standalone_header: list[tuple[Pattern, DataType]]
row: list[tuple[Pattern, RowDataType]]
currency_codes: list[str]
currency_aliases: dict[str, list[Pattern]]

View File

@ -6,6 +6,9 @@
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
Файлы — реальные ОСВ `osv/**`, пакет ДКБ `osv-dkb/**` (диапазон месяцев, «Итого развернутое», шапка без меток) и
синтетика.
«Срабатывание» — `search`/`search_normalized` вернул совпадение. Счётчик — обёртка вокруг методов `Pattern` в тесте,
боевой код не меняется.
"""
@ -13,7 +16,7 @@
from pathlib import Path
import pytest
from fixtures_helpers import osv_fixture_files, synthetic_files
from fixtures_helpers import osv_dkb_files, osv_fixture_files, synthetic_files
from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
from src.parser import get_parser
@ -33,7 +36,7 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
base = get_parser()
assert isinstance(base, OsvParser)
parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)
files = [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)]
files = [*osv_fixture_files(fixtures_dir), *osv_dkb_files(fixtures_dir), *synthetic_files(fixtures_dir)]
for file in files:
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
return rules, counter, len(files)

View File

@ -6,6 +6,8 @@ from pathlib import Path
FIXTURES_ENV = "ARM_KA_FIXTURES"
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
_SYNTHETIC = "synthetic"
_OSV_DKB = "osv-dkb"
_ANALYSIS_REPORTS = "analysis-reports"
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
_IGNORED_PREFIXES = (".", "~$")
@ -52,6 +54,17 @@ def osv_fixture_files(root: Path) -> list[Path]:
return sorted(path for path in osv.rglob("*") if _is_workbook(path, osv) and synthetic not in path.parents)
def osv_dkb_files(root: Path) -> list[Path]:
"""Файлы Excel пакета ДКБ `root/osv-dkb/**` (многолистовые книги, статусы `needs_check`), кроме отчётов
«Анализ счета» (`analysis-reports/`: не ОСВ); отсортированы. Нет каталога — пусто."""
directory = root / _OSV_DKB
if not directory.is_dir():
return []
reports = directory / _ANALYSIS_REPORTS
found = (path for path in directory.rglob("*") if _is_workbook(path, directory))
return sorted(path for path in found if reports not in path.parents)
def synthetic_files(root: Path) -> list[Path]:
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
считаются); отсортированы."""

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

@ -228,6 +228,92 @@ def test_total_matches_first_root_account() -> None:
assert total_matches_root(rows) is False
# Лист без строки корневого счёта (вариант 2, issue #11 п. 3, OQ-106/OQ-133): верхний уровень — субсчета 58.01, 58.03;
# «Итого» равно сумме строк уровня 0
SUB_A: Sums = ("100", None, "10", "0", "110", None)
SUB_B: Sums = ("40", None, "30", "20", "50", None)
SUB_SUM: Sums = ("140", None, "40", "20", "160", None)
def multi_root_tree(total_sums: Sums = SUB_SUM) -> list[ClassifiedRow]:
return [
account(7, SUB_A),
row(8, SUB_A, level=1, parent=7),
account(9, SUB_B),
row(10, SUB_B, level=1, parent=9),
total(11, total_sums),
]
def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None:
rows = multi_root_tree()
checks, warnings = run_checks(rows)
assert total_matches_root(rows) is True
assert checks.total_row_matches_root is True
assert warnings == []
def test_collapsed_total_matches_sum_of_level_zero_rows_by_net_balance() -> None:
"""1С: «Итого» — свёрнутое сальдо (одна сторона), субсчета — развёрнутое; нетто сходится (R-21 для пары
«Итого» ↔ Σ уровня 0); обороты сверяются по колонкам.
"""
mixed_a: Sums = ("100", None, "10", "0", "110", None)
mixed_b: Sums = (None, "30", "30", "20", None, "20")
net_total: Sums = ("70", None, "40", "20", "90", None)
rows = [
account(7, mixed_a),
account(8, mixed_b),
total(9, net_total),
]
assert total_matches_root(rows) is True
rows[-1] = total(9, ("70", None, "41", "20", "90", None))
assert total_matches_root(rows) is False
def test_expanded_balance_total_is_not_matched_by_net_when_total_has_both_sides() -> None:
"""Нетто допускается, только если сальдо «Итого» свёрнуто; «Итого» с обеими сторонами сверяется по колонкам."""
mixed_a: Sums = ("100", None, "10", "0", "110", None)
mixed_b: Sums = (None, "30", "30", "20", None, "20")
both_sides: Sums = ("70", "1", "40", "20", "90", "1")
rows = [account(7, mixed_a), account(8, mixed_b), total(9, both_sides)]
assert total_matches_root(rows) is False
def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None:
rows = multi_root_tree(("140", None, "40", "20", "161", None))
checks, warnings = run_checks(rows)
assert checks.total_row_matches_root is False
assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings)
def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None:
rows = multi_root_tree()
rows.insert(2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY"))
assert total_matches_root(rows) is True
def test_total_still_matches_single_root_even_if_other_level_zero_rows_exist() -> None:
"""Прежнее поведение сохраняется: «Итого» = первый корень достаточно, лишние строки уровня 0 статус не портят."""
rows = tree()
rows.insert(3, row(9, ("1", None, None, None, "1", None), group_type="OTHER"))
assert total_matches_root(rows) is True
def test_second_total_row_does_not_replace_the_first_one() -> None:
"""Вторая итоговая строка («Итого развернутое») в сверку с корнем не входит: берётся первая TOTAL листа."""
rows = [*tree(), total(11, ("1", None, None, None, "1", None))]
assert total_matches_root(rows) is True
def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None:
assert total_matches_root([total(8, EMPTY)]) is True
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True

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 ---
@ -567,6 +699,52 @@ def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) ->
assert unpinned.by_row[total.body.row] > 0
def test_second_total_row_after_total_is_total_too(rules: Rules) -> None:
"""Issue #11 п. 5: «Итого развернутое» после «Итого» — вторая итоговая строка (TOTAL, уровень 0), не OTHER."""
sheet = osv(
r(0, "60.01", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого", S100),
r(None, "Итого развернутое", S100),
[],
["Главный бухгалтер", None, "Иванова"],
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60.01", "ООО Поставщик", "Итого", "Итого развернутое"]
assert parsed.total_row == parsed.one("Итого").body.row # основная — первая из подряд идущих итоговых
for text in ("Итого", "Итого развернутое"):
total = parsed.one(text)
assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None)
def test_second_total_row_alone_serves_as_total(rules: Rules) -> None:
"""Если обычной «Итого» нет, «Итого развернутое» — единственная итоговая строка и сверяется с корнем."""
sheet = osv(
r(0, "60", S100),
r(1, "ООО Поставщик", S100, inn="7701234567"),
r(None, "Итого развернутое", S100),
)
parsed = parse(sheet, rules)
assert parsed.total_row == parsed.one("Итого развернутое").body.row
assert parsed.one("Итого развернутое").group_type == "TOTAL"
def test_text_after_total_that_is_not_a_second_total_stays_footer(rules: Rules) -> None:
sheet = osv(
r(0, "60", S100),
r(None, "Итого", S100),
r(None, "Руководитель", S100),
)
parsed = parse(sheet, rules)
assert [row.body.text for row in parsed.rows] == ["60", "Итого"]
def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
"""T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого»."""
sheet = osv(

View File

@ -427,6 +427,57 @@ def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, qua
assert header.warnings == ()
# --- Диапазон месяцев с годами: скользящий период (issue #11 п. 1, OQ-131) ---
@pytest.mark.parametrize(
"period",
[
"Октябрь 2024 г. - Сентябрь 2025 г.",
"с октября 2024 г. по сентябрь 2025 г.",
"октябрь 2024 - сентябрь 2025",
"Октябрь 2024 г. - Сентябрь 2025 г. (по подразделениям)",
],
)
def test_rolling_period_is_not_read_as_first_month_quarter(rules: Rules, period: str) -> None:
header = _variant1(rules, title=_title(period))
assert header.period_text == period.removesuffix(" (по подразделениям)")
# Конец диапазона определяет год; квартал и «нарастающий» не определены: 12 месяцев — не квартал и не с начала года
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None)
assert any("не с начала года" in warning and "12 мес" in warning for warning in header.warnings)
def test_month_range_of_one_quarter_is_a_quarter_not_cumulative(rules: Rules) -> None:
header = _variant1(rules, title=_title("Апрель 2025 г. - Июнь 2025 г."))
assert header.period_text == "Апрель 2025 г. - Июнь 2025 г."
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q2", False)
assert header.warnings == ()
def test_month_range_inside_year_not_from_january_is_not_a_quarter(rules: Rules) -> None:
header = _variant1(rules, title=_title("Апрель 2025 г. - Сентябрь 2025 г."))
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None)
assert any("не с начала года" in warning for warning in header.warnings)
def test_month_range_from_january_to_quarter_end_stays_cumulative(rules: Rules) -> None:
header = _variant1(rules, title=_title("Январь 2025 г. - Сентябрь 2025 г."))
assert header.period_text == "Январь 2025 г. - Сентябрь 2025 г."
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q3", True)
assert header.warnings == ()
def test_month_range_from_january_to_mid_quarter_has_no_quarter(rules: Rules) -> None:
header = _variant1(rules, title=_title("Январь 2025 г. - Май 2025 г."))
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None)
assert any("период" in warning for warning in header.warnings)
# --- Даты числами / датами (xlsb) (T5.5) ---

View File

@ -260,6 +260,80 @@ def test_number_warnings_carry_row_numbers(rules: Rules) -> None:
assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome))
# --- Шапка без меток и код/наименование счёта в двух колонках (issue #11 п. 4, OQ-134) ---
NO_LABEL_ORG_SUMS = (None, 600, 100, None, None, 500)
NO_LABEL_SUPPLIER = (None, 400, None, None, None, 400)
NO_LABEL_CONTRACTOR = (None, 200, 100, None, None, 100)
def code_name_osv(*, total_label: str | None = None) -> list[Cells]:
"""ОСВ 63 Новоросметалл: «Бухгалтерский | период» отдельной строкой, организация — в названии ведомости, колонки
«Счет: Код / Наименование», контрагенты — в колонке правее кода (уровень — сдвиг колонки), «Итого» без подписи.
"""
blank = [None] * 3
sums_head = ["Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"]
def line(label_cells: Sequence[object], sums: Sequence[object]) -> Cells:
return [*label_cells, *sums]
return [
["Оборотно-сальдовая ведомость НОВОРОСМЕТАЛЛ ООО"], # 1
[], # 2
["Бухгалтерский", *([None] * 10), "9 месяцев 2025 г."], # 3
["Счет", *([None] * 5), M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4
["Код", *blank, "Наименование", None, *sums_head], # 5
line([63, *blank, "Резервы по сомнительным долгам", None], NO_LABEL_ORG_SUMS), # 6
line([None, "ООО Поставщик", None, None, None, None], NO_LABEL_SUPPLIER), # 7
line([None, "ООО Подрядчик", None, None, None, None], NO_LABEL_CONTRACTOR), # 8
line([total_label, None, None, None, None, None], NO_LABEL_ORG_SUMS), # 9
]
def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX")
document = only_document(outcome)
header = document.header
assert header.organization_name == "НОВОРОСМЕТАЛЛ ООО"
assert header.account_code_raw == "63"
assert (header.period_text, header.period_year, header.period_quarter, header.is_cumulative) == (
"9 месяцев 2025 г.",
2025,
"Q3",
True,
)
assert header.data_type == "BU"
warnings = warnings_of(outcome)
assert not any("период" in warning and "не найден" in warning for warning in warnings), warnings
assert not any("тип выводимых данных" in warning for warning in warnings), warnings
def test_code_name_columns_rows_are_account_and_children_by_label_column(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX")
document = only_document(outcome)
assert [(row.row_value, row.level, row.group_type) for row in document.rows] == [
("63", 0, "ACCOUNT"),
("ООО Поставщик", 1, "OTHER"),
("ООО Подрядчик", 1, "OTHER"),
("Итого", 0, "TOTAL"),
]
assert document.rows[0].extra == {"Наименование": "Резервы по сомнительным долгам"}
assert outcome.status is ResultStatus.DONE
def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None:
outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX")
document = only_document(outcome)
assert document.rows[-1].group_type == "TOTAL"
assert document.checks is not None
assert document.checks.total_row_matches_root is True
assert outcome.status is ResultStatus.DONE
assert any("без подписи" in warning for warning in warnings_of(outcome)), warnings_of(outcome)
# --- Несколько листов ---

View File

@ -184,6 +184,69 @@ def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules
assert end.search("с 01.01.2025 по 31.03.2025") is None
@pytest.mark.parametrize(
("text", "expected"),
[("январь", 1), ("Мая", 5), ("мая", 5), ("октября", 10), ("Декабрь", 12), ("квартал", None), ("", None)],
)
def test_month_number_by_name_in_any_case(rules: Rules, text: str, expected: int | None) -> None:
assert rules.periods.month_number(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("Октябрь 2024 г. - Сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
("с октября 2024 по сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
("апрель - июнь 2025 г.", (4, 2025, 6, 2025, 3)),
("сентябрь - май 2025", (9, 2024, 5, 2025, 9)),
("Январь 2025 г. - Сентябрь 2025 г.", (1, 2025, 9, 2025, 9)),
],
)
def test_month_range_with_years(rules: Rules, text: str, expected: tuple[int, int, int, int, int]) -> None:
found = rules.periods.month_range(text)
assert found is not None
assert (found.start_month, found.start_year, found.end_month, found.end_year, found.months) == expected
@pytest.mark.parametrize("text", ["январь-март 2025 г.", "9 месяцев 2025 г.", "3 квартал 2025 г.", "2025 г.", ""])
def test_month_range_is_none_for_other_period_texts(rules: Rules, text: str) -> None:
assert rules.periods.month_range(text) is None
def test_periods_months_without_all_month_groups_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["months"] = "(?P<m01>январ)"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*m01"):
load_rules(target)
def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["range"] = "январ"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*range"):
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"

View File

@ -6,6 +6,7 @@ import pytest
from fixtures_helpers import (
FIXTURES_ENV,
fixtures_skip_reason,
osv_dkb_files,
osv_fixture_files,
resolve_fixtures_dir,
synthetic_files,
@ -45,6 +46,19 @@ def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None:
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
def test_osv_dkb_files_skip_analysis_reports_and_hidden_files(tmp_path: Path) -> None:
dkb = tmp_path / "osv-dkb"
(dkb / "nm" / "2025-Q3").mkdir(parents=True)
(dkb / "analysis-reports").mkdir()
(dkb / "nm" / "2025-Q3" / "a.xlsx").write_bytes(b"")
(dkb / "nm" / "2025-Q3" / "~$a.xlsx").write_bytes(b"")
(dkb / "nm" / "README.md").write_bytes(b"")
(dkb / "analysis-reports" / "report.xls").write_bytes(b"")
assert osv_dkb_files(tmp_path) == [dkb / "nm" / "2025-Q3" / "a.xlsx"]
assert osv_dkb_files(tmp_path / "нет") == []
def test_hidden_and_excel_lock_files_are_ignored(tmp_path: Path) -> None:
"""T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры."""
osv = tmp_path / "osv"