feat(osv): стратегия header_account — плоский список под счётом шапки (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
774141588e
commit
71c4c93db2
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -44,6 +44,7 @@ HIERARCHY_TEXTS = {
|
||||
"outline": "по группировке листа (outline)",
|
||||
"indent": "по отступам (indent)",
|
||||
"account_pattern": "по кодам счетов (account_pattern)",
|
||||
"header_account": "плоский список под счётом шапки (header_account)",
|
||||
"sums": "по суммам (sums)",
|
||||
"trivial": "плоский список (trivial)",
|
||||
}
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
103
api/tests/unit/parser/test_osv_flat_form.py
Normal file
103
api/tests/unit/parser/test_osv_flat_form.py
Normal 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"]
|
||||
Loading…
x
Reference in New Issue
Block a user