Классификация и проверки по признаку строки-показателя, BU по умолчанию (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
33b32cbad6
commit
8924dd13a6
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user