feat(osv): счета без сальдо по аналитике (91, 99) вне тождества строки (OQ-132, #11 п. 2)
У 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09), поэтому каждая ОСВ 91 получала needs_check. Список счетов — данные api/rules/osv_checks.yaml (accounts_without_analytic_balance: 91, 99; 90 не входит). - тождество строки на таких счетах не проверяется; - «сумма детей = родитель» сохраняется: обороты всегда, сальдо — только у родителя с дочерними субсчетами (корень -> субсчета); у родителя с одной аналитикой сальдо не сверяется; - «Итого» сверяется как обычно; - строка относится к счёту списка по коду ближайшего счёта ветки (91, 91.01; не 910). Тесты: unit checks/rules, манифест osv_dkb_expectations (две ОСВ 91 теперь done). README, CLAUDE.md, openspec/specs/osv-parsing, api/rules/README.md обновлены. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
76f8df48d2
commit
49400682bc
@ -80,6 +80,10 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
|
по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
|
||||||
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
|
контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
|
||||||
|
|
||||||
|
**Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (91, 99) — данные `api/rules/osv_checks.yaml`, не код;
|
||||||
|
тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и
|
||||||
|
«Итого» сохраняются. Счёт 90 в список не входит. Не расширять список «чтобы файл прошёл» — только по решению владельца.
|
||||||
|
|
||||||
**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
|
**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
|
||||||
кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют
|
кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют
|
||||||
лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23).
|
лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23).
|
||||||
|
|||||||
13
README.md
13
README.md
@ -120,7 +120,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` |
|
| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` |
|
||||||
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
||||||
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
||||||
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень |
|
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (91, 99) — вне тождества строки (`osv_checks.yaml`) |
|
||||||
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
|
| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
|
||||||
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
|
| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
|
||||||
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
|
| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
|
||||||
@ -176,11 +176,20 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо
|
|||||||
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
||||||
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
|
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
|
||||||
|
|
||||||
|
**Счета без сальдо по аналитике (OQ-132).** У 91 и 99 строки аналитики несут только обороты, сальдо закрывается на
|
||||||
|
субсчёт закрытия (91.09), поэтому тождество строки на них не проверяется; иначе каждая ОСВ 91 получала бы `needs_check`.
|
||||||
|
Список счетов — данные: `accounts_without_analytic_balance` в `api/rules/osv_checks.yaml` (начальный состав `91`, `99`;
|
||||||
|
90 не входит — внутри года у его субсчетов сальдо есть). Строка относится к счёту списка по коду ближайшего счёта её ветки:
|
||||||
|
код совпадает с записью или является её субсчётом (`91`, `91.01`, но не `910`). Сверка «сумма детей = родитель» остаётся:
|
||||||
|
обороты — всегда, сальдо — только у родителя с дочерними субсчетами (корень → 91.01/91.02/91.09); у родителя с одной
|
||||||
|
аналитикой сальдо не сверяется. «Итого» сверяется как обычно.
|
||||||
|
|
||||||
### Словари и как добавить синоним
|
### Словари и как добавить синоним
|
||||||
|
|
||||||
Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
|
Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
|
||||||
`osv_groups.yaml` (группировки → `ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER`), `periods.yaml` (период → квартал и
|
`osv_groups.yaml` (группировки → `ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER`), `periods.yaml` (период → квартал и
|
||||||
нарастающий итог), `data_types.yaml` (БУ/НУ/сумма, строки «Вал.»/«Кол.», коды и написания валют). Формат и правила
|
нарастающий итог), `data_types.yaml` (БУ/НУ/сумма, строки «Вал.»/«Кол.», коды и написания валют), `osv_checks.yaml` (счета без сальдо
|
||||||
|
по аналитике — вне тождества строки). Формат и правила
|
||||||
записи паттернов — [`api/rules/README.md`](api/rules/README.md).
|
записи паттернов — [`api/rules/README.md`](api/rules/README.md).
|
||||||
|
|
||||||
Новый формат подключается строкой в YAML, без правки `src/`. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на
|
Новый формат подключается строкой в YAML, без правки `src/`. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на
|
||||||
|
|||||||
@ -12,6 +12,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `osv_headers.yaml` | `sheet_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_headers.yaml` | `sheet_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` — для всего прочего) |
|
||||||
|
| `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (начальный состав `91`, `99`; OQ-132): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения |
|
||||||
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
|
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
|
||||||
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
|
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
|
||||||
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке `min_codes`, ослабленный минимум для листа-продолжения после формы `min_codes_continuation`), `okud_code` (код ОКУД внутри ячейки), `units_thousands` (единицы в тысячах), `fractional_share_percent` (порог доли значений с дробной частью при «тыс. руб.», процент 1..100: при превышении — одно предупреждение на документ; на статус не влияет) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
|
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке `min_codes`, ослабленный минимум для листа-продолжения после формы `min_codes_continuation`), `okud_code` (код ОКУД внутри ячейки), `units_thousands` (единицы в тысячах), `fractional_share_percent` (порог доли значений с дробной частью при «тыс. руб.», процент 1..100: при превышении — одно предупреждение на документ; на статус не влияет) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
|
||||||
|
|||||||
13
api/rules/osv_checks.yaml
Normal file
13
api/rules/osv_checks.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Параметры контрольных сумм ОСВ (05-domain-rules.md §1.4). Данные, а не код: счёт добавляется строкой.
|
||||||
|
#
|
||||||
|
# Счета без сальдо по аналитике (решение владельца 07.10.2026, OQ-132): строки аналитики несут только обороты, сальдо
|
||||||
|
# закрывается на субсчёт закрытия (91.09), поэтому для таких счетов:
|
||||||
|
# - тождество строки «начало + обороты = конец» не проверяется;
|
||||||
|
# - сверка «сумма детей = родитель» сохраняется: обороты сверяются всегда, сальдо — только у родителя, у которого
|
||||||
|
# есть субсчета (корень со своими субсчетами); у родителя с одной лишь аналитикой сальдо не сверяется;
|
||||||
|
# - «Итого» сверяется как обычно.
|
||||||
|
# Запись — код счёта верхнего уровня («91»); к нему относятся и его субсчета (91.01, 91.09), но не «910». Счёт 90 не
|
||||||
|
# входит: внутри года у его субсчетов сальдо обычно есть.
|
||||||
|
accounts_without_analytic_balance:
|
||||||
|
- '91'
|
||||||
|
- '99'
|
||||||
@ -15,6 +15,11 @@
|
|||||||
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
|
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
|
||||||
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
|
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
|
||||||
|
|
||||||
|
Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только
|
||||||
|
обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2
|
||||||
|
обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь
|
||||||
|
аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется.
|
||||||
|
|
||||||
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
|
||||||
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
|
||||||
"""
|
"""
|
||||||
@ -25,6 +30,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from src.contract.result import OsvChecks
|
from src.contract.result import OsvChecks
|
||||||
from src.parser.classify import SUM_FIELDS, ClassifiedRow
|
from src.parser.classify import SUM_FIELDS, ClassifiedRow
|
||||||
|
from src.parser.rules import OsvCheckRules
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"TOLERANCE",
|
"TOLERANCE",
|
||||||
@ -64,10 +70,20 @@ def _is_indicator(item: ClassifiedRow) -> bool:
|
|||||||
return item.data_type in _INDICATOR_DATA_TYPES
|
return item.data_type in _INDICATOR_DATA_TYPES
|
||||||
|
|
||||||
|
|
||||||
def balance_identity(rows: Sequence[ClassifiedRow]) -> list[int]:
|
def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool:
|
||||||
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого»."""
|
"""Строка относится к счёту без сальдо по аналитике (код ближайшего счёта ветки; у «Итого» кода нет)."""
|
||||||
|
return exempt is not None and exempt.has_no_analytic_balance(item.account_code_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def balance_identity(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]:
|
||||||
|
"""Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого».
|
||||||
|
|
||||||
|
Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются.
|
||||||
|
"""
|
||||||
violations: list[int] = []
|
violations: list[int] = []
|
||||||
for item in rows:
|
for item in rows:
|
||||||
|
if _no_analytic_balance(item, exempt):
|
||||||
|
continue
|
||||||
sums = _values(item)
|
sums = _values(item)
|
||||||
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
|
expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
|
||||||
if abs(expected - _net(sums[_CLOSING])) > TOLERANCE:
|
if abs(expected - _net(sums[_CLOSING])) > TOLERANCE:
|
||||||
@ -83,24 +99,29 @@ def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal]) ->
|
|||||||
return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE
|
return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE
|
||||||
|
|
||||||
|
|
||||||
def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
|
def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal], *, with_balance: bool = True) -> bool:
|
||||||
|
"""Суммы родителя = Σ детей; `with_balance=False` — только обороты (сальдо детей не несёт данных)."""
|
||||||
return (
|
return (
|
||||||
_balance_matches(parent[_OPENING], children[_OPENING])
|
(not with_balance or _balance_matches(parent[_OPENING], children[_OPENING]))
|
||||||
and not _differs(parent[_TURNOVER], children[_TURNOVER])
|
and not _differs(parent[_TURNOVER], children[_TURNOVER])
|
||||||
and _balance_matches(parent[_CLOSING], children[_CLOSING])
|
and (not with_balance or _balance_matches(parent[_CLOSING], children[_CLOSING]))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
|
def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> list[int]:
|
||||||
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
"""Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
|
||||||
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
|
||||||
|
|
||||||
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
|
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
|
||||||
Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не
|
Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не
|
||||||
проверяется.
|
проверяется.
|
||||||
|
|
||||||
|
Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по
|
||||||
|
оборотам: сальдо его аналитики пусто по определению, а сальдо закрыто на субсчёт закрытия.
|
||||||
"""
|
"""
|
||||||
by_row = {item.body.row: item for item in rows}
|
by_row = {item.body.row: item for item in rows}
|
||||||
totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
|
totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
|
||||||
|
has_subaccounts: set[int] = set()
|
||||||
for item in rows:
|
for item in rows:
|
||||||
if item.parent is None or _is_indicator(item):
|
if item.parent is None or _is_indicator(item):
|
||||||
continue
|
continue
|
||||||
@ -110,7 +131,18 @@ def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
|
|||||||
accumulated = totals[item.parent]
|
accumulated = totals[item.parent]
|
||||||
for index, value in enumerate(_values(item)):
|
for index, value in enumerate(_values(item)):
|
||||||
accumulated[index] += value
|
accumulated[index] += value
|
||||||
return [row for row in by_row if row in totals and not _sums_match(_values(by_row[row]), totals[row])]
|
if item.group_type == "ACCOUNT":
|
||||||
|
has_subaccounts.add(item.parent)
|
||||||
|
return [
|
||||||
|
row
|
||||||
|
for row in by_row
|
||||||
|
if row in totals
|
||||||
|
and not _sums_match(
|
||||||
|
_values(by_row[row]),
|
||||||
|
totals[row],
|
||||||
|
with_balance=row in has_subaccounts or not _no_analytic_balance(by_row[row], exempt),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
|
||||||
@ -184,10 +216,13 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
|
|||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]:
|
def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[str]]:
|
||||||
"""Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось)."""
|
"""Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось).
|
||||||
identity = balance_identity(rows)
|
|
||||||
parents = parent_sums(rows)
|
`exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью.
|
||||||
|
"""
|
||||||
|
identity = balance_identity(rows, exempt)
|
||||||
|
parents = parent_sums(rows, exempt)
|
||||||
matches = total_matches_root(rows)
|
matches = total_matches_root(rows)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if identity:
|
if identity:
|
||||||
|
|||||||
@ -153,7 +153,7 @@ class OsvParser:
|
|||||||
raise _invalid_format(sheet, step, exc) from exc
|
raise _invalid_format(sheet, step, exc) from exc
|
||||||
doc = _with_table_account(doc, classified)
|
doc = _with_table_account(doc, classified)
|
||||||
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
|
||||||
checks, check_warnings = run_checks(classified)
|
checks, check_warnings = run_checks(classified, rules.checks)
|
||||||
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)
|
||||||
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
|
warnings = [sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
|
||||||
if not any(item.group_type != "TOTAL" for item in classified):
|
if not any(item.group_type != "TOTAL" for item in classified):
|
||||||
|
|||||||
@ -29,6 +29,7 @@ __all__ = [
|
|||||||
"DEFAULT_RULES_DIR",
|
"DEFAULT_RULES_DIR",
|
||||||
"DataTypeRules",
|
"DataTypeRules",
|
||||||
"MonthRange",
|
"MonthRange",
|
||||||
|
"OsvCheckRules",
|
||||||
"OsvGroupRules",
|
"OsvGroupRules",
|
||||||
"OsvHeaderRules",
|
"OsvHeaderRules",
|
||||||
"Pattern",
|
"Pattern",
|
||||||
@ -51,6 +52,7 @@ HEADERS_FILE = "osv_headers.yaml"
|
|||||||
GROUPS_FILE = "osv_groups.yaml"
|
GROUPS_FILE = "osv_groups.yaml"
|
||||||
PERIODS_FILE = "periods.yaml"
|
PERIODS_FILE = "periods.yaml"
|
||||||
DATA_TYPES_FILE = "data_types.yaml"
|
DATA_TYPES_FILE = "data_types.yaml"
|
||||||
|
CHECKS_FILE = "osv_checks.yaml"
|
||||||
STATEMENT_HEADERS_FILE = "statement_headers.yaml"
|
STATEMENT_HEADERS_FILE = "statement_headers.yaml"
|
||||||
STATEMENT_PERIODS_FILE = "statement_periods.yaml"
|
STATEMENT_PERIODS_FILE = "statement_periods.yaml"
|
||||||
STATEMENT_CHECKS_FILE = "statement_checks.yaml"
|
STATEMENT_CHECKS_FILE = "statement_checks.yaml"
|
||||||
@ -75,6 +77,8 @@ StatementLabel = Literal[
|
|||||||
]
|
]
|
||||||
|
|
||||||
_ISO_CODE_LENGTH = 3
|
_ISO_CODE_LENGTH = 3
|
||||||
|
# Код счёта в словаре проверок: цифры, при необходимости с субсчетами через точку
|
||||||
|
_ACCOUNT_CODE = re.compile(r"\d+(\.\d+)*")
|
||||||
# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
|
# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
|
||||||
# U+2010–U+2015, знак минус U+2212
|
# U+2010–U+2015, знак минус U+2212
|
||||||
_SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+")
|
_SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+")
|
||||||
@ -215,6 +219,24 @@ class OsvGroupRules(_Model):
|
|||||||
return self.default if found is None else found
|
return self.default if found is None else found
|
||||||
|
|
||||||
|
|
||||||
|
class OsvCheckRules(_Model):
|
||||||
|
accounts_without_analytic_balance: list[str]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_codes(self) -> "OsvCheckRules":
|
||||||
|
bad = [code for code in self.accounts_without_analytic_balance if not _ACCOUNT_CODE.fullmatch(code)]
|
||||||
|
if bad:
|
||||||
|
raise ValueError(f"accounts_without_analytic_balance: ожидаются коды счетов вида «91» или «76.09», {bad}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def has_no_analytic_balance(self, account_code: str | None) -> bool:
|
||||||
|
"""Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт."""
|
||||||
|
if not account_code:
|
||||||
|
return False
|
||||||
|
code = account_code.strip()
|
||||||
|
return any(code == root or code.startswith(f"{root}.") for root in self.accounts_without_analytic_balance)
|
||||||
|
|
||||||
|
|
||||||
class PeriodRule(_Model):
|
class PeriodRule(_Model):
|
||||||
pattern: Pattern
|
pattern: Pattern
|
||||||
quarter: Quarter
|
quarter: Quarter
|
||||||
@ -584,6 +606,7 @@ class StatementCheckRules(_Model):
|
|||||||
class Rules:
|
class Rules:
|
||||||
headers: OsvHeaderRules
|
headers: OsvHeaderRules
|
||||||
groups: OsvGroupRules
|
groups: OsvGroupRules
|
||||||
|
checks: OsvCheckRules
|
||||||
periods: PeriodRules
|
periods: PeriodRules
|
||||||
data_types: DataTypeRules
|
data_types: DataTypeRules
|
||||||
statement_headers: StatementHeaderRules
|
statement_headers: StatementHeaderRules
|
||||||
@ -592,10 +615,11 @@ class Rules:
|
|||||||
|
|
||||||
|
|
||||||
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
|
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
|
||||||
"""Читает семь словарей каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
|
"""Читает восемь словарей каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
|
||||||
return Rules(
|
return Rules(
|
||||||
headers=_load(path, HEADERS_FILE, OsvHeaderRules),
|
headers=_load(path, HEADERS_FILE, OsvHeaderRules),
|
||||||
groups=_load(path, GROUPS_FILE, OsvGroupRules),
|
groups=_load(path, GROUPS_FILE, OsvGroupRules),
|
||||||
|
checks=_load(path, CHECKS_FILE, OsvCheckRules),
|
||||||
periods=_load(path, PERIODS_FILE, PeriodRules),
|
periods=_load(path, PERIODS_FILE, PeriodRules),
|
||||||
data_types=_load(path, DATA_TYPES_FILE, DataTypeRules),
|
data_types=_load(path, DATA_TYPES_FILE, DataTypeRules),
|
||||||
statement_headers=_load(path, STATEMENT_HEADERS_FILE, StatementHeaderRules),
|
statement_headers=_load(path, STATEMENT_HEADERS_FILE, StatementHeaderRules),
|
||||||
|
|||||||
@ -4,12 +4,6 @@
|
|||||||
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
|
# Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора
|
||||||
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
|
# (иначе чинить парсер): запись удаляется, когда вопрос закрыт.
|
||||||
needs_check:
|
needs_check:
|
||||||
"tsimlyansky/2025-Q3/91_ 9мес.2025.xls":
|
|
||||||
reason: "OQ-132: аналитика счёта 91 несёт только обороты, сальдо закрыто на 91.09"
|
|
||||||
sheets: {"sheet:Лист_1": [balance_identity, parent_sum]}
|
|
||||||
"primanych/2025-Q3/Оборотно-сальдовая ведомость по счету 91 за 9 месяцев 2025 г. ООО Приманыческий.xls":
|
|
||||||
reason: "OQ-132: то же, счёт 91"
|
|
||||||
sheets: {"sheet:Лист_1": [balance_identity, parent_sum]}
|
|
||||||
"nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls":
|
"nkz/2025-Q3/НКЗ_ОСВ_58+66+67_Субсчета+Контрагенты_2025.09-01_24459.xls":
|
||||||
reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся"
|
reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся"
|
||||||
sheets: {"sheet:58": [parent_sum]}
|
sheets: {"sheet:58": [parent_sum]}
|
||||||
|
|||||||
@ -17,14 +17,16 @@ from src.parser.rules import Pattern, Rules
|
|||||||
DICTIONARIES = {
|
DICTIONARIES = {
|
||||||
"headers": "osv_headers.yaml",
|
"headers": "osv_headers.yaml",
|
||||||
"groups": "osv_groups.yaml",
|
"groups": "osv_groups.yaml",
|
||||||
|
"checks": "osv_checks.yaml",
|
||||||
"periods": "periods.yaml",
|
"periods": "periods.yaml",
|
||||||
"data_types": "data_types.yaml",
|
"data_types": "data_types.yaml",
|
||||||
"statement_headers": "statement_headers.yaml",
|
"statement_headers": "statement_headers.yaml",
|
||||||
"statement_periods": "statement_periods.yaml",
|
"statement_periods": "statement_periods.yaml",
|
||||||
"statement_checks": "statement_checks.yaml",
|
"statement_checks": "statement_checks.yaml",
|
||||||
}
|
}
|
||||||
# Словари без записей распознавания: соотношения §7 — выражения над кодами, а не `Pattern` (в покрытии не участвуют)
|
# Словари без записей распознавания: соотношения §7 — выражения над кодами, список счетов проверок ОСВ — коды счетов,
|
||||||
PATTERNLESS = frozenset({"statement_checks.yaml"})
|
# а не `Pattern` (в покрытии не участвуют)
|
||||||
|
PATTERNLESS = frozenset({"statement_checks.yaml", "osv_checks.yaml"})
|
||||||
|
|
||||||
|
|
||||||
def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]:
|
def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from src.contract.common import DataType
|
|||||||
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
|
from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
|
||||||
from src.parser.classify import ClassifiedRow, RowGroupType
|
from src.parser.classify import ClassifiedRow, RowGroupType
|
||||||
from src.parser.hierarchy import BodyRow
|
from src.parser.hierarchy import BodyRow
|
||||||
|
from src.parser.rules import OsvCheckRules
|
||||||
|
|
||||||
Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None]
|
Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None]
|
||||||
EMPTY: Sums = (None, None, None, None, None, None)
|
EMPTY: Sums = (None, None, None, None, None, None)
|
||||||
@ -24,6 +25,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
|
|||||||
group_type: RowGroupType = "COUNTERPARTY",
|
group_type: RowGroupType = "COUNTERPARTY",
|
||||||
data_type: DataType | None = "BU",
|
data_type: DataType | None = "BU",
|
||||||
text: str = "строка",
|
text: str = "строка",
|
||||||
|
code: str | None = "60",
|
||||||
) -> ClassifiedRow:
|
) -> ClassifiedRow:
|
||||||
body = BodyRow(
|
body = BodyRow(
|
||||||
row=number,
|
row=number,
|
||||||
@ -41,7 +43,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
|
|||||||
parent=parent,
|
parent=parent,
|
||||||
group_type=group_type,
|
group_type=group_type,
|
||||||
group_name=None,
|
group_name=None,
|
||||||
account_code_raw="60",
|
account_code_raw=code,
|
||||||
counterparty_name=None,
|
counterparty_name=None,
|
||||||
counterparty_inn=None,
|
counterparty_inn=None,
|
||||||
data_type=data_type,
|
data_type=data_type,
|
||||||
@ -365,3 +367,91 @@ def test_run_checks_warning_lists_are_capped() -> None:
|
|||||||
(identity,) = [warning for warning in warnings if "сальдо" in warning]
|
(identity,) = [warning for warning in warnings if "сальдо" in warning]
|
||||||
assert "всего 100" in identity
|
assert "всего 100" in identity
|
||||||
assert "100," not in identity
|
assert "100," not in identity
|
||||||
|
|
||||||
|
|
||||||
|
# --- Счета без сальдо по аналитике (OQ-132): 91, 99 ---
|
||||||
|
|
||||||
|
NO_SALDO = OsvCheckRules(accounts_without_analytic_balance=["91", "99"])
|
||||||
|
|
||||||
|
|
||||||
|
def account_91(number: int, sums: Sums, *, level: int, parent: int | None, code: str) -> ClassifiedRow:
|
||||||
|
return row(number, sums, level=level, parent=parent, group_type="ACCOUNT", text=code, code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def analytic_91(number: int, sums: Sums, *, parent: int, code: str = "91.01") -> ClassifiedRow:
|
||||||
|
return row(number, sums, level=2, parent=parent, group_type="OTHER", code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def tree_91() -> list[ClassifiedRow]:
|
||||||
|
"""91: аналитика только с оборотами, сальдо закрыто на 91.09 (как в ОСВ 91 пакета ДКБ)."""
|
||||||
|
return [
|
||||||
|
account_91(1, (None, None, "30", "30", None, None), level=0, parent=None, code="91"),
|
||||||
|
account_91(2, (None, None, None, "10", None, "10"), level=1, parent=1, code="91.01"),
|
||||||
|
analytic_91(3, (None, None, None, "4", None, None), parent=2),
|
||||||
|
analytic_91(4, (None, None, None, "6", None, None), parent=2),
|
||||||
|
account_91(5, (None, None, "25", None, "25", None), level=1, parent=1, code="91.02"),
|
||||||
|
analytic_91(6, (None, None, "25", None, None, None), parent=5, code="91.02"),
|
||||||
|
account_91(7, (None, None, "5", "20", None, "15"), level=1, parent=1, code="91.09"),
|
||||||
|
analytic_91(8, (None, None, "5", "20", None, None), parent=7, code="91.09"),
|
||||||
|
total(9, (None, None, "30", "30", None, None)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_exemption_account_91_violates_identity_and_parent_sums() -> None:
|
||||||
|
rows = tree_91()
|
||||||
|
|
||||||
|
assert balance_identity(rows) == [3, 4, 6, 8]
|
||||||
|
assert parent_sums(rows) == [2, 5, 7]
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_is_not_checked_on_accounts_without_analytic_balance() -> None:
|
||||||
|
assert balance_identity(tree_91(), NO_SALDO) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parent_sums_on_such_accounts_compare_turnovers_but_not_balance_of_analytics() -> None:
|
||||||
|
assert parent_sums(tree_91(), NO_SALDO) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parent_turnover_is_still_checked_on_such_accounts() -> None:
|
||||||
|
rows = tree_91()
|
||||||
|
rows[3] = analytic_91(4, (None, None, None, "7", None, None), parent=2) # Σ оборотов детей 11 ≠ 10
|
||||||
|
|
||||||
|
assert parent_sums(rows, NO_SALDO) == [2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_balance_of_subaccounts_under_root_is_still_checked_on_such_accounts() -> None:
|
||||||
|
rows = tree_91()
|
||||||
|
rows[6] = account_91(7, (None, None, "5", "20", None, "14"), level=1, parent=1, code="91.09") # сальдо ≠ нетто
|
||||||
|
|
||||||
|
assert parent_sums(rows, NO_SALDO) == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_row_is_still_checked_on_such_accounts() -> None:
|
||||||
|
rows = tree_91()
|
||||||
|
rows[-1] = total(9, (None, None, "31", "30", None, None))
|
||||||
|
|
||||||
|
assert total_matches_root(rows) is False
|
||||||
|
checks, warnings = run_checks(rows, NO_SALDO)
|
||||||
|
assert checks.total_row_matches_root is False
|
||||||
|
assert any("Итого" in warning for warning in warnings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exemption_is_matched_by_account_root_not_by_prefix_of_digits() -> None:
|
||||||
|
rows = [
|
||||||
|
account_91(1, ("5", None, "1", None, "9", None), level=0, parent=None, code="910"), # 5 + 1 ≠ 9
|
||||||
|
account_91(2, ("5", None, "1", None, "9", None), level=0, parent=None, code="91.5"), # субсчёт 91 — вне
|
||||||
|
account_91(3, ("5", None, "1", None, "9", None), level=0, parent=None, code="90.01"),
|
||||||
|
account_91(4, ("5", None, "1", None, "9", None), level=0, parent=None, code="99.01"), # 99 — вне
|
||||||
|
row(5, ("5", None, "1", None, "9", None), code=None), # кода нет — проверяется
|
||||||
|
]
|
||||||
|
|
||||||
|
assert balance_identity(rows, NO_SALDO) == [1, 3, 5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_checks_without_violations_on_account_91_gives_clean_status() -> None:
|
||||||
|
checks, warnings = run_checks(tree_91(), NO_SALDO)
|
||||||
|
|
||||||
|
assert checks.balance_identity_violations == []
|
||||||
|
assert checks.parent_sum_violations == []
|
||||||
|
assert checks.total_row_matches_root is True
|
||||||
|
assert warnings == []
|
||||||
|
|||||||
@ -611,3 +611,53 @@ def test_currency_code_recognises_bare_iso_code(rules: Rules, text: str) -> None
|
|||||||
@pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"])
|
@pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"])
|
||||||
def test_currency_code_unknown(rules: Rules, text: str) -> None:
|
def test_currency_code_unknown(rules: Rules, text: str) -> None:
|
||||||
assert rules.data_types.currency_code(text) is None
|
assert rules.data_types.currency_code(text) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Счета без сальдо по аналитике (osv_checks.yaml, OQ-132) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_accounts_without_analytic_balance_are_91_and_99(rules: Rules) -> None:
|
||||||
|
assert rules.checks.accounts_without_analytic_balance == ["91", "99"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("code", "expected"),
|
||||||
|
[
|
||||||
|
("91", True),
|
||||||
|
("91.01", True),
|
||||||
|
(" 91.09 ", True),
|
||||||
|
("99", True),
|
||||||
|
("99.01.1", True),
|
||||||
|
("90", False),
|
||||||
|
("90.01", False),
|
||||||
|
("910", False),
|
||||||
|
("9", False),
|
||||||
|
("", False),
|
||||||
|
(None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_account_without_analytic_balance_is_matched_by_root(rules: Rules, code: str | None, expected: bool) -> None:
|
||||||
|
assert rules.checks.has_no_analytic_balance(code) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_of_accounts_without_analytic_balance_is_a_data_edit(tmp_path: Path) -> None:
|
||||||
|
target = _copy_rules(tmp_path)
|
||||||
|
path = target / "osv_checks.yaml"
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
data["accounts_without_analytic_balance"].append("96")
|
||||||
|
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
|
assert load_rules(target).checks.has_no_analytic_balance("96.01") is True
|
||||||
|
assert load_rules().checks.has_no_analytic_balance("96.01") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["", "abc", "91.", "9 1"])
|
||||||
|
def test_account_code_in_osv_checks_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
|
||||||
|
target = _copy_rules(tmp_path)
|
||||||
|
path = target / "osv_checks.yaml"
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
data["accounts_without_analytic_balance"] = [bad]
|
||||||
|
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match=r"osv_checks\.yaml.*accounts_without_analytic_balance"):
|
||||||
|
load_rules(target)
|
||||||
|
|||||||
@ -169,6 +169,14 @@ R-13), «Итого» SHALL быть пустой или нулевой. Есл
|
|||||||
balance_identity_violations, parent_sum_violations}`. Любое нарушение → `status = needs_check` всего результата и запись
|
balance_identity_violations, parent_sum_violations}`. Любое нарушение → `status = needs_check` всего результата и запись
|
||||||
причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость.
|
причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость.
|
||||||
|
|
||||||
|
Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут
|
||||||
|
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
|
||||||
|
(`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если
|
||||||
|
код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом
|
||||||
|
(`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель»
|
||||||
|
сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у
|
||||||
|
родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно.
|
||||||
|
|
||||||
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
|
Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
|
||||||
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
|
- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
|
||||||
(перестановка сторон при совпавшем нетто) не проверяется.
|
(перестановка сторон при совпавшем нетто) не проверяется.
|
||||||
@ -200,6 +208,12 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
|
|||||||
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
|
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
|
||||||
или расхождение оборотов — нарушение
|
или расхождение оборотов — нарушение
|
||||||
|
|
||||||
|
#### Scenario: Счёт 91 — аналитика только с оборотами
|
||||||
|
- **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на
|
||||||
|
субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`)
|
||||||
|
- **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а
|
||||||
|
расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением
|
||||||
|
|
||||||
#### Scenario: «Итого» не найдена
|
#### Scenario: «Итого» не найдена
|
||||||
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
|
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
|
||||||
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена»
|
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена»
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user