Счета без сальдо по аналитике (91, 99) вне тождества строк — OQ-132 (#11 п. 2) #26

Merged
aep merged 1 commits from feat/11-osv-91-no-saldo into main 2026-10-07 23:24:56 +03:00
12 changed files with 260 additions and 24 deletions

View File

@ -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).

View File

@ -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/`. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на

View File

@ -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) |

13
api/rules/osv_checks.yaml Normal file
View 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'

View File

@ -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:

View File

@ -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):

View File

@ -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),

View File

@ -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]}

View File

@ -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]]:

View File

@ -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 == []

View File

@ -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)

View File

@ -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` — «строка «Итого» не найдена»