diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 8f63bd2..9509235 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -4,8 +4,9 @@ mapping: - ['^(счета?|субконто):?$', ACCOUNT] - ['^подразделени', DEPARTMENT] - ['^контрагент', COUNTERPARTY] - # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10) - - ['^организаци', COUNTERPARTY] + # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число + # «Организация» — собственная организация учёта (измерение шапки), контрагентом не является + - ['^организации\b', COUNTERPARTY] - ['^договор', CONTRACT] # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных) @@ -13,7 +14,8 @@ mapping: - ['^нематериальн\w*\s+актив', OTHER] - ['^номенклатур', OTHER] - ['^стать(и|я)\b', OTHER] - - ['^валют', OTHER] + # строки валюты («Валюта USD», «В валюте») — не контрагенты и по плану счетов (R-18) + - ['^(в\s+)?валют', OTHER] # Расчётные счета, у которых аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван # (ОСВ 1С по счёту 60 без строки «Контрагенты»; AURORA-1661, OQ-158). Счёт сопоставляется по коду и субсчетам diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 31260be..493a149 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -79,6 +79,11 @@ def _is_secondary_total(item: ClassifiedRow, secondary: Sequence[Pattern]) -> bo return item.group_type == "TOTAL" and any(p.search(item.body.text) for p in secondary) +def _has_turnovers(item: ClassifiedRow) -> bool: + """В строке есть хотя бы одно значение оборотов за период (дебет или кредит; пусто = нет, 0 — значение).""" + return any(value is not None for value in item.body.sums[_TURNOVER]) + + def balance_identity( rows: Sequence[ClassifiedRow], exempt: OsvCheckRules | None = None, @@ -86,13 +91,17 @@ def balance_identity( ) -> list[int]: """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого». - Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторые итоговые строки - (`secondary_totals`, «Итого развернутое») не проверяются: в 1С они несут только развёрнутые сальдо, без оборотов - (#59, R-16); основная «Итого» проверяется. + Строки счетов без сальдо по аналитике (`exempt`, OQ-132) не проверяются. Вторая итоговая строка + (`secondary_totals`, «Итого развернутое») пропускается, только если у неё нет оборотов (обе колонки пусты) и она не + единственная TOTAL листа: «Итого развернутое» перед «Итого» несёт одни развёрнутые сальдо (#59, R-19). С оборотами + или единственная — проверяется как обычная строка. """ violations: list[int] = [] + totals = sum(1 for item in rows if item.group_type == "TOTAL") for item in rows: - if _no_analytic_balance(item, exempt) or _is_secondary_total(item, secondary_totals): + if _no_analytic_balance(item, exempt): + continue + if _is_secondary_total(item, secondary_totals) and totals > 1 and not _has_turnovers(item): continue sums = _values(item) expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER]) @@ -196,8 +205,8 @@ def total_matches_root(rows: Sequence[ClassifiedRow], secondary_totals: Sequence Нет «Итого» — `False` (R-20); нет корня ACCOUNT, но есть строки уровня 0 — «Итого» сверяется с их суммой (плоский список под счётом шапки, #59, R-5); нет ни того ни другого — `True`, только если все суммы «Итого» пусты или - нулевые (R-13). Сумма - уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. + нулевые (R-13). Сумма уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, + «Итого» равно их сумме. """ total = _total(rows, secondary_totals) if total is None: @@ -250,8 +259,8 @@ def run_checks( """Три проверки §1.4 и заметки с кодами `OSV_*` и номерами строк (пустой список — всё сошлось). `exempt` — счета без сальдо по аналитике (`rules.checks`, OQ-132); без него проверяются все строки полностью. - `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они, и тождество - строки на них не проверяется (#59, R-16). + `secondary_totals` — вторые итоговые строки («Итого развернутое»): «Итого» для сверки — не они; тождество на них + пропускается только без оборотов, когда есть другая «Итого» (#59, R-19). """ identity = balance_identity(rows, exempt, secondary_totals) parents = parent_sums(rows, exempt) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 789c897..4055ab5 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -83,7 +83,7 @@ class ClassifiedRow: data_type: DataType | None extra: dict[str, str | None] | None indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке - # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-9), а не по схеме группировок + # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок counterparty_by_account: bool = False @@ -427,12 +427,15 @@ 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) + # Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов + # (решение владельца №3, R-6); строки, которые словарь групп явно знает как не-контрагентов (валюта), — нет (R-18) + known = context.rules.groups.known(item.text) by_account = ( group_type == "OTHER" and name is None and position == 1 and position >= len(context.schema) + and (known is None or known == "COUNTERPARTY") and context.rules.groups.is_counterparty_account(code) ) if by_account: diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index f68c5af..5bceb0e 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -113,7 +113,7 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]: - """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-9), в порядке листа.""" + """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа.""" codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account) return [ f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — " diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index cbeacef..037cd30 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -201,7 +201,7 @@ def _is_under_account(account_code: str | None, roots: Iterable[str]) -> bool: class OsvGroupRules(_Model): mapping: list[tuple[Pattern, GroupType]] default: GroupType = "OTHER" - # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-9) + # Расчётные счета: аналитика первого уровня — контрагенты, даже если вид субконто в шапке не назван (#59, R-6) counterparty_accounts: list[str] = [] @model_validator(mode="after") diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 7893503..ab638d8 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -964,7 +964,7 @@ def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]: def _s43() -> list[SheetSpec]: """Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом).""" - return _s43_s44("60", "60.01", ("ИП Знаменских А.И.", 'ООО "ЭСТЕТ ОМСК"')) + return _s43_s44("60", "60.01", ("ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"')) def _s44() -> list[SheetSpec]: diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index c86e98d..eddc7d5 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s32_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 9464495..54d1ef3 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -258,7 +258,7 @@ def test_total_check_uses_main_total_when_secondary_comes_first() -> None: SECONDARY = [Pattern.compile(r"^итого\s+развернут")] -# «Итого развернутое» в 1С несёт только развёрнутые сальдо, без оборотов: тождество строки на ней не выполняется +# «Итого развернутое» перед «Итого» несёт только развёрнутые сальдо, без оборотов: тождество на ней не выполняется SECONDARY_ROW: Sums = ("100", "10", None, None, "120", "5") @@ -266,7 +266,7 @@ def _secondary(number: int, sums: Sums) -> ClassifiedRow: return row(number, sums, group_type="TOTAL", text="Итого развернутое") -def test_balance_identity_skips_secondary_total_without_turnovers() -> None: +def test_balance_identity_skips_secondary_total_without_turnovers_when_not_the_only_total() -> None: rows = [*tree(), _secondary(11, SECONDARY_ROW)] assert balance_identity(rows, secondary_totals=SECONDARY) == [] @@ -275,6 +275,30 @@ def test_balance_identity_skips_secondary_total_without_turnovers() -> None: assert not any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) +def test_balance_identity_checks_secondary_total_with_turnovers() -> None: + """R-19: «Итого развернутое» после «Итого» несёт обороты — тождество проверяется, сломанное даёт нарушение.""" + broken: Sums = ("100", None, "50", "30", "121", None) + rows = [*tree(), _secondary(11, broken)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [11] + checks, notes = run_checks(rows, secondary_totals=SECONDARY) + assert checks.balance_identity_violations == [11] + assert any(note.code == "OSV_BALANCE_IDENTITY" for note in notes) + + +def test_balance_identity_accepts_secondary_total_with_turnovers_that_balance() -> None: + rows = [*tree(), _secondary(11, ROOT)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [] + + +def test_balance_identity_checks_only_total_even_if_secondary_without_turnovers() -> None: + """Единственная итоговая строка проверяется, даже если она вторая и без оборотов.""" + rows = [account(7, ROOT), _secondary(8, SECONDARY_ROW)] + + assert balance_identity(rows, secondary_totals=SECONDARY) == [8] + + def test_balance_identity_still_checks_main_total_with_secondary_present() -> None: broken: Sums = ("100", None, "50", "30", "121", None) rows = [account(7, ROOT), total(8, broken), _secondary(9, SECONDARY_ROW)] diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 2eeeee1..3a5dabd 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -1134,7 +1134,7 @@ def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> No def test_only_secondary_total_is_the_total(rules: Rules) -> None: - """Review Focus 3: без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" + """Без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100)) parsed = parse(sheet, rules) @@ -1144,7 +1144,7 @@ def test_only_secondary_total_is_the_total(rules: Rules) -> None: assert secondary.group_type == "TOTAL" -# --- Контрагенты расчётных счетов без названия вида (#59, R-9) --- +# --- Контрагенты расчётных счетов без названия вида (#59, R-6) --- def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: @@ -1157,23 +1157,23 @@ def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: 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(1), "ИП Иванов А.А.", *S100], [L(0), "Итого", *S100], ) parsed = parse(sheet, rules) - row = parsed.one("ИП Знаменских А.И.") + row = parsed.one("ИП Иванов А.А.") assert row.group_type == "COUNTERPARTY" assert row.group_name is None - assert row.counterparty_name == "ИП Знаменских А.И." + 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(1), "ИП Иванов А.А.", *S100], [L(2), "Договор № 1", *S100], [L(0), "Итого", *S100], ) @@ -1213,3 +1213,21 @@ def test_non_settlement_account_stays_other(rules: Rules) -> None: row = parsed.one("Мука пшеничная") assert row.group_type == "OTHER" assert row.counterparty_by_account is False + + +@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "Валюта CNY", "Валюта руб", "В валюте"]) +def test_currency_row_under_settlement_account_is_not_counterparty(rules: Rules, text: str) -> None: + """R-18: строка валюты под 76.55 — не контрагент по плану счетов, а контрагент рядом остаётся контрагентом.""" + sheet = _subkonto_sheet( + [L(0), "76.55", *S100], + [L(1), "ООО Ромашка", *S100], + [L(1), text, *S100], + [L(0), "Итого", *S100], + ) + + parsed = parse(sheet, rules) + + row = parsed.one(text) + assert row.group_type == "OTHER" + assert row.counterparty_by_account is False + assert parsed.one("ООО Ромашка").group_type == "COUNTERPARTY" diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index 61a2fc9..ac0a226 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -645,6 +645,11 @@ def test_organizations_kind_row_is_counterparty(rules: Rules) -> None: assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")] +def test_single_organization_kind_row_is_not_counterparty(rules: Rules) -> None: + """R-17: «Организация» — собственная организация учёта (варианты 1/2 шапки), схема остаётся [ACCOUNT].""" + assert _flat_schema(rules, "Организация") == [("ACCOUNT", "Субконто")] + + def test_currency_row_between_title_and_period_is_not_added_to_schema(rules: Rules) -> None: assert _flat_schema(rules, "Валюта БУ, НУ: RUB") == [("ACCOUNT", "Субконто")] diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index c963974..e4ce1e9 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -486,3 +486,15 @@ def test_header_account_not_used_with_account_rows() -> None: assert levels.strategy == "account_pattern" assert strategy_header_account(rows) is None + + +def test_header_account_goes_before_sums_for_adjacent_rows_with_equal_sums() -> None: + """Две соседние строки с равными суммами при счёте шапки — плоский список, а не вложенность по суммам.""" + same = money("100.00", None, "50.00", None, "150.00", None) + rows = numbered(("ООО Ромашка", {"sums": same}), ("ООО Лютик", {"sums": same})) + + levels = resolve_levels(rows, None, never_expires(), header_account=True) + + assert levels.strategy == "header_account" + assert levels_list(rows, levels.by_row) == [0, 0] + assert set(levels.parent.values()) == {None} diff --git a/api/tests/unit/parser/test_osv_flat_form.py b/api/tests/unit/parser/test_osv_flat_form.py index aac74c5..ed45612 100644 --- a/api/tests/unit/parser/test_osv_flat_form.py +++ b/api/tests/unit/parser/test_osv_flat_form.py @@ -86,7 +86,7 @@ def test_flat_list_s41(rules: Rules) -> None: def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Rules) -> None: - """Review Focus 4: счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки.""" + """Счёт в шапке, строк-счетов нет, outline есть — `outline`, но строки наследуют счёт шапки.""" rows: list[list[object]] = [ ["ООО Зерновая"], ["Оборотно-сальдовая ведомость по счету 60"], @@ -106,7 +106,7 @@ def test_outline_wins_over_header_account_and_rows_inherit_header_code(rules: Ru assert [item.account_code_raw for item in data] == ["60", "60"] -# --- Контрагенты расчётных счетов без названия вида (Task 5, R-9, R-10) --- +# --- Контрагенты расчётных счетов без названия вида (Task 5, R-6, R-10, R-17) --- BY_ACCOUNT_WARNING = ( "вид субконто в шапке не назван: строки аналитики счёта 60.01 приняты за контрагентов по плану счетов — проверьте" @@ -129,7 +129,7 @@ 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 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] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index e4ffb65..a17a5f6 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -373,6 +373,18 @@ def test_known_group_is_none_for_default(rules: Rules) -> None: assert rules.groups.known("Номенклатура") == "OTHER" +def test_single_organization_is_own_organization_not_counterparty(rules: Rules) -> None: + """R-17: «Организация» (единственное число) — собственная организация учёта, не вид субконто «Организации».""" + assert rules.groups.known("Организация") is None + assert rules.groups.classify("Организация") == "OTHER" + + +@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "В валюте", "в валюте CNY"]) +def test_currency_rows_are_known_other_groups(rules: Rules, text: str) -> None: + """R-18: строки валюты словарь групп знает как OTHER — по плану счетов они контрагентами не становятся.""" + assert rules.groups.known(text) == "OTHER" + + def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None: text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г." match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text))) @@ -708,7 +720,7 @@ 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) --- +# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-6) --- @pytest.mark.parametrize(