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

View File

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

View File

@ -11,6 +11,7 @@ from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from src.contract import OsvDocument, ResultWarning from src.contract import OsvDocument, ResultWarning
from src.contract.common import DataType
from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser.checks import run_checks from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows 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:" LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn" INN_COLUMN_KEY = "inn"
NO_DATA_ROWS_WARNING = "нет строк данных" NO_DATA_ROWS_WARNING = "нет строк данных"
DEFAULT_DATA_TYPE: DataType = "BU"
DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"
def _columns(header: TableHeader) -> dict[str, str]: def _columns(header: TableHeader) -> dict[str, str]:
@ -194,13 +197,18 @@ class OsvParser:
rules=rules, rules=rules,
) )
step = "классификация строк" 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( classified = classify_rows(
glue.rows, glue.rows,
glue.levels, glue.levels,
schema.groups, schema.groups,
header, header,
sheet, sheet,
doc.data_type, data_type,
total_row, total_row,
rules, rules,
inns=glue.inns, inns=glue.inns,
@ -215,6 +223,7 @@ class OsvParser:
notes = [ notes = [
hierarchy_note(levels.strategy), hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings), *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_ROWS, body_warnings),
*notes_of(OSV_INN_ROWS, glue.warnings), *notes_of(OSV_INN_ROWS, glue.warnings),
] ]

View File

@ -26,6 +26,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
data_type: DataType | None = "BU", data_type: DataType | None = "BU",
text: str = "строка", text: str = "строка",
code: str | None = "60", code: str | None = "60",
indicator: bool = False,
) -> ClassifiedRow: ) -> ClassifiedRow:
body = BodyRow( body = BodyRow(
row=number, row=number,
@ -48,6 +49,7 @@ def row( # noqa: PLR0913 - строка теста описывается по
counterparty_inn=None, counterparty_inn=None,
data_type=data_type, data_type=data_type,
extra=None, extra=None,
indicator=indicator,
) )
@ -198,8 +200,24 @@ def test_currency_and_quantity_children_are_excluded() -> None:
rows = [ rows = [
account(7, ROOT), account(7, ROOT),
row(8, ROOT, level=1, parent=7), 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(
row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"), 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), 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: def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None:
rows = multi_root_tree() 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 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.parent_sum_violations == []
assert checks.total_row_matches_root is True assert checks.total_row_matches_root is True
assert warnings == [] 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[row].label_data_type for row in (4, 5, 6, 7, 8)] == ["BU", "BU", "NU", "BU", "NU"]
assert by_row[5].text == "ООО Поставщик" assert by_row[5].text == "ООО Поставщик"
assert not by_row[6].is_account 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",
}