feat(osv): форматы пакета ДКБ — период-диапазон, Итого по Σ уровня 0, «Итого развернутое», шапка ОСВ 63 (#11) #13
@ -68,7 +68,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
||||||
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
||||||
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
||||||
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 88 файлов: 72 реальных + 16 синтетических) — регрессия, а не
|
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 91 файл: 72 реальных + 19 синтетических) — регрессия, а не
|
||||||
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
||||||
проверяют property-тест и ручная сверка «Итого».
|
проверяют property-тест и ручная сверка «Итого».
|
||||||
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
||||||
|
|||||||
20
README.md
20
README.md
@ -158,6 +158,24 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка
|
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение 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` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
|
Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
|
||||||
@ -251,7 +269,7 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки —
|
|||||||
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
|
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
|
||||||
которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check`
|
которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check`
|
||||||
завершается кодом 1.
|
завершается кодом 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, текстовые числа и т. д.).
|
форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.).
|
||||||
Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml`
|
Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml`
|
||||||
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а
|
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а
|
||||||
|
|||||||
@ -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` — для всего прочего) |
|
| `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` — конец текста периода в строке шапки («г.», «год») |
|
| `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`, `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
|
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
|
||||||
|
|
||||||
## Как писать паттерны
|
## Как писать паттерны
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,11 @@ header:
|
|||||||
- ['\bну\b|налогов', NU]
|
- ['\bну\b|налогов', NU]
|
||||||
- ['\bсумм', SUM]
|
- ['\bсумм', SUM]
|
||||||
|
|
||||||
|
# Тип данных отдельной ячейкой шапки без метки «Выводимые данные:» («Бухгалтерский» в строке «Бухгалтерский | период»):
|
||||||
|
# ячейка целиком — название типа; ищется, только если метки нет. НУ отдельной ячейкой в файлах не встречался.
|
||||||
|
standalone_header:
|
||||||
|
- ['^бухгалтерск\w*(\s+учет\w*)?$', BU]
|
||||||
|
|
||||||
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
|
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
|
||||||
row:
|
row:
|
||||||
- ['^вал\.?$', CURRENCY]
|
- ['^вал\.?$', CURRENCY]
|
||||||
|
|||||||
@ -49,6 +49,28 @@ service_columns:
|
|||||||
total_row:
|
total_row:
|
||||||
- '^итого\b'
|
- '^итого\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:
|
header_markers:
|
||||||
# «по счету 60 за …»: код счёта — сразу после совпадения
|
# «по счету 60 за …»: код счёта — сразу после совпадения
|
||||||
|
|||||||
@ -25,3 +25,16 @@ year: '(?<!\d)(19|20)\d{2}(?!\d)'
|
|||||||
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
|
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
|
||||||
# без совпадения период идёт до конца строки
|
# без совпадения период идёт до конца строки
|
||||||
end: '\bг\.|\bгода?\b'
|
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\.?|года?)?$'
|
||||||
|
|||||||
@ -9,8 +9,11 @@
|
|||||||
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
|
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
|
||||||
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
|
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
|
||||||
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
|
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
|
||||||
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0); корня нет (пустая ОСВ, R-13) — «Итого»
|
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
|
||||||
должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`.
|
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
|
||||||
|
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
|
||||||
|
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
|
||||||
|
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
|
||||||
|
|
||||||
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
||||||
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
||||||
@ -111,7 +114,8 @@ def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
|
|||||||
|
|
||||||
|
|
||||||
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
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:
|
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:
|
def _level_zero(rows: Sequence[ClassifiedRow]) -> list[ClassifiedRow]:
|
||||||
"""«Итого» = первая корневая строка ACCOUNT по шести колонкам (±0.01).
|
"""Строки уровня 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)
|
total = _total(rows)
|
||||||
if total is None:
|
if total is None:
|
||||||
@ -132,7 +154,12 @@ def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
|
|||||||
root = _root(rows)
|
root = _root(rows)
|
||||||
if root is None:
|
if root is None:
|
||||||
return all(abs(value) <= TOLERANCE for value in _values(total))
|
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:
|
def _rows_text(rows: Iterable[int]) -> str:
|
||||||
@ -150,7 +177,11 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
|
|||||||
return TOTAL_NOT_FOUND_WARNING
|
return TOTAL_NOT_FOUND_WARNING
|
||||||
if root is None:
|
if root is None:
|
||||||
return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
|
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]]:
|
def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]:
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Literal
|
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.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
|
||||||
from src.parser.grid import Sheet, column_letter
|
from src.parser.grid import Sheet, column_letter
|
||||||
from src.parser.hierarchy import BodyRow, Levels
|
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
|
from src.parser.rules import GroupType, Rules, first_match, normalize_text
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@ -49,6 +49,10 @@ __all__ = [
|
|||||||
# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
|
# («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)
|
ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
|
||||||
_SEGMENT_SEPARATOR = "."
|
_SEGMENT_SEPARATOR = "."
|
||||||
|
# Сдвиг на одну колонку текста в «индентах» строки: больше любого отступа ячейки
|
||||||
|
LABEL_COLUMN_INDENT_STEP = 1000
|
||||||
|
# Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных
|
||||||
|
MIN_ROWS_WITH_TOTAL = 2
|
||||||
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
|
# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
|
||||||
DEADLINE_CHECK_EVERY_ROWS = 500
|
DEADLINE_CHECK_EVERY_ROWS = 500
|
||||||
# Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта
|
# Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта
|
||||||
@ -112,6 +116,8 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
|
|||||||
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
|
строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
|
||||||
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
|
чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
|
||||||
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
|
«Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
|
||||||
|
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого»
|
||||||
|
перед ней; без обычной «Итого» вторая строка служит единственной итоговой.
|
||||||
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
|
Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
|
||||||
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
|
«Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
|
||||||
"""
|
"""
|
||||||
@ -119,14 +125,41 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
|
|||||||
return None
|
return None
|
||||||
outline_floor = min(sheet.outline(row) for row in rows)
|
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)
|
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):
|
for row in reversed(rows):
|
||||||
text = sheet.text_at(row, header.text_col)
|
text = sheet.text_at(row, header.text_col)
|
||||||
if not any(pattern.search(text) for pattern in rules.headers.total_row):
|
if not any(pattern.search(text) for pattern in rules.headers.total_row):
|
||||||
continue
|
continue
|
||||||
_, indent = _text_cell_style(sheet, row, header.text_col)
|
_, indent = _text_cell_style(sheet, row, header.text_col)
|
||||||
is_root = sheet.outline(row) == outline_floor and indent == indent_floor
|
if sheet.outline(row) != outline_floor or indent != indent_floor:
|
||||||
return row if is_root else None
|
return only_secondary
|
||||||
return None
|
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:
|
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())
|
return tuple(sums.values())
|
||||||
|
|
||||||
|
|
||||||
def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str]) -> BodyRow:
|
def _has_sums(item: BodyRow) -> bool:
|
||||||
text = sheet.text_at(row, header.text_col)
|
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)
|
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(
|
return BodyRow(
|
||||||
row=row,
|
row=row,
|
||||||
text=text,
|
text=text,
|
||||||
@ -163,6 +266,7 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str])
|
|||||||
indent=indent,
|
indent=indent,
|
||||||
outline=sheet.outline(row),
|
outline=sheet.outline(row),
|
||||||
sums=_sums(sheet, row, header.columns, warnings),
|
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]]:
|
) -> tuple[list[BodyRow], int | None, list[str]]:
|
||||||
"""Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
|
"""Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
|
||||||
|
|
||||||
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5).
|
Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5);
|
||||||
|
исключение — вторые итоговые строки («Итого развернутое») сразу за «Итого»: они остаются в таблице как TOTAL.
|
||||||
"""
|
"""
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if header.columns.collapsed:
|
if header.columns.collapsed:
|
||||||
warnings.append(_collapsed_warning(header.columns))
|
warnings.append(_collapsed_warning(header.columns))
|
||||||
rows = _table_rows(sheet, header, rules, deadline)
|
rows = _table_rows(sheet, header, rules, deadline)
|
||||||
total_row = _find_total(sheet, header, rows, rules)
|
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:
|
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] = []
|
body: list[BodyRow] = []
|
||||||
for index, row in enumerate(rows, start=1):
|
for index, row in enumerate(rows, start=1):
|
||||||
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
if index % DEADLINE_CHECK_EVERY_ROWS == 0:
|
||||||
deadline.check("суммы строк")
|
deadline.check("суммы строк")
|
||||||
body.append(_body_row(sheet, row, header, warnings))
|
body.append(_body_row(sheet, row, header, label_cols, warnings))
|
||||||
return body, total_row, warnings
|
return _glue_inn_rows(body, rules, warnings), total_row, warnings
|
||||||
|
|
||||||
|
|
||||||
# --- Классификация ---
|
# --- Классификация ---
|
||||||
@ -206,8 +317,11 @@ def _extra_keys(sheet: Sheet, header: TableHeader, rows: Sequence[BodyRow]) -> d
|
|||||||
Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)»
|
Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)»
|
||||||
(Review Focus 1); заголовок без повторов — как в файле.
|
(Review Focus 1); заголовок без повторов — как в файле.
|
||||||
"""
|
"""
|
||||||
|
label_cols = {item.label_col for item in rows}
|
||||||
used = {
|
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())
|
titles = list(used.values())
|
||||||
return {col: f"{title} ({column_letter(col)})" if titles.count(title) > 1 else title for col, title in used.items()}
|
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":
|
if group_type != "COUNTERPARTY":
|
||||||
return _Kind(group_type, name, code, doc_data_type), branch
|
return _Kind(group_type, name, code, doc_data_type), branch
|
||||||
inn_col = context.header.inn_col
|
inn_col = context.header.inn_col
|
||||||
inn = None if inn_col is None else inn_digits(context.sheet.cell(item.row, inn_col))
|
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
|
||||||
return _Kind(group_type, name, code, doc_data_type, counterparty_name=item.text, counterparty_inn=inn), branch
|
# ИНН: колонка ИНН, затем строка без сумм над названием (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): всё, что нужно классификатору, приходит явно
|
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
|
||||||
@ -300,8 +417,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
) -> list[ClassifiedRow]:
|
) -> list[ClassifiedRow]:
|
||||||
"""Классификация строк по дереву `levels` и схеме группировок.
|
"""Классификация строк по дереву `levels` и схеме группировок.
|
||||||
|
|
||||||
- «Итого» (`total_row`) — TOTAL, уровень 0 без родителя; в `rows` остаётся (# ASSUMPTION(OQ-01): хранить ли
|
- «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя;
|
||||||
«Итого», решает ядро);
|
в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро);
|
||||||
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
|
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
|
||||||
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
|
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
|
||||||
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
группировки, а вид сумм родителя; позицию в схеме она не занимает;
|
||||||
@ -313,7 +430,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
|
|||||||
branches: dict[int, _Branch] = {}
|
branches: dict[int, _Branch] = {}
|
||||||
result: list[ClassifiedRow] = []
|
result: list[ClassifiedRow] = []
|
||||||
for item in rows:
|
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; хранить ли её, решает ядро
|
# ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
|
||||||
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
|
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
|
||||||
level, parent = 0, None
|
level, parent = 0, None
|
||||||
|
|||||||
@ -392,11 +392,13 @@ def _block_title(sheet: Sheet, first_row: int, last_row: int, col: int) -> str |
|
|||||||
return None
|
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] = []
|
labels: list[int] = []
|
||||||
for row in range(first_row, last_row + 1):
|
for row in range(first_row, last_row + 1):
|
||||||
origin_row, origin_col = sheet.merged_origin(row, text_col)
|
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)
|
labels.append(row)
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
@ -464,7 +466,7 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No
|
|||||||
inn_col=inn_col,
|
inn_col=inn_col,
|
||||||
right_edge=right_edge,
|
right_edge=right_edge,
|
||||||
extra_cols=dict(sorted(extra_cols.items())),
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,22 +6,23 @@
|
|||||||
пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
|
пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
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.common import DataType
|
||||||
from src.contract.result import OsvGroup
|
from src.contract.result import OsvGroup
|
||||||
from src.parser.detect import TableHeader
|
from src.parser.detect import TableHeader
|
||||||
from src.parser.grid import Sheet
|
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"]
|
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
||||||
|
QUARTERS: tuple[Quarter, ...] = get_args(Quarter)
|
||||||
HeaderVariant = Literal[1, 2]
|
HeaderVariant = Literal[1, 2]
|
||||||
|
ACCOUNT_NOT_FOUND_WARNING = "код счёта (после «по счету») в шапке не найден"
|
||||||
|
|
||||||
# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
|
# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
|
||||||
_ACCOUNT_CODE = re.compile(r"\d[^\s,;]*")
|
_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:
|
def _tail(text: str, match: re.Match[str]) -> str:
|
||||||
"""Исходный текст после совпадения (позиции `match` — в нормализованном тексте)."""
|
"""Исходный текст после совпадения (позиции `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:
|
def _find(patterns: Iterable[Pattern], text: str) -> re.Match[str] | None:
|
||||||
@ -147,18 +136,39 @@ class _PeriodHit:
|
|||||||
text: str
|
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:
|
def _cut_period(tail: str, rules: Rules) -> str:
|
||||||
"""Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт.
|
"""Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт.
|
||||||
|
|
||||||
Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно
|
Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно
|
||||||
начало не узнано, период — весь хвост до конца строки.
|
начало не узнано, период — весь хвост до конца строки.
|
||||||
"""
|
"""
|
||||||
|
ranged = _cut_month_range(tail, rules)
|
||||||
|
if ranged is not None:
|
||||||
|
return ranged
|
||||||
start = 0
|
start = 0
|
||||||
while start < len(tail):
|
while start < len(tail):
|
||||||
end = rules.periods.end.search(tail[start:])
|
end = rules.periods.end.search(tail[start:])
|
||||||
if end is None:
|
if end is None:
|
||||||
break
|
break
|
||||||
start += _source_offset(tail[start:], end.end())
|
start += source_offset(tail[start:], end.end())
|
||||||
candidate = tail[:start].strip()
|
candidate = tail[:start].strip()
|
||||||
if rules.periods.match(candidate) is not None:
|
if rules.periods.match(candidate) is not None:
|
||||||
return candidate
|
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
|
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:
|
def _find_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None:
|
||||||
"""Строка периода: сначала с опознанным периодом, затем строка названия ведомости или метка с двоеточием."""
|
"""Строка периода: сначала с опознанным периодом по метке, затем период отдельной ячейкой, затем строка названия
|
||||||
|
ведомости или метка с двоеточием.
|
||||||
|
"""
|
||||||
hits = _period_candidates(cells, rules)
|
hits = _period_candidates(cells, rules)
|
||||||
recognised = next((hit for hit in hits if _is_recognised(hit.text, rules)), None)
|
recognised = next((hit for hit in hits if _is_recognised(hit.text, rules)), None)
|
||||||
if recognised is not None:
|
if recognised is not None:
|
||||||
return recognised
|
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)
|
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:
|
def _standalone_data_type(cells: list[_HeadCell], rules: Rules) -> tuple[_HeadCell, DataType] | None:
|
||||||
"""Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода или меткой валюты."""
|
"""Тип данных отдельной ячейкой без метки: ячейка целиком — «Бухгалтерский» (словарь `standalone_header`)."""
|
||||||
taken = [cell for cell in (None if period is None else period.cell, *_currency_cells(cells, rules)) if cell]
|
for cell in cells:
|
||||||
return next(
|
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)),
|
(cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
return _title_tail_organization(cells, rules) if found is None else found.text
|
||||||
|
|
||||||
|
|
||||||
def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
|
def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
|
||||||
@ -252,7 +305,47 @@ class _Period:
|
|||||||
is_cumulative: bool | None
|
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:
|
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)
|
rule = rules.periods.match(text)
|
||||||
year = rules.periods.year.search(text)
|
year = rules.periods.year.search(text)
|
||||||
if rule is None:
|
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:
|
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"])
|
value = _labelled_value(sheet, cells, rules.headers.header_markers["data_type"])
|
||||||
if value is None:
|
if value is None:
|
||||||
|
standalone = _standalone_data_type(cells, rules)
|
||||||
|
if standalone is not None:
|
||||||
|
return standalone[1]
|
||||||
warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден")
|
warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден")
|
||||||
return None
|
return None
|
||||||
data_type = first_match(rules.data_types.header, value)
|
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] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
period_hit = _find_period(cells, rules)
|
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:
|
if organization is None:
|
||||||
warnings.append("наименование организации в шапке не найдено")
|
warnings.append("наименование организации в шапке не найдено")
|
||||||
account = _account_code(cells, rules)
|
account = _account_code(cells, rules)
|
||||||
if account is None:
|
if account is None:
|
||||||
warnings.append("код счёта (после «по счету») в шапке не найден")
|
warnings.append(ACCOUNT_NOT_FOUND_WARNING)
|
||||||
period_text = None if period_hit is None else period_hit.text
|
period_text = None if period_hit is None else period_hit.text
|
||||||
if period_text is None:
|
if period_text is None:
|
||||||
warnings.append("период (после «за» или «Период:») в шапке не найден")
|
warnings.append("период (после «за» или «Период:») в шапке не найден")
|
||||||
@ -316,7 +413,7 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead
|
|||||||
currency = _parse_currency(sheet, cells, rules, warnings)
|
currency = _parse_currency(sheet, cells, rules, warnings)
|
||||||
variant: HeaderVariant = 1 if _subkonto_items(cells, rules) is None else 2
|
variant: HeaderVariant = 1 if _subkonto_items(cells, rules) is None else 2
|
||||||
return DocHeader(
|
return DocHeader(
|
||||||
organization_name="" if organization is None else organization.text,
|
organization_name="" if organization is None else organization,
|
||||||
account_code_raw=account or "",
|
account_code_raw=account or "",
|
||||||
period_text=period_text or "",
|
period_text=period_text or "",
|
||||||
period_year=period.year,
|
period_year=period.year,
|
||||||
|
|||||||
@ -58,6 +58,8 @@ class BodyRow:
|
|||||||
indent: int # alignment.indent или число ведущих пробелов (единицы разные — сравниваются только ранги)
|
indent: int # alignment.indent или число ведущих пробелов (единицы разные — сравниваются только ранги)
|
||||||
outline: int
|
outline: int
|
||||||
sums: tuple[Decimal | None, ...]
|
sums: tuple[Decimal | None, ...]
|
||||||
|
label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы)
|
||||||
|
inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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:
|
def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
|
||||||
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
|
||||||
|
|
||||||
«Итого» (`total_row`) в стратегии не передаётся и получает уровень 0: это контрольная сумма документа, а не
|
«Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это
|
||||||
ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`.
|
контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное
|
||||||
|
дерево корней `TRIVIAL_STRATEGY`.
|
||||||
"""
|
"""
|
||||||
body = [item for item in rows if item.row != total_row]
|
# «Итого» и вторые итоговые строки за ней («Итого развернутое») — не данные: уровень 0 без участия стратегий
|
||||||
pinned = {} if total_row is None else {total_row: ROOT_LEVEL}
|
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:
|
if len(body) < MIN_ROWS_FOR_CASCADE:
|
||||||
by_row = {item.row: ROOT_LEVEL for item in rows}
|
by_row = {item.row: ROOT_LEVEL for item in rows}
|
||||||
return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)
|
return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)
|
||||||
|
|||||||
@ -15,14 +15,17 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from src.parser.detect import INN_LENGTHS, inn_from_cell
|
from src.parser.detect import INN_LENGTHS, inn_from_cell
|
||||||
from src.parser.grid import Cell
|
from src.parser.grid import Cell
|
||||||
|
from src.parser.rules import Rules, source_offset
|
||||||
|
|
||||||
__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")
|
MONEY_Q = Decimal("0.01")
|
||||||
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
|
# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
|
||||||
# извлечении из выбранной колонки ИНН, ruling R-24)
|
# извлечении из выбранной колонки ИНН, ruling R-24)
|
||||||
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
|
_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
|
||||||
_ZERO = Decimal("0.00")
|
_ZERO = Decimal("0.00")
|
||||||
|
# Что остаётся на конце названия после отрезанного хвоста с ИНН: знаки-разделители («ООО Альфа (», «ООО Альфа,»)
|
||||||
|
_NAME_TRAILING = " ,;(/:-–—"
|
||||||
|
|
||||||
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
|
# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
|
||||||
# числа с гигантской экспонентой само падает с InvalidOperation
|
# числа с гигантской экспонентой само падает с InvalidOperation
|
||||||
@ -137,3 +140,40 @@ def _restored_leading_zero(cell: Cell) -> str | None:
|
|||||||
return None
|
return None
|
||||||
digits = str(int(value))
|
digits = str(int(value))
|
||||||
return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None
|
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
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from loguru import logger
|
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.detect import SheetKind, TableHeader, classify_sheet, find_table_header
|
||||||
from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
|
from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
|
||||||
from src.parser.grid import Sheet, column_letter
|
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.hierarchy import resolve_levels
|
||||||
from src.parser.readers import read_book
|
from src.parser.readers import read_book
|
||||||
from src.parser.readers._common import one_line_reason
|
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:
|
def _is_ok(checks: OsvChecks) -> bool:
|
||||||
return (
|
return (
|
||||||
bool(checks.total_row_matches_root)
|
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)
|
classified = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
|
||||||
except InvalidFormatError as exc:
|
except InvalidFormatError as exc:
|
||||||
raise _invalid_format(sheet, step, exc) from exc
|
raise _invalid_format(sheet, step, exc) from exc
|
||||||
|
doc = _with_table_account(doc, classified)
|
||||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||||
checks, check_warnings = run_checks(classified)
|
checks, check_warnings = run_checks(classified)
|
||||||
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
|
document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа.
|
неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import bisect
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -24,6 +25,7 @@ from src.contract.common import DataType
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_RULES_DIR",
|
"DEFAULT_RULES_DIR",
|
||||||
"DataTypeRules",
|
"DataTypeRules",
|
||||||
|
"MonthRange",
|
||||||
"OsvGroupRules",
|
"OsvGroupRules",
|
||||||
"OsvHeaderRules",
|
"OsvHeaderRules",
|
||||||
"Pattern",
|
"Pattern",
|
||||||
@ -33,6 +35,7 @@ __all__ = [
|
|||||||
"first_match",
|
"first_match",
|
||||||
"load_rules",
|
"load_rules",
|
||||||
"normalize_text",
|
"normalize_text",
|
||||||
|
"source_offset",
|
||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules"
|
DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules"
|
||||||
@ -66,6 +69,18 @@ def normalize_text(s: str) -> str:
|
|||||||
return _SEPARATORS.sub(" ", lowered).strip()
|
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:
|
class Pattern:
|
||||||
"""Регулярное выражение словаря: `search` применяется к `normalize_text(text)`, регистр не учитывается."""
|
"""Регулярное выражение словаря: `search` применяется к `normalize_text(text)`, регистр не учитывается."""
|
||||||
|
|
||||||
@ -97,6 +112,11 @@ class Pattern:
|
|||||||
"""Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
|
"""Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
|
||||||
return self._regex.search(normalized)
|
return self._regex.search(normalized)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def group_names(self) -> frozenset[str]:
|
||||||
|
"""Имена именованных групп паттерна: словарь требует их у записей месяцев и диапазона."""
|
||||||
|
return frozenset(self._regex.groupindex)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Pattern({self.source!r})"
|
return f"Pattern({self.source!r})"
|
||||||
|
|
||||||
@ -130,6 +150,11 @@ class OsvHeaderRules(_Model):
|
|||||||
credit: list[Pattern]
|
credit: list[Pattern]
|
||||||
service_columns: dict[str, list[Pattern]]
|
service_columns: dict[str, list[Pattern]]
|
||||||
total_row: 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]]
|
header_markers: dict[HeaderMarker, list[Pattern]]
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
@ -143,6 +168,15 @@ class OsvHeaderRules(_Model):
|
|||||||
raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
|
raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
|
||||||
if "inn" not in self.service_columns:
|
if "inn" not in self.service_columns:
|
||||||
raise ValueError("service_columns: нет обязательного ключа 'inn'")
|
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
|
return self
|
||||||
|
|
||||||
|
|
||||||
@ -162,10 +196,68 @@ class PeriodRule(_Model):
|
|||||||
is_cumulative: bool
|
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):
|
class PeriodRules(_Model):
|
||||||
rules: list[PeriodRule]
|
rules: list[PeriodRule]
|
||||||
year: Pattern
|
year: Pattern
|
||||||
end: 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:
|
def match(self, text: str) -> PeriodRule | None:
|
||||||
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
|
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
|
||||||
@ -177,6 +269,7 @@ class PeriodRules(_Model):
|
|||||||
|
|
||||||
class DataTypeRules(_Model):
|
class DataTypeRules(_Model):
|
||||||
header: list[tuple[Pattern, DataType]]
|
header: list[tuple[Pattern, DataType]]
|
||||||
|
standalone_header: list[tuple[Pattern, DataType]]
|
||||||
row: list[tuple[Pattern, RowDataType]]
|
row: list[tuple[Pattern, RowDataType]]
|
||||||
currency_codes: list[str]
|
currency_codes: list[str]
|
||||||
currency_aliases: dict[str, list[Pattern]]
|
currency_aliases: dict[str, list[Pattern]]
|
||||||
|
|||||||
7
api/tests/fixtures/test_rules_coverage.py
vendored
7
api/tests/fixtures/test_rules_coverage.py
vendored
@ -6,6 +6,9 @@
|
|||||||
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
|
`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
|
||||||
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
|
находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
|
||||||
|
|
||||||
|
Файлы — реальные ОСВ `osv/**`, пакет ДКБ `osv-dkb/**` (диапазон месяцев, «Итого развернутое», шапка без меток) и
|
||||||
|
синтетика.
|
||||||
|
|
||||||
«Срабатывание» — `search`/`search_normalized` вернул совпадение. Счётчик — обёртка вокруг методов `Pattern` в тесте,
|
«Срабатывание» — `search`/`search_normalized` вернул совпадение. Счётчик — обёртка вокруг методов `Pattern` в тесте,
|
||||||
боевой код не меняется.
|
боевой код не меняется.
|
||||||
"""
|
"""
|
||||||
@ -13,7 +16,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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 rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
|
||||||
|
|
||||||
from src.parser import get_parser
|
from src.parser import get_parser
|
||||||
@ -33,7 +36,7 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
|
|||||||
base = get_parser()
|
base = get_parser()
|
||||||
assert isinstance(base, OsvParser)
|
assert isinstance(base, OsvParser)
|
||||||
parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)
|
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:
|
for file in files:
|
||||||
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
|
parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
|
||||||
return rules, counter, len(files)
|
return rules, counter, len(files)
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from pathlib import Path
|
|||||||
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
||||||
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
|
||||||
_SYNTHETIC = "synthetic"
|
_SYNTHETIC = "synthetic"
|
||||||
|
_OSV_DKB = "osv-dkb"
|
||||||
|
_ANALYSIS_REPORTS = "analysis-reports"
|
||||||
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
|
# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
|
||||||
_IGNORED_PREFIXES = (".", "~$")
|
_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)
|
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]:
|
def synthetic_files(root: Path) -> list[Path]:
|
||||||
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
|
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
|
||||||
считаются); отсортированы."""
|
считаются); отсортированы."""
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
||||||
|
|
||||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||||||
ОСВ (R-22); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
|
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание —
|
||||||
|
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
|
||||||
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
|
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
|
||||||
записи фиксирован.
|
записи фиксирован.
|
||||||
|
|
||||||
@ -43,6 +44,9 @@ Pair = tuple[Money, Money]
|
|||||||
Six = tuple[D, D, D, D, D, D]
|
Six = tuple[D, D, D, D, D, D]
|
||||||
Hierarchy = Literal["outline", "indent", "spaces", "none"]
|
Hierarchy = Literal["outline", "indent", "spaces", "none"]
|
||||||
NumStyle = Literal["num", "text", "text_nbsp", "text_narrow"]
|
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)
|
turnover: Pair = (None, None)
|
||||||
indicator: bool = False
|
indicator: bool = False
|
||||||
num_style: NumStyle = "num"
|
num_style: NumStyle = "num"
|
||||||
|
inn_style: InnStyle = "column"
|
||||||
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
|
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
|
||||||
|
|
||||||
def children(self) -> list["Node"]:
|
def children(self) -> list["Node"]:
|
||||||
@ -200,6 +205,7 @@ class OsvSpec:
|
|||||||
detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2)
|
detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2)
|
||||||
org_name: str | None = ORG # None — в шапке нет строки с названием организации
|
org_name: str | None = ORG # None — в шапке нет строки с названием организации
|
||||||
group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
|
group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
|
||||||
|
inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная)
|
||||||
labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
|
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)
|
sheet.put(label_row + 1, 1, sub_label)
|
||||||
service_row = label_row + 1 if below else top + 1
|
service_row = label_row + 1 if below else top + 1
|
||||||
if spec.inn_col is not None:
|
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:
|
if spec.kpp_col is not None:
|
||||||
sheet.put(service_row, spec.kpp_col, "КПП")
|
sheet.put(service_row, spec.kpp_col, "КПП")
|
||||||
for key, col, width in _groups(spec):
|
for key, col, width in _groups(spec):
|
||||||
@ -275,19 +281,38 @@ def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int:
|
|||||||
return nxt
|
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` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
|
"""Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
|
||||||
node, depth = entry
|
node, depth = entry
|
||||||
mode = spec.hierarchy
|
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())
|
bold = spec.bold_accounts and bool(node.children())
|
||||||
sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0)
|
sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0)
|
||||||
if mode == "outline" and depth:
|
if mode == "outline" and depth:
|
||||||
sheet.outline[row] = depth
|
sheet.outline[row] = depth
|
||||||
if spec.inn_col is not None and 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, node.inn)
|
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:
|
if spec.kpp_col is not None and node.kpp:
|
||||||
sheet.put(row, spec.kpp_col, node.kpp)
|
sheet.put(row, spec.kpp_col, node.kpp)
|
||||||
|
if blank:
|
||||||
|
return # строка без сумм (ИНН над названием)
|
||||||
values = list(six)
|
values = list(six)
|
||||||
values[4] += node.closing_debit_delta
|
values[4] += node.closing_debit_delta
|
||||||
for key, col, width in _groups(spec):
|
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:
|
if spec.repeat_header_after == index:
|
||||||
# Печать по страницам: пустая строка и копия блока заголовка
|
# Печать по страницам: пустая строка и копия блока заголовка
|
||||||
next_row = _header_rows(sheet, spec, next_row + 1, "Счет", "Контрагенты")
|
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())
|
_row_cells(sheet, spec, next_row, (node, depth), node.six())
|
||||||
next_row += 1
|
next_row += 1
|
||||||
total = SheetSpec("")
|
total = SheetSpec("")
|
||||||
@ -586,6 +616,35 @@ _CORE_PART = "docProps/core.xml"
|
|||||||
_FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode()
|
_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:
|
def _normalized_zip(data: bytes) -> bytes:
|
||||||
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
|
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
|
||||||
при каждом `save`, заданное в свойствах значение перезаписывает)."""
|
при каждом `save`, заданное в свойствах значение перезаписывает)."""
|
||||||
@ -651,6 +710,9 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
|
|||||||
"s14_statement_forms.xlsx": (_s14, to_xlsx),
|
"s14_statement_forms.xlsx": (_s14, to_xlsx),
|
||||||
"s15_group_title_synonyms.xlsx": (_s15, to_xlsx),
|
"s15_group_title_synonyms.xlsx": (_s15, to_xlsx),
|
||||||
"s16_header_synonyms.xlsx": (_s16, 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)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -669,7 +731,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="python -m tests.synthetic.generate",
|
prog="python -m tests.synthetic.generate",
|
||||||
description="Синтетические ОСВ s01…s16 (xlsx и один xlsb) в указанный каталог.",
|
description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.",
|
||||||
)
|
)
|
||||||
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
|
|||||||
assert left.read_bytes() == right.read_bytes(), left.name
|
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)]
|
names = [path.name for path in generate_all(tmp_path)]
|
||||||
|
|
||||||
assert names == list(FILE_NAMES)
|
assert names == list(FILE_NAMES)
|
||||||
|
|||||||
@ -43,6 +43,9 @@ EXPECTED_NAMES = [
|
|||||||
"s14_statement_forms.xlsx",
|
"s14_statement_forms.xlsx",
|
||||||
"s15_group_title_synonyms.xlsx",
|
"s15_group_title_synonyms.xlsx",
|
||||||
"s16_header_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_NAME = "s13_no_outline_indent.xlsb"
|
||||||
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
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),
|
"s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
|
||||||
# шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2
|
# шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2
|
||||||
"s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 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:
|
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())
|
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
|
||||||
assert present == [name for name in EXPECTED_NAMES if name in present]
|
assert present == [name for name in EXPECTED_NAMES if name in present]
|
||||||
missing = [name for name in EXPECTED_NAMES if name not in present]
|
missing = [name for name in EXPECTED_NAMES if name not in present]
|
||||||
@ -483,3 +492,55 @@ def test_s16_header_without_organization_year_only_period_and_currency_note(fixt
|
|||||||
("OTHER", "Валюта"),
|
("OTHER", "Валюта"),
|
||||||
]
|
]
|
||||||
assert outcome.status is ResultStatus.DONE
|
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"
|
||||||
|
|||||||
@ -228,6 +228,92 @@ def test_total_matches_first_root_account() -> None:
|
|||||||
assert total_matches_root(rows) is False
|
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:
|
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, EMPTY)]) is True
|
||||||
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True
|
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True
|
||||||
|
|||||||
@ -532,6 +532,138 @@ def test_header_repeat_is_skipped(rules: Rules) -> None:
|
|||||||
assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW
|
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 ---
|
# --- 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
|
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:
|
def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
|
||||||
"""T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого»."""
|
"""T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого»."""
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
|
|||||||
@ -427,6 +427,57 @@ def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, qua
|
|||||||
assert header.warnings == ()
|
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) ---
|
# --- Даты числами / датами (xlsb) (T5.5) ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
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)
|
||||||
|
|
||||||
|
|
||||||
# --- Несколько листов ---
|
# --- Несколько листов ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
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:
|
def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
|
||||||
target = _copy_rules(tmp_path)
|
target = _copy_rules(tmp_path)
|
||||||
path = target / "periods.yaml"
|
path = target / "periods.yaml"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import pytest
|
|||||||
from fixtures_helpers import (
|
from fixtures_helpers import (
|
||||||
FIXTURES_ENV,
|
FIXTURES_ENV,
|
||||||
fixtures_skip_reason,
|
fixtures_skip_reason,
|
||||||
|
osv_dkb_files,
|
||||||
osv_fixture_files,
|
osv_fixture_files,
|
||||||
resolve_fixtures_dir,
|
resolve_fixtures_dir,
|
||||||
synthetic_files,
|
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"]
|
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:
|
def test_hidden_and_excel_lock_files_are_ignored(tmp_path: Path) -> None:
|
||||||
"""T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры."""
|
"""T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры."""
|
||||||
osv = tmp_path / "osv"
|
osv = tmp_path / "osv"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user