Классификация и проверки по признаку строки-показателя, BU по умолчанию (#58)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 12:09:05 +04:00
parent 33b32cbad6
commit 8924dd13a6
5 changed files with 139 additions and 29 deletions

View File

@ -4,16 +4,17 @@
1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit =
closing_debit − closing_credit`;
2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки CURRENCY/QUANTITY («Вал.», «Кол.»)
в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) —
по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
`дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки-показатели (`indicator`: «Вал.», «Кол.»,
НУ и прочие метки колонки «Показатели») в суммирование не входят и сами родителями не проверяются.
Обороты сверяются по колонкам. Сальдо (начало, конец) — по колонкам (развёрнутое сальдо) либо, если у родителя
сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым
сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с
разными сторонами сальдо);
3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их
несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо —
по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет
(пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна,
результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы.
результат `false`. Строки-показатели (валюта, количество, НУ) в сумму уровня 0 не входят: это другие единицы.
Счета без сальдо по аналитике (91, 99; список — `rules/osv_checks.yaml`, OQ-132): у них строки аналитики несут только
обороты, сальдо закрыто на субсчёт закрытия (91.09). Для строк такого счёта тождество п. 1 не проверяется; в п. 2
@ -45,8 +46,6 @@ TOLERANCE = Decimal("0.01")
# Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks`
WARNING_ROWS_SHOWN = 20
_ZERO = Decimal(0)
# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят
_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"})
_ROOT_LEVEL = 0
# Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом
_OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6)
@ -68,7 +67,7 @@ def _differs(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
def _is_indicator(item: ClassifiedRow) -> bool:
return item.data_type in _INDICATOR_DATA_TYPES
return item.indicator
def _no_analytic_balance(item: ClassifiedRow, exempt: OsvCheckRules | None) -> bool:
@ -114,7 +113,7 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
или по нетто при свёрнутом сальдо родителя (см. описание модуля).
Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не
Строки-показатели не суммируются и не проверяются как родители; родитель без учитываемых детей не
проверяется.
Родитель счёта без сальдо по аналитике (`exempt`, OQ-132) без дочерних субсчетов (ACCOUNT) сверяется только по
@ -147,8 +146,8 @@ def parent_sums(rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = No
def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
"""Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит)."""
return next((item for item in rows if item.group_type == "TOTAL"), None)
"""Основная «Итого» листа — первая строка TOTAL без признака показателя (следующие за ней в сверку не входят)."""
return next((item for item in rows if item.group_type == "TOTAL" and not item.indicator), None)
def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:

View File

@ -83,6 +83,7 @@ class ClassifiedRow:
counterparty_inn: str | None
data_type: DataType | None
extra: dict[str, str | None] | None
indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке
# --- Строки таблицы ---
@ -368,6 +369,7 @@ class _Kind:
data_type: DataType | None
counterparty_name: str | None = None
counterparty_inn: str | None = None
indicator: bool = False
@dataclass(frozen=True)
@ -391,31 +393,31 @@ def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType,
def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]:
"""Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей."""
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть
doc_data_type = context.doc_data_type
code = inherited.account_code_raw
if item.indicator:
# Строка-показатель (§1.3): не уровень группировки, а вид сумм родителя; позицию в схеме не занимает
return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited
# ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки
own = item.label_data_type or context.doc_data_type
account = ACCOUNT_RE.match(item.text) if item.is_account else None
if account is not None:
code = account.group(1)
_, name = _schema_group(context.schema, _ACCOUNT_POSITION)
return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
code = inherited.account_code_raw
data_type = row_data_type(item.text, context.rules, context.currency_codes)
if data_type is not None:
return _Kind("OTHER", None, code, data_type), inherited
return _Kind("ACCOUNT", name, code, own), _Branch(code, 0)
if item.row in context.unpaired:
# Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте
return _Kind("OTHER", None, code, doc_data_type), inherited
return _Kind("OTHER", None, code, own), inherited
position = inherited.depth + 1
group_type, name = _schema_group(context.schema, position)
branch = _Branch(code, position)
if group_type != "COUNTERPARTY":
return _Kind(group_type, name, code, doc_data_type), branch
return _Kind(group_type, name, code, own), branch
inn_col = context.header.inn_col
column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules)
# ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100)
counterparty, in_name = split_inn_suffix(item.text, context.rules)
inn = column or context.inns.get(item.row) or in_name
return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch
return _Kind(group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn), branch
def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
@ -436,8 +438,11 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
- «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя;
в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро);
- счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
- строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
группировки, а вид сумм родителя; позицию в схеме она не занимает;
- строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) —
OTHER, `indicator=True`, `data_type` метки (НУ/CURRENCY/QUANTITY/`None`), `account_code_raw` ветки родителя,
без контрагента: это не уровень группировки, а вид сумм родителя; позицию в схеме не занимает; итоговая
строка-показатель — TOTAL с тем же признаком;
- вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`);
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`.
@ -452,7 +457,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
if total_row is not None and item.row >= total_row:
# Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал)
# ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
kind = _Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator)
branch = _ROOT_BRANCH
level, parent = 0, None
else:
parent = levels.parent[item.row]
@ -472,6 +478,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
counterparty_inn=kind.counterparty_inn,
data_type=kind.data_type,
extra=_extra(sheet, item.row, extra_keys),
indicator=kind.indicator,
)
)
return result

View File

@ -11,6 +11,7 @@ from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace
from src.contract import OsvDocument, ResultWarning
from src.contract.common import DataType
from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
@ -37,6 +38,8 @@ __all__ = ["OsvParser", "SheetResult", "data_rows"]
LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn"
NO_DATA_ROWS_WARNING = "нет строк данных"
DEFAULT_DATA_TYPE: DataType = "BU"
DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"
def _columns(header: TableHeader) -> dict[str, str]:
@ -194,13 +197,18 @@ class OsvParser:
rules=rules,
)
step = "классификация строк"
data_type = doc.data_type or DEFAULT_DATA_TYPE
# Вид данных шапки не указан: строки без метки приняты как БУ — предупреждение, если умолчание сработало
defaulted = doc.data_type is None and any(
not item.indicator and item.label_data_type is None for item in body
)
classified = classify_rows(
glue.rows,
glue.levels,
schema.groups,
header,
sheet,
doc.data_type,
data_type,
total_row,
rules,
inns=glue.inns,
@ -215,6 +223,7 @@ class OsvParser:
notes = [
hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings),
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
*notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_INN_ROWS, glue.warnings),
]

View File

@ -26,6 +26,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
data_type: DataType | None = "BU",
text: str = "строка",
code: str | None = "60",
indicator: bool = False,
) -> ClassifiedRow:
body = BodyRow(
row=number,
@ -48,6 +49,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
counterparty_inn=None,
data_type=data_type,
extra=None,
indicator=indicator,
)
@ -198,8 +200,24 @@ def test_currency_and_quantity_children_are_excluded() -> None:
rows = [
account(7, ROOT),
row(8, ROOT, level=1, parent=7),
row(9, ("1", None, "2", None, "3", None), level=2, parent=8, group_type="OTHER", data_type="CURRENCY"),
row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"),
row(
9,
("1", None, "2", None, "3", None),
level=2,
parent=8,
group_type="OTHER",
data_type="CURRENCY",
indicator=True,
),
row(
10,
("7", None, None, None, "7", None),
level=2,
parent=8,
group_type="OTHER",
data_type="QUANTITY",
indicator=True,
),
total(11, ROOT),
]
@ -299,7 +317,9 @@ def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> N
def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None:
rows = multi_root_tree()
rows.insert(2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY"))
rows.insert(
2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY", indicator=True)
)
assert total_matches_root(rows) is True
@ -464,3 +484,41 @@ def test_run_checks_without_violations_on_account_91_gives_clean_status() -> Non
assert checks.parent_sum_violations == []
assert checks.total_row_matches_root is True
assert warnings == []
# --- Признак строки-показателя (#58) ---
def test_indicator_nu_child_is_excluded_from_parent_sum() -> None:
"""Строка-показатель НУ под родителем БУ в сумму детей не входит, хотя её data_type не CURRENCY/QUANTITY."""
rows = [
account(7, ("10", None, None, None, "10", None)),
row(8, ("10", None, None, None, "10", None), level=1, parent=7),
row(
9, ("5", None, None, None, "5", None), level=1, parent=7, group_type="OTHER", data_type="NU", indicator=True
),
]
assert parent_sums(rows) == []
def test_document_of_nu_rows_without_indicator_is_checked_as_usual() -> None:
"""«Выводимые данные: НУ» (s12): все строки НУ без признака — проверки идут как обычно."""
rows = [
row(7, ("10", None, None, None, "10", None), group_type="ACCOUNT", data_type="NU"),
row(8, ("4", None, None, None, "4", None), level=1, parent=7, data_type="NU"),
row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"),
]
assert parent_sums(rows) == [7]
def test_total_skips_leading_indicator_total() -> None:
"""Первая итоговая строка без признака — основная «Итого», даже если перед ней стоит итог-показатель."""
rows = [
*tree(),
row(12, ("1", None, None, None, "1", None), group_type="TOTAL", data_type="NU", indicator=True),
]
rows.insert(0, row(2, ("9", None, None, None, "9", None), group_type="TOTAL", data_type="NU", indicator=True))
assert total_matches_root(rows) is True

View File

@ -1074,3 +1074,40 @@ def test_indicator_column_rows_are_labelled_indicators(rules: Rules) -> None:
assert [by_row[row].label_data_type for row in (4, 5, 6, 7, 8)] == ["BU", "BU", "NU", "BU", "NU"]
assert by_row[5].text == "ООО Поставщик"
assert not by_row[6].is_account
def test_indicator_rows_and_totals_are_classified_by_flag(rules: Rules) -> None:
parsed = parse(_indicator_sheet(), rules, None)
by_row = {item.body.row: item for item in parsed.rows}
nu = by_row[6]
assert (nu.group_type, nu.data_type, nu.indicator) == ("OTHER", "NU", True)
assert (nu.account_code_raw, nu.counterparty_name, nu.counterparty_inn) == ("60", None, None)
nu_total = by_row[8]
assert (nu_total.group_type, nu_total.data_type, nu_total.indicator) == ("TOTAL", "NU", True)
assert by_row[7].indicator is False
assert by_row[7].data_type == "BU"
def test_label_data_type_wins_over_document_data_type(rules: Rules) -> None:
parsed = parse(_indicator_sheet(), rules, "NU")
assert {item.body.row: item.data_type for item in parsed.rows if not item.indicator} == {
4: "BU",
5: "BU",
7: "BU",
}
def test_row_without_label_takes_document_data_type(rules: Rules) -> None:
"""Строка без метки получает вид данных шапки; умолчание БУ подставляет `osv.py` до вызова классификатора."""
sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100))
assert {item.body.text: item.data_type for item in parse(sheet, rules, "BU").rows} == {
"60": "BU",
"ООО Поставщик": "BU",
}
assert {item.body.text: item.data_type for item in parse(sheet, rules, "NU").rows} == {
"60": "NU",
"ООО Поставщик": "NU",
}