feat(osv): стратегия header_account — плоский список под счётом шапки (#59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 12:17:15 +04:00
parent 774141588e
commit 71c4c93db2
10 changed files with 201 additions and 10 deletions

View File

@ -184,7 +184,9 @@ def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]:
def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence[Pattern] = ()) -> bool:
"""«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01).
Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма
Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский
список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или
нулевые (R-13). Сумма
уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме.
"""
total = _total(rows, secondary_totals)
@ -192,6 +194,9 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence
return False
root = _root(rows)
if root is None:
roots = _level_zero(rows)
if roots:
return _sums_match(_values(total), _sum_of(roots))
return all(abs(value) <= TOLERANCE for value in _values(total))
if not _differs(_values(total), _values(root)):
return True

View File

@ -445,6 +445,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
*,
inns: Mapping[int, str] | None = None,
unpaired: AbstractSet[int] = frozenset(),
header_account: str | None = None,
) -> list[ClassifiedRow]:
"""Классификация строк по дереву `levels` и схеме группировок.
@ -458,12 +459,15 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
- вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`);
- прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН);
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`.
- строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`;
- корневая ветка наследует счёт шапки `header_account` (R-4, #59): строки до первой строки-счёта и плоский список
получают его код.
"""
context = _Context(
schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired
)
extra_keys = _extra_keys(sheet, header, rows)
root_branch = _Branch(header_account, 0)
branches: dict[int, _Branch] = {}
result: list[ClassifiedRow] = []
for item in rows:
@ -475,7 +479,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta
level, parent = 0, None
else:
parent = levels.parent[item.row]
inherited = _ROOT_BRANCH if parent is None else branches.get(parent, _ROOT_BRANCH)
inherited = root_branch if parent is None else branches.get(parent, root_branch)
kind, branch = _classify_row(item, inherited, context)
level = levels.by_row[item.row]
branches[item.row] = branch

View File

@ -186,6 +186,17 @@ def _matches(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> bool:
return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True))
def strategy_header_account(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
"""Плоский список под счётом шапки (#59): все строки — уровень 0.
Применима, только если ни одна строка тела не является счётом (`is_account`): счёт назван в шапке, а строки таблицы
— его аналитика без оформления. Условие «в шапке есть код счёта» проверяет `resolve_levels`.
"""
if any(item.is_account for item in _paced(rows, deadline, "иерархия (header_account)")):
return None
return {item.row: ROOT_LEVEL for item in rows}
def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
"""Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами.
@ -269,9 +280,14 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int
return result
def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
def resolve_levels(
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
) -> Levels:
"""Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
`header_account` — в шапке есть код счёта: тогда после `account_pattern` и перед `sums` пробуется `header_account`
(плоский список под счётом шапки, #59).
«Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это
контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное
дерево корней `TRIVIAL_STRATEGY`.
@ -283,7 +299,11 @@ def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Dea
if len(body) < MIN_ROWS_FOR_CASCADE:
by_row = {item.row: ROOT_LEVEL for item in rows}
return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)
for name, strategy in STRATEGIES:
cascade = list(STRATEGIES)
if header_account:
before_sums = next((index for index, (name, _) in enumerate(cascade) if name == "sums"), len(cascade))
cascade.insert(before_sums, ("header_account", strategy_header_account))
for name, strategy in cascade:
deadline.check(f"иерархия ({name})")
found = strategy(body, deadline)
if found is None:

View File

@ -60,13 +60,17 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules
return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU"
def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
def levels_with_indicators(
rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline, *, header_account: bool = False
) -> Levels:
"""Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже
(§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ.
Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя.
"""
levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline)
levels = resolve_levels(
[item for item in rows if not item.indicator], total_row, deadline, header_account=header_account
)
by_row: dict[int, int] = {}
parent: dict[int, int | None] = {}
previous: int | None = None

View File

@ -184,7 +184,7 @@ class OsvParser:
step = "строки таблицы"
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
step = "иерархия строк"
levels = levels_with_indicators(body, total_row, deadline)
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
step = "строки ИНН"
glue = glue_inn_rows(
body,
@ -213,6 +213,7 @@ class OsvParser:
rules,
inns=glue.inns,
unpaired=glue.unpaired,
header_account=doc.account_code_raw or None,
)
except InvalidFormatError as exc:
raise _invalid_format(sheet, step, exc) from exc

View File

@ -44,6 +44,7 @@ HIERARCHY_TEXTS = {
"outline": "по группировке листа (outline)",
"indent": "по отступам (indent)",
"account_pattern": "по кодам счетов (account_pattern)",
"header_account": "плоский список под счётом шапки (header_account)",
"sums": "по суммам (sums)",
"trivial": "плоский список (trivial)",
}

View File

@ -902,7 +902,7 @@ def _s40() -> list[SheetSpec]:
("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0),
("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0),
("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0),
("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0),
("Итого развернутое", ("1000.00", "200.00", "650.00", "750.00", "1200.00", "500.00"), 0),
("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0),
]
return [_flat_sheet(texts, 9, rows)]
@ -918,7 +918,7 @@ def _s41() -> list[SheetSpec]:
rows: list[FlatRow] = [
("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0),
("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0),
("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0),
("Итого развернутое", ("820.21", "2763.79", "5000.00", "4991.00", "829.21", "2763.79"), 0),
("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0),
]
return [_flat_sheet(texts, 5, rows)]

View File

@ -355,6 +355,29 @@ def test_second_total_row_does_not_replace_the_first_one() -> None:
assert total_matches_root(rows) is True
def test_single_total_matching_secondary_is_used_as_the_total() -> None:
"""T3.1: единственная TOTAL, чей текст матчит вторые итоговые, — всё равно итоговая строка."""
secondary = [Pattern.compile(r"^итого\s+развернут")]
only = row(8, ROOT, group_type="TOTAL", text="Итого развернутое")
assert total_matches_root([account(7, ROOT), only], secondary) is True
def test_total_without_account_root_matches_level_zero_sum() -> None:
flat = [
row(7, ("10", None, "5", "3", "12", None), group_type="OTHER"),
row(8, (None, "4", "1", "2", None, "3"), group_type="COUNTERPARTY"),
row(9, ("6", None, "2", "1", "7", None), group_type="COUNTERPARTY"),
]
# сумма корней: дебет/кредит сальдо 16/4 -> 12 нетто, на конец 19/3 -> 16 нетто; обороты 8/6 по колонкам
good = total(10, ("12", None, "8", "6", "16", None))
bad = total(10, ("12", None, "9", "6", "16", None))
assert total_matches_root([*flat, good]) is True
assert total_matches_root([*flat, bad]) is False
assert total_matches_root([total(10, EMPTY)]) is True
def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None:
assert total_matches_root([total(8, EMPTY)]) is True
assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True

View File

@ -23,6 +23,7 @@ from src.parser.hierarchy import (
parents,
resolve_levels,
strategy_account_pattern,
strategy_header_account,
strategy_indent,
strategy_outline,
strategy_sums,
@ -456,3 +457,32 @@ def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name:
with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"):
strategy(rows, deadline)
assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK
# --- Плоский список под счётом шапки (#59, стратегия header_account) ---
def test_header_account_strategy_flat_rows() -> None:
rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "header_account"
assert levels_list(rows, levels.by_row) == [0, 0, 0]
assert set(levels.parent.values()) == {None}
def test_header_account_not_used_without_header_code() -> None:
rows = numbered(("Август - Агро ООО", {}), ("АВИС ООО", {}), ("Агроаспект ООО", {}))
with pytest.raises(InvalidFormatError, match="иерархия строк не определена"):
resolve_levels(rows, None, never_expires(), header_account=False)
def test_header_account_not_used_with_account_rows() -> None:
rows = numbered(("60.01", {}), ("ООО А", {}))
levels = resolve_levels(rows, None, never_expires(), header_account=True)
assert levels.strategy == "account_pattern"
assert strategy_header_account(rows) is None

View File

@ -0,0 +1,103 @@
"""Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк."""
import pytest
from gridbuilder import L, M, sheet_from_rows
from src.contract.result import OsvDocument
from src.parser.deadline import Deadline
from src.parser.osv import OsvParser
from src.parser.rules import Rules, load_rules
TIMEOUT_SECONDS = 60
HEADER_ACCOUNT_CODE = "OSV_HIERARCHY_BY_HEADER_ACCOUNT"
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _table_head() -> list[list[object]]:
return [
["Субконто", M("Сальдо на начало периода", 2), M("Обороты за период", 2), M("Сальдо на конец периода", 2)],
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
def _parse(rules: Rules, rows: list[list[object]]) -> tuple[OsvDocument, list[str]]:
result = OsvParser(rules).parse_sheet(sheet_from_rows(rows), Deadline(TIMEOUT_SECONDS))
return result.document, [note.code for note in result.notes]
def test_flat_list_s40(rules: Rules) -> None:
"""Образец s40: счёт «по счету: 76.Н.1», вид «Контрагенты», организация ниже периода, данные без оформления."""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 76.Н.1"],
["Контрагенты"],
["за 2025 г."],
['ООО "Товарное хозяйство"'],
[],
["Единица измерения: рубль (код по ОКЕИ 383)"],
[],
*_table_head(),
["Август - Агро ООО", 1000, None, 500, 300, 1200, None],
["АВИС ООО", None, 200, 100, 400, None, 500],
["Агроаспект ООО", None, None, 50, 50, None, None],
["Итого развернутое", 1000, 200, 650, 750, 1200, 500],
["Итого", 800, None, 650, 750, 700, None],
]
document, codes = _parse(rules, rows)
assert document.status == "done"
assert HEADER_ACCOUNT_CODE in codes
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.row_value for item in data] == ["Август - Агро ООО", "АВИС ООО", "Агроаспект ООО"]
assert all(item.level == 0 and item.parent_row_number is None for item in data)
assert {item.account_code_raw for item in data} == {"76.Н.1"}
assert document.header.organization_name == 'ООО "Товарное хозяйство"'
def test_flat_list_s41(rules: Rules) -> None:
"""Образец s41 (R-10): схема [ACCOUNT, COUNTERPARTY («Организации»)], счёт шапки «60»."""
rows: list[list[object]] = [
[],
["Оборотно-сальдовая ведомость по счету: 60"],
["Организации"],
["за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"],
[],
*_table_head(),
["Автодор-Платные Дороги ООО", 820, None, 5000, 4991, 829, None],
["АГРОПРОФИ ООО", None, 2764, None, None, None, 2764],
["Итого развернутое", 820, 2764, 5000, 4991, 829, 2764],
["Итого", None, 1944, 5000, 4991, None, 1935],
]
document, _ = _parse(rules, rows)
assert document.status == "done"
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.account_code_raw for item in data] == ["60", "60"]
assert [item.group_type for item in data] == ["COUNTERPARTY", "COUNTERPARTY"]
def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None:
"""Review Focus 4: счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки."""
rows: list[list[object]] = [
["ООО Зерновая"],
["Оборотно-сальдовая ведомость по счету 60"],
["Период: 9 месяцев 2025 г."],
[],
*_table_head(),
[L(0), "ООО А", 100, None, 50, 30, 120, None],
[L(1), "Договор 1", 100, None, 50, 30, 120, None],
[L(0), "Итого", 100, None, 50, 30, 120, None],
]
document, codes = _parse(rules, rows)
assert "OSV_HIERARCHY_BY_OUTLINE" in codes
assert HEADER_ACCOUNT_CODE not in codes
data = [item for item in document.rows if item.group_type != "TOTAL"]
assert [item.account_code_raw for item in data] == ["60", "60"]