From 49400682bc0a42369bb2128e2d2874618aebe207 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 00:18:36 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D1=81=D1=87=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D1=81=D0=B0=D0=BB=D1=8C=D0=B4=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B5=20(91,=2099)=20=D0=B2=D0=BD=D0=B5=20=D1=82=D0=BE=D0=B6?= =?UTF-8?q?=D0=B4=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20(OQ-132,=20#11=20=D0=BF.=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 4 ++ README.md | 13 +++- api/rules/README.md | 1 + api/rules/osv_checks.yaml | 13 ++++ api/src/parser/checks.py | 57 +++++++++++++---- api/src/parser/osv.py | 2 +- api/src/parser/rules.py | 26 +++++++- api/tests/osv_dkb_expectations.yaml | 6 -- api/tests/rules_coverage.py | 6 +- api/tests/unit/parser/test_checks.py | 92 +++++++++++++++++++++++++++- api/tests/unit/parser/test_rules.py | 50 +++++++++++++++ openspec/specs/osv-parsing/spec.md | 14 +++++ 12 files changed, 260 insertions(+), 24 deletions(-) create mode 100644 api/rules/osv_checks.yaml diff --git a/CLAUDE.md b/CLAUDE.md index bf896f8..198c1af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения. +**Счета без сальдо по аналитике (OQ-132, #11 п. 2):** список счетов (91, 99) — данные `api/rules/osv_checks.yaml`, не код; +тождество строки на них не проверяется, «сумма детей = родитель» (обороты всегда, сальдо — только корень → субсчета) и +«Итого» сохраняются. Счёт 90 в список не входит. Не расширять список «чтобы файл прошёл» — только по решению владельца. + **Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с; кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23). diff --git a/README.md b/README.md index 33eada4..f64f893 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо | `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` | | `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет | | `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`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики | | `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) | | `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) | @@ -176,11 +176,20 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` и `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_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). Новый формат подключается строкой в YAML, без правки `src/`. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на diff --git a/api/rules/README.md b/api/rules/README.md index 9f8d562..2773014 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -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_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` — конец текста периода в строке шапки («г.», «год») | | `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) | diff --git a/api/rules/osv_checks.yaml b/api/rules/osv_checks.yaml new file mode 100644 index 0000000..61d634c --- /dev/null +++ b/api/rules/osv_checks.yaml @@ -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' diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 2c31a20..057e69c 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -15,6 +15,11 @@ (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы. +Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только +обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2 +обороты сверяются как обычно, а сальдо — только у родителя, у которого есть дочерние субсчета (у родителя с одной лишь +аналитикой сальдо детей пусто по определению); п. 3 («Итого») не меняется. + Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений. """ @@ -25,6 +30,7 @@ from decimal import Decimal from src.contract.result import OsvChecks from src.parser.classify import SUM_FIELDS, ClassifiedRow +from src.parser.rules import OsvCheckRules __all__ = [ "TOLERANCE", @@ -64,10 +70,20 @@ def _is_indicator(item: ClassifiedRow) -> bool: return item.data_type in _INDICATOR_DATA_TYPES -def balance_identity(rows: Sequence[ClassifiedRow]) -> list[int]: - """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого».""" +def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool: + """Строка относится к счёту без сальдо по аналитике (код ближайшего счёта ветки; у «Итого» кода нет).""" + 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] = [] for item in rows: + if _no_analytic_balance(item, exempt): + continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) 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 -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 ( - _balance_matches(parent[_OPENING], children[_OPENING]) + (not with_balance or _balance_matches(parent[_OPENING], children[_OPENING])) 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): обороты — по колонкам, сальдо — по колонкам или по нетто при свёрнутом сальдо родителя (см. описание модуля). Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются. Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не проверяется. + + Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по + оборотам: сальдо его аналитики пусто по определению, а сальдо закрыто на субсчёт закрытия. """ by_row = {item.body.row: item for item in rows} totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS)) + has_subaccounts: set[int] = set() for item in rows: if item.parent is None or _is_indicator(item): continue @@ -110,7 +131,18 @@ def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]: accumulated = totals[item.parent] for index, value in enumerate(_values(item)): 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: @@ -184,10 +216,13 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str: return message -def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]: - """Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось).""" - identity = balance_identity(rows) - parents = parent_sums(rows) +def run_checks(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None) -> tuple[OsvChecks, list[str]]: + """Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось). + + `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. + """ + identity = balance_identity(rows, exempt) + parents = parent_sums(rows, exempt) matches = total_matches_root(rows) warnings: list[str] = [] if identity: diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index b5d8191..8b0bb46 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -153,7 +153,7 @@ class OsvParser: raise _invalid_format(sheet, step, exc) from exc doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") - checks, check_warnings = run_checks(classified) + checks, check_warnings = run_checks(classified, rules.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] if not any(item.group_type != "TOTAL" for item in classified): diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 8861b24..e54b43f 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -29,6 +29,7 @@ __all__ = [ "DEFAULT_RULES_DIR", "DataTypeRules", "MonthRange", + "OsvCheckRules", "OsvGroupRules", "OsvHeaderRules", "Pattern", @@ -51,6 +52,7 @@ HEADERS_FILE = "osv_headers.yaml" GROUPS_FILE = "osv_groups.yaml" PERIODS_FILE = "periods.yaml" DATA_TYPES_FILE = "data_types.yaml" +CHECKS_FILE = "osv_checks.yaml" STATEMENT_HEADERS_FILE = "statement_headers.yaml" STATEMENT_PERIODS_FILE = "statement_periods.yaml" STATEMENT_CHECKS_FILE = "statement_checks.yaml" @@ -75,6 +77,8 @@ StatementLabel = Literal[ ] _ISO_CODE_LENGTH = 3 +# Код счёта в словаре проверок: цифры, при необходимости с субсчетами через точку +_ACCOUNT_CODE = re.compile(r"\d+(\.\d+)*") # Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире # U+2010–U+2015, знак минус U+2212 _SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+") @@ -215,6 +219,24 @@ class OsvGroupRules(_Model): 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): pattern: Pattern quarter: Quarter @@ -584,6 +606,7 @@ class StatementCheckRules(_Model): class Rules: headers: OsvHeaderRules groups: OsvGroupRules + checks: OsvCheckRules periods: PeriodRules data_types: DataTypeRules statement_headers: StatementHeaderRules @@ -592,10 +615,11 @@ class Rules: def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules: - """Читает семь словарей каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом.""" + """Читает восемь словарей каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом.""" return Rules( headers=_load(path, HEADERS_FILE, OsvHeaderRules), groups=_load(path, GROUPS_FILE, OsvGroupRules), + checks=_load(path, CHECKS_FILE, OsvCheckRules), periods=_load(path, PERIODS_FILE, PeriodRules), data_types=_load(path, DATA_TYPES_FILE, DataTypeRules), statement_headers=_load(path, STATEMENT_HEADERS_FILE, StatementHeaderRules), diff --git a/api/tests/osv_dkb_expectations.yaml b/api/tests/osv_dkb_expectations.yaml index 7cde46d..041cd29 100644 --- a/api/tests/osv_dkb_expectations.yaml +++ b/api/tests/osv_dkb_expectations.yaml @@ -4,12 +4,6 @@ # Файлов и листов, которых здесь нет, тест ждёт `done` без нарушений. Причина — данные файла, а не дефект разбора # (иначе чинить парсер): запись удаляется, когда вопрос закрыт. 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": reason: "лист 58: сумма детей ≠ родитель (строки 12, 24), см. arm-tabular-parser#11 A11-5; листы 66, 67 сходятся" sheets: {"sheet:58": [parent_sum]} diff --git a/api/tests/rules_coverage.py b/api/tests/rules_coverage.py index c81e919..192fe3b 100644 --- a/api/tests/rules_coverage.py +++ b/api/tests/rules_coverage.py @@ -17,14 +17,16 @@ from src.parser.rules import Pattern, Rules DICTIONARIES = { "headers": "osv_headers.yaml", "groups": "osv_groups.yaml", + "checks": "osv_checks.yaml", "periods": "periods.yaml", "data_types": "data_types.yaml", "statement_headers": "statement_headers.yaml", "statement_periods": "statement_periods.yaml", "statement_checks": "statement_checks.yaml", } -# Словари без записей распознавания: соотношения §7 — выражения над кодами, а не `Pattern` (в покрытии не участвуют) -PATTERNLESS = frozenset({"statement_checks.yaml"}) +# Словари без записей распознавания: соотношения §7 — выражения над кодами, список счетов проверок ОСВ — коды счетов, +# а не `Pattern` (в покрытии не участвуют) +PATTERNLESS = frozenset({"statement_checks.yaml", "osv_checks.yaml"}) def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]: diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 3f994e5..b088714 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -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.classify import ClassifiedRow, RowGroupType 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] EMPTY: Sums = (None, None, None, None, None, None) @@ -24,6 +25,7 @@ def row( # noqa: PLR0913 - строка теста описывается по group_type: RowGroupType = "COUNTERPARTY", data_type: DataType | None = "BU", text: str = "строка", + code: str | None = "60", ) -> ClassifiedRow: body = BodyRow( row=number, @@ -41,7 +43,7 @@ def row( # noqa: PLR0913 - строка теста описывается по parent=parent, group_type=group_type, group_name=None, - account_code_raw="60", + account_code_raw=code, counterparty_name=None, counterparty_inn=None, 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] assert "всего 100" 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 == [] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index 458903f..d7dfd16 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -611,3 +611,53 @@ def test_currency_code_recognises_bare_iso_code(rules: Rules, text: str) -> None @pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"]) def test_currency_code_unknown(rules: Rules, text: str) -> 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) diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 32b3a4c..162dafb 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -169,6 +169,14 @@ R-13), «Итого» SHALL быть пустой или нулевой. Есл balance_identity_violations, parent_sum_violations}`. Любое нарушение → `status = needs_check` всего результата и запись причины в `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): - RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом (перестановка сторон при совпавшем нетто) не проверяется. @@ -200,6 +208,12 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен - **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: «Итого» не найдена - **WHEN** в таблице нет строки с текстом из словаря «Итого» - **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена» -- 2.47.2