From 71c4c93db2d5c1ce7bc6ed979081e73b044bcd8e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:17:15 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D1=81=D1=82=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=B8=D1=8F=20header=5Faccount=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=BE=D1=81=D0=BA=D0=B8=D0=B9=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=BA=20=D0=BF=D0=BE=D0=B4=20=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/checks.py | 7 +- api/src/parser/classify.py | 8 +- api/src/parser/hierarchy.py | 24 ++++- api/src/parser/indicators.py | 8 +- api/src/parser/osv.py | 3 +- api/src/parser/osv_warnings.py | 1 + api/tests/synthetic/generate.py | 4 +- api/tests/unit/parser/test_checks.py | 23 +++++ api/tests/unit/parser/test_hierarchy.py | 30 ++++++ api/tests/unit/parser/test_osv_flat_form.py | 103 ++++++++++++++++++++ 10 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 api/tests/unit/parser/test_osv_flat_form.py diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 4b9f399..a9b5342 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -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 diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index ae122d9..3da3c06 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -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 diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index af1836a..295029e 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -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: diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py index 13fc7ac..7da5ee3 100644 --- a/api/src/parser/indicators.py +++ b/api/src/parser/indicators.py @@ -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 diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 9ed7de9..e4818dc 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -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 diff --git a/api/src/parser/osv_warnings.py b/api/src/parser/osv_warnings.py index 22ad46a..b0d07ad 100644 --- a/api/src/parser/osv_warnings.py +++ b/api/src/parser/osv_warnings.py @@ -44,6 +44,7 @@ HIERARCHY_TEXTS = { "outline": "по группировке листа (outline)", "indent": "по отступам (indent)", "account_pattern": "по кодам счетов (account_pattern)", + "header_account": "плоский список под счётом шапки (header_account)", "sums": "по суммам (sums)", "trivial": "плоский список (trivial)", } diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index b441dd9..2771051 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -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)] diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 2571c78..e5d1d72 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -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 diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index 7a15156..c963974 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -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 diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py new file mode 100644 index 0000000..4bc1f77 --- /dev/null +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -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"]