diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 8691bb6..8adc464 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -4,6 +4,8 @@ mapping: - ['^(счета?|субконто):?$', ACCOUNT] - ['^подразделени', DEPARTMENT] - ['^контрагент', COUNTERPARTY] + # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10) + - ['^организаци', COUNTERPARTY] - ['^договор', CONTRACT] # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 375d300..b4956f3 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -95,3 +95,6 @@ header_markers: subkonto: - '\bсубконто\b' - '\bдетализация\b' + # плоская форма 1С: «за 2 Квартал 2026 г. План счетов ООО Хреновская Нива» — организация после метки + chart_of_accounts: + - '\bплан\s+счетов\b' diff --git a/api/src/parser/header.py b/api/src/parser/header.py index ac7e8a9..58ca007 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -128,6 +128,7 @@ def _is_structural_marker(cell: _HeadCell, rules: Rules) -> bool: *markers["data_type"], *markers["org_params"], *markers["subkonto"], + *markers["chart_of_accounts"], ] return _find(patterns, cell.text) is not None @@ -254,14 +255,49 @@ def _title_tail_organization(cells: list[_HeadCell], rules: Rules) -> str | None return None +def _kind_row_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]: + """Ячейки строго между строкой названия ведомости (метка `account`) и строкой периода, если название выше периода. + + Плоская форма 1С: «по счету: 76» / «Контрагенты» / «за 2025 г.» — средняя строка называет вид субконто, а не + организацию. Название и период в одной строке (или период выше) — таких ячеек нет. + """ + title = next((cell for cell in cells if _find(rules.headers.header_markers["account"], cell.text)), None) + period = _find_period(cells, rules) + if title is None or period is None or period.cell.row <= title.row: + return [] + return [cell for cell in cells if title.row < cell.row < period.cell.row] + + +def _chart_of_accounts_tail(cells: list[_HeadCell], rules: Rules) -> str | None: + """Организация после метки «План счетов» (хвост строки периода плоской формы 1С); пустой хвост — `None`.""" + for cell in cells: + match = _find(rules.headers.header_markers["chart_of_accounts"], cell.text) + if match is None: + continue + tail = _tail(cell.text, match).strip(" ,;:") + if tail: + return tail + return None + + def _organization( cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None ) -> str | None: - """Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода, меткой валюты или типом данных; иначе - хвост названия ведомости. + """Организация: хвост метки «План счетов»; иначе первая текстовая ячейка, не являющаяся меткой шапки, строкой + периода, строкой вида субконто, меткой валюты или типом данных; иначе хвост названия ведомости. """ + tail = _chart_of_accounts_tail(cells, rules) + if tail is not None: + return tail taken = [ - cell for cell in (None if period is None else period.cell, data_type, *_currency_cells(cells, rules)) if cell + cell + for cell in ( + None if period is None else period.cell, + data_type, + *_currency_cells(cells, rules), + *_kind_row_cells(cells, rules), + ) + if cell ] found = next( (cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)), @@ -275,7 +311,7 @@ def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: match = _find(rules.headers.header_markers["account"], cell.text) if match is None: continue - code = _ACCOUNT_CODE.match(_tail(cell.text, match)) + code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": ")) if code is not None: return code.group(0).rstrip(_CODE_TRAILING) return None @@ -539,9 +575,22 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSc groups.append(_group(group_type, label, has_inn=has_inn)) if not groups: groups = [_group("ACCOUNT")] + if len(groups) == 1: + groups.extend(_kind_row_groups(sheet, header, rules)) return GroupSchema(groups, 1, hint) +def _kind_row_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]: + """Виды субконто из строки шапки между названием и периодом: только узнанные явной записью словаря групп.""" + groups: list[OsvGroup] = [] + for cell in _kind_row_cells(_head_cells(sheet, header), rules): + for item in (part.strip() for part in cell.text.split(_LIST_SEPARATOR)): + known = rules.groups.known(item) if item else None + if known is not None: + groups.append(_group(known, item)) + return groups + + def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> GroupSchema: groups = [_group("ACCOUNT", _first_group_name(sheet, header))] hint = _inn_hint(items, rules) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 9e7846c..3972b0e 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -49,7 +49,7 @@ CHECKS_FILE = "osv_checks.yaml" GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] SumGroup = Literal["opening", "turnover", "closing"] -HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"] +HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto", "chart_of_accounts"] RowDataType = Literal["CURRENCY", "QUANTITY"] Quarter = Literal["Q1", "Q2", "Q3", "Q4"] @@ -193,6 +193,10 @@ class OsvGroupRules(_Model): found = first_match(self.mapping, text) return self.default if found is None else found + def known(self, text: str) -> GroupType | None: + """Тип группировки по явной записи `mapping`; не совпавшее ни с одной записью — `None` (не `default`).""" + return first_match(self.mapping, text) + class OsvCheckRules(_Model): accounts_without_analytic_balance: list[str] diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index dfe2820..c2776ce 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -10,7 +10,7 @@ from gridbuilder import M, at, sheet_from_rows from src.parser.detect import TableHeader, find_table_header from src.parser.grid import Sheet -from src.parser.header import DocHeader, build_group_schema, parse_doc_header +from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header from src.parser.rules import Rules, load_rules OPENING = "Сальдо на начало периода" @@ -569,3 +569,77 @@ def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None: doc = _variant1(rules, title=_title("за неизвестно что")) assert doc.period_start is None and doc.period_end is None + + +# --- Плоская форма 1С: «по счету: X», строка вида субконто, «План счетов» --- + + +def _flat_rows(*head: str, group_label: str = "Субконто") -> list[list[object]]: + """Шапка плоской формы: строки шапки, заголовок «Субконто» с группами сумм, строка данных.""" + groups_row = [M(group_label, 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)] + subhead = at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"}) + return [*[[text] for text in head], groups_row, subhead, DATA_ROW] + + +def test_account_code_after_colon(rules: Rules) -> None: + rows = _flat_rows("Оборотно-сальдовая ведомость по счету: 76.Н.1", "за 2025 г.", "ООО Ромашка") + + doc = _parse(rows, rules)[2] + + assert doc.account_code_raw == "76.Н.1" + assert ACCOUNT_NOT_FOUND_WARNING not in doc.warnings + + +def test_colon_without_code_falls_back_to_table_row(rules: Rules) -> None: + rows = _flat_rows("Оборотно-сальдовая ведомость по счету:", "за 2025 г.", "ООО Ромашка") + + doc = _parse(rows, rules)[2] + + assert doc.account_code_raw == "" + assert ACCOUNT_NOT_FOUND_WARNING in doc.warnings + + +def test_kind_row_between_title_and_period_is_not_organization(rules: Rules) -> None: + rows = _flat_rows( + "Оборотно-сальдовая ведомость по счету: 76", "Контрагенты", "за 2025 г.", 'ООО "Товарное хозяйство"' + ) + + assert _parse(rows, rules)[2].organization_name == 'ООО "Товарное хозяйство"' + + +def test_organization_from_chart_of_accounts_tail(rules: Rules) -> None: + rows = _flat_rows( + "Оборотно-сальдовая ведомость по счету: 60", + "Организации", + "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива", + ) + + doc = _parse(rows, rules)[2] + + assert doc.organization_name == "ООО Хреновская Нива" + assert doc.period_quarter == "Q2" + assert doc.period_year == 2026 # noqa: PLR2004 + + +def test_title_and_period_in_one_row_keep_old_organization_rule(rules: Rules) -> None: + header = _variant1(rules, title=_title("3 квартал 2024 г.")) + + assert header.organization_name == 'АО "Ромашка"' + + +def _flat_schema(rules: Rules, kind: str) -> list[tuple[str, str | None]]: + rows = _flat_rows("Оборотно-сальдовая ведомость по счету: 76", kind, "за 2025 г.", "ООО Ромашка") + sheet, header, _ = _parse(rows, rules) + return [(g.type, g.name) for g in build_group_schema(sheet, header, rules)[0]] + + +def test_known_kind_row_extends_single_account_schema(rules: Rules) -> None: + assert _flat_schema(rules, "Контрагенты") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Контрагенты")] + + +def test_unknown_kind_row_is_not_added_to_schema(rules: Rules) -> None: + assert _flat_schema(rules, "Материалы") == [("ACCOUNT", "Субконто")] + + +def test_organizations_kind_row_is_counterparty(rules: Rules) -> None: + assert _flat_schema(rules, "Организации") == [("ACCOUNT", "Субконто"), ("COUNTERPARTY", "Организации")] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index e8f0f0f..ae7ba75 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -359,12 +359,20 @@ def test_credit_synonyms(rules: Rules, text: str) -> None: ("org_params", "Параметры:"), ("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"), ("subkonto", "Субконто"), + ("chart_of_accounts", "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"), ], ) def test_header_markers(rules: Rules, kind: str, text: str) -> None: assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index] +def test_known_group_is_none_for_default(rules: Rules) -> None: + assert rules.groups.known("Контрагенты") == "COUNTERPARTY" + assert rules.groups.known("Материалы") is None + assert rules.groups.known("Организации") == "COUNTERPARTY" + assert rules.groups.known("Номенклатура") == "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)))