From cdbe23b9b60accc1d1cd7c063c5cf463806e537c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:27:00 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B=20=D1=80=D0=B0=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D0=BD=D1=8B=D1=85=20=D1=81=D1=87=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D1=83=20=D1=81=D1=87?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit counterparty_accounts в osv_groups.yaml (R-9: без 71 и 73), is_counterparty_account, OSV_ROWS по счёту; формулировка проверки «Итого» без корня ACCOUNT (T4.1); ожидания s40–s44 в синтетике (T1.1/T1.2), тест испорченной «Итого» плоского списка (T4.2). Co-Authored-By: Claude Sonnet 5.5 --- api/rules/osv_groups.yaml | 5 ++ api/src/parser/checks.py | 3 + api/src/parser/classify.py | 25 ++++++- api/src/parser/osv.py | 11 +++ api/src/parser/rules.py | 34 +++++++-- api/tests/synthetic/test_synthetic_files.py | 27 ++++++- api/tests/unit/parser/test_classify.py | 71 ++++++++++++++++++ api/tests/unit/parser/test_osv_flat_form.py | 81 ++++++++++++++++++++- api/tests/unit/parser/test_rules.py | 41 +++++++++++ 9 files changed, 285 insertions(+), 13 deletions(-) diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 8adc464..8f63bd2 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -15,5 +15,10 @@ mapping: - ['^стать(и|я)\b', OTHER] - ['^валют', OTHER] +# Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван +# (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам +# («76» -> 76.2, 76.Н.1; не 760). 71 и 73 исключены (R-9): подотчётные лица и персонал — не контрагенты. +counterparty_accounts: ['58', '60', '62', '66', '67', '75', '76', '80'] + # Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка default: OTHER diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index a9b5342..11e0bf9 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -220,6 +220,9 @@ def _total_warning(rows: Sequence[ClassifiedRow], secondary: Sequence[Pattern] = if total is None: return TOTAL_NOT_FOUND_WARNING if root is None: + level_zero = _level_zero(rows) + if level_zero: + return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ сумме {len(level_zero)} строк уровня 0" return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет" message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})" level_zero = _level_zero(rows) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 3da3c06..9ef2c94 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -83,6 +83,8 @@ class ClassifiedRow: data_type: DataType | None extra: dict[str, str | None] | None indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке + # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-9), а не по схеме группировок + counterparty_by_account: bool = False # --- Строки таблицы --- @@ -383,6 +385,7 @@ class _Kind: counterparty_name: str | None = None counterparty_inn: str | None = None indicator: bool = False + counterparty_by_account: bool = False @dataclass(frozen=True) @@ -423,6 +426,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) + # Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов (R-9) + by_account = ( + group_type == "OTHER" + and name is None + and position == 1 + and position >= len(context.schema) + and context.rules.groups.is_counterparty_account(code) + ) + if by_account: + group_type = "COUNTERPARTY" if group_type != "COUNTERPARTY": return _Kind(group_type, name, code, own), branch inn_col = context.header.inn_col @@ -430,7 +443,16 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple # ИНН: колонка ИНН, затем строка ИНН рядом (`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, own, counterparty_name=counterparty, counterparty_inn=inn), branch + kind = _Kind( + group_type, + name, + code, + own, + counterparty_name=counterparty, + counterparty_inn=inn, + counterparty_by_account=by_account, + ) + return kind, branch def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно @@ -496,6 +518,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta data_type=kind.data_type, extra=_extra(sheet, item.row, extra_keys), indicator=kind.indicator, + counterparty_by_account=kind.counterparty_by_account, ) ) return result diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index e4818dc..f68c5af 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -112,6 +112,16 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea return replace(doc, account_code_raw=code, warnings=tuple(warnings)) +def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]: + """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-9), в порядке листа.""" + codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account) + return [ + f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — " + "проверьте" + for code in codes + ] + + def _is_ok(checks: OsvChecks) -> bool: return ( bool(checks.total_row_matches_root) @@ -226,6 +236,7 @@ class OsvParser: *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, _by_account_warnings(classified)), *notes_of(OSV_INN_ROWS, glue.warnings), ] if not any(item.group_type != "TOTAL" for item in classified): diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 3972b0e..cbeacef 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -184,9 +184,34 @@ class OsvHeaderRules(_Model): return self +def _check_account_codes(field_name: str, codes: Iterable[str]) -> None: + bad = [code for code in codes if not _ACCOUNT_CODE.fullmatch(code)] + if bad: + raise ValueError(f"{field_name}: ожидаются коды счетов вида «91» или «76.09», {bad}") + + +def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool: + """Код счёта из файла («91», «91.01») совпадает с одним из `roots` либо является его субсчётом (не «910»).""" + if not account_code: + return False + code = account_code.strip() + return any(code == root or code.startswith(f"{root}.") for root in roots) + + class OsvGroupRules(_Model): mapping: list[tuple[Pattern, GroupType]] default: GroupType = "OTHER" + # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-9) + counterparty_accounts: list[str] = [] + + @model_validator(mode="after") + def _check_counterparty_accounts(self) -> "OsvGroupRules": + _check_account_codes("counterparty_accounts", self.counterparty_accounts) + return self + + def is_counterparty_account(self, account_code: str | None) -> bool: + """Счёт из `counterparty_accounts` или его субсчёт («76» -> 76, 76.2, 76.Н.1; не 760).""" + return _is_under_account(account_code, self.counterparty_accounts) def classify(self, text: str) -> GroupType: """Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`.""" @@ -203,17 +228,12 @@ class OsvCheckRules(_Model): @model_validator(mode="after") def _check_codes(self) -> "OsvCheckRules": - bad = [code for code in self.accounts_without_analytic_balance if not _ACCOUNT_CODE.fullmatch(code)] - if bad: - raise ValueError(f"accounts_without_analytic_balance: ожидаются коды счетов вида «91» или «76.09», {bad}") + _check_account_codes("accounts_without_analytic_balance", self.accounts_without_analytic_balance) return self def has_no_analytic_balance(self, account_code: str | None) -> bool: """Код счёта из файла («91», «91.01») относится к счёту из списка: совпадает с ним либо его субсчёт.""" - if not account_code: - return False - code = account_code.strip() - return any(code == root or code.startswith(f"{root}.") for root in self.accounts_without_analytic_balance) + return _is_under_account(account_code, self.accounts_without_analytic_balance) class PeriodRule(_Model): diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 5a68d0c..e375732 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -43,7 +43,7 @@ VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx" -# Плоская форма ОСВ 1С (#59): на этапе снимков текущего поведения ожиданий нет — их добавляют задачи 2–5 плана +# Плоская форма ОСВ 1С (#59) FLAT_FORM_FILES = ( "s40_flat_header_account.xlsx", "s41_flat_chart_of_accounts.xlsx", @@ -162,6 +162,27 @@ EXPECTED: dict[str, Expect] = { "s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False), "s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False), "s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), + # плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `data_type = None` (умолчание — трек A, AURORA-1613) + # счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.» + "s40_flat_header_account.xlsx": Expect( + "header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' + ), + # вид «Организации», «за 2 Квартал 2026 г. План счетов …» + "s41_flat_chart_of_accounts.xlsx": Expect( + "header_account", 2026, "Q2", False, data_type=None, organization="ООО Хреновская Нива" + ), + # субсчета 76.2…76.Н.1 по шаблону кода; «Итого развернутое» перед «Итого» + "s42_subaccounts_totals_reversed.xlsx": Expect( + "account_pattern", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' + ), + # счёт 60 без вида аналитики в шапке, отступы + "s43_subkonto_unnamed_60.xlsx": Expect( + "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' + ), + # то же на счёте 10: материалы остаются OTHER + "s44_subkonto_unnamed_10.xlsx": Expect( + "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' + ), } @@ -248,13 +269,11 @@ def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None: """Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора).""" - assert synthetic_file.name in {*EXPECTED, *FLAT_FORM_FILES}, f"добавьте ожидания для {synthetic_file.name}" + assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}" def test_header_status_and_hierarchy(synthetic_file: Path) -> None: """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту.""" - if synthetic_file.name in FLAT_FORM_FILES: - pytest.skip("плоская форма (#59): ожидания по файлу — в сценариях задач 2–5, до них поведение фиксирует golden") expect = EXPECTED[synthetic_file.name] outcome = parse_path(synthetic_file) diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index a1d780c..2eeeee1 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1142,3 +1142,74 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None: secondary = parsed.one("Итого развернутое") assert parsed.total_row == secondary.body.row assert secondary.group_type == "TOTAL" + + +# --- Контрагенты расчётных счетов без названия вида (#59, R-9) --- + + +def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: + """Схема из одной ACCOUNT («Субконто»); `label` — вторая группировка в блоке заголовка.""" + block: list[list[object]] = [["Субконто", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]] + block.append([label, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"]) + return sheet_from_rows([["ООО Ромашка"], [TITLE], [], *block, *rows]) + + +def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "60.01", *S100], + [L(1), "ИП Знаменских А.И.", *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one("ИП Знаменских А.И.") + assert row.group_type == "COUNTERPARTY" + assert row.group_name is None + assert row.counterparty_name == "ИП Знаменских А.И." + assert row.counterparty_by_account is True + + +def test_unnamed_second_level_stays_other(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "60.01", *S100], + [L(1), "ИП Знаменских А.И.", *S100], + [L(2), "Договор № 1", *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one("Договор № 1") + assert row.group_type == "OTHER" + assert row.counterparty_by_account is False + + +def test_named_other_group_is_not_reclassified(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "76.01", *S100], + [L(1), "Станок", *S100], + [L(0), "Итого", *S100], + label="Основные средства", + ) + + parsed = parse(sheet, rules) + + row = parsed.one("Станок") + assert row.group_type == "OTHER" + assert row.group_name == "Основные средства" + assert row.counterparty_by_account is False + + +def test_non_settlement_account_stays_other(rules: Rules) -> None: + sheet = _subkonto_sheet( + [L(0), "10.01", *S100], + [L(1), "Мука пшеничная", *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one("Мука пшеничная") + assert row.group_type == "OTHER" + assert row.counterparty_by_account is False diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index 4bc1f77..f6e049b 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -1,9 +1,12 @@ """Плоская форма ОСВ 1С (#59, change osv-flat-form): стратегия `header_account` и счёт шапки у строк.""" +from pathlib import Path + import pytest from gridbuilder import L, M, sheet_from_rows -from src.contract.result import OsvDocument +from src.contract.result import OsvDocument, OsvRow +from src.parser import get_parser from src.parser.deadline import Deadline from src.parser.osv import OsvParser from src.parser.rules import Rules, load_rules @@ -101,3 +104,79 @@ def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Ru 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"] + + +# --- Контрагенты расчётных счетов без названия вида (Task 5, R-9, R-10) --- + +BY_ACCOUNT_WARNING = ( + "вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте" +) + + +def _parse_synthetic(synthetic_dir: Path, name: str) -> tuple[OsvDocument, list[str], str]: + outcome = get_parser().parse((synthetic_dir / name).read_bytes(), "XLSX") + assert outcome.error is None, outcome.error + (document,) = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] + return document, [note.message for note in document.warnings], document.status + + +def _data_rows(document: OsvDocument) -> list[OsvRow]: + return [item for item in document.rows if item.group_type != "TOTAL"] + + +@pytest.mark.fixtures +def test_s43_counterparties_with_warning(synthetic_dir: Path) -> None: + document, messages, status = _parse_synthetic(synthetic_dir, "s43_subkonto_unnamed_60.xlsx") + + kids = {item.row_value: item for item in _data_rows(document) if item.row_value != "60.01"} + assert set(kids) == {"ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"'} + assert {item.group_type for item in kids.values()} == {"COUNTERPARTY"} + notes = [note for note in document.warnings if note.code == "OSV_ROWS"] + assert [note.message for note in notes] == [BY_ACCOUNT_WARNING] + assert status == "done" + assert messages.count(BY_ACCOUNT_WARNING) == 1 + + +@pytest.mark.fixtures +def test_s44_materials_stay_other(synthetic_dir: Path) -> None: + document, messages, _ = _parse_synthetic(synthetic_dir, "s44_subkonto_unnamed_10.xlsx") + + kids = [item for item in _data_rows(document) if item.row_value in {"Мука пшеничная", "Сахар-песок"}] + assert [item.row_value for item in kids] == ["Мука пшеничная", "Сахар-песок"] + assert {item.group_type for item in kids} == {"OTHER"} + assert not any("вид субконто в шапке не назван" in message for message in messages) + + +@pytest.mark.fixtures +def test_s41_flat_counterparties(synthetic_dir: Path) -> None: + document, messages, _ = _parse_synthetic(synthetic_dir, "s41_flat_chart_of_accounts.xlsx") + + data = _data_rows(document) + assert {item.group_type for item in data} == {"COUNTERPARTY"} + assert {item.account_code_raw for item in data} == {"60"} + assert not any("вид субконто в шапке не назван" in message for message in messages) + + +def test_flat_list_with_broken_total_needs_check(rules: Rules) -> None: + """T4.2: плоский список под счётом шапки с испорченной суммой «Итого» — needs_check, не done.""" + rows: list[list[object]] = [ + [], + ["Оборотно-сальдовая ведомость по счету: 76.Н.1"], + ["Контрагенты"], + ["за 2025 г."], + [], + *_table_head(), + ["Август - Агро ООО", 1000, None, 500, 300, 1200, None], + ["АВИС ООО", None, 200, 100, 400, None, 500], + ["Итого", 800, None, 650, 700, 1200, 500], + ] + + document, _ = _parse(rules, rows) + + assert document.status == "needs_check" + assert document.checks is not None + assert document.checks.total_row_matches_root is False + # T4.1: без корня ACCOUNT текст говорит о сумме строк уровня 0, а не о «корневом счёте нет» + messages = [note.message for note in document.warnings] + assert any("≠ сумме 2 строк уровня 0" in message for message in messages), messages + assert not any("корневого счёта нет" in message for message in messages) diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index ae7ba75..e4ffb65 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -706,3 +706,44 @@ def test_v_valyute_row_is_currency(rules: Rules) -> None: @pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"]) def test_indicator_service_column(rules: Rules, title: str) -> None: assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"]) + + +# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-9) --- + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("76", True), + ("76.2", True), + ("76.Н.1", True), + ("60.01", True), + ("60", True), + (" 62.01 ", True), + ("760", False), + ("600", False), + ("10.01", False), + ("71", False), + ("73.01", False), + ("", False), + (None, False), + ], +) +def test_counterparty_accounts_match_code_and_subaccounts(rules: Rules, code: str | None, expected: bool) -> None: + assert rules.groups.is_counterparty_account(code) is expected + + +def test_counterparty_accounts_list_is_data(rules: Rules) -> None: + assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"] + + +@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"]) +def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None: + target = _copy_rules(tmp_path) + path = target / "osv_groups.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + data["counterparty_accounts"] = [bad] + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*counterparty_accounts"): + load_rules(target)