From b8bf2f29e4715f3bce820f86ce9324c152584be5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 15:36:26 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC,=20=D1=82=D0=B8=D0=BF=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D1=83=20=D0=B8=20=D0=9E=D0=9A=D0=A3=D0=94,=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=8B-=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F;=20classify=5Fsheet=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20statement=5Fmarkers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #14 Co-Authored-By: Claude Haiku 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/rules/README.md | 4 +- api/rules/osv_headers.yaml | 7 - api/src/parser/detect.py | 16 +- api/src/parser/rules.py | 1 - api/src/parser/statements/detect.py | 138 ++++++++++++++++++ api/tests/fixtures/test_rules_real.py | 1 - .../unit/parser/statements/test_detect.py | 137 +++++++++++++++++ api/tests/unit/parser/test_rules.py | 6 - 8 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 api/src/parser/statements/detect.py create mode 100644 api/tests/unit/parser/statements/test_detect.py diff --git a/api/rules/README.md b/api/rules/README.md index da667cf..6b9c693 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -10,7 +10,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. | Файл | Ключи | Что описывает | |---|---|---| -| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | +| `osv_headers.yaml` | `sheet_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | | `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | @@ -37,7 +37,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. ## Покрытие словарей -Каждая запись распознавания (один `Pattern` в любом из четырёх словарей: `sheet_markers`, `statement_markers`, +Каждая запись распознавания (один `Pattern` в любом из словарей: `sheet_markers`, `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end` периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике: это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 05216b3..a5ac32c 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -11,13 +11,6 @@ sheet_markers: - 'оборотно\s*сальдовая\s+ведомость' -# Листы форм ББ/ОФР (Срез 2b): ОСВ-парсер их пропускает с предупреждением -statement_markers: - - 'бухгалтерский\s+баланс' - - '0710001' - - 'отчет\s+о\s+финансовых\s+результатах' - - '0710002' - # Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover: # отсюда границы слова `\bоборот(ы)?\b`. groups: diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 201092c..46b5bda 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -18,6 +18,7 @@ from src.parser.deadline import Deadline from src.parser.errors import InvalidFormatError from src.parser.grid import Cell, Sheet, column_letter from src.parser.rules import Pattern, Rules, SumGroup, normalize_text +from src.parser.statements.detect import classify_statement_sheet __all__ = [ "DEADLINE_CHECK_EVERY_ROWS", @@ -73,6 +74,7 @@ SIDE_TITLES: dict[Side, str] = {"debit": "Дебет", "credit": "Кредит"} class SheetKind(StrEnum): OSV = "osv" STATEMENT = "statement" + STATEMENT_CONTINUATION = "statement_continuation" OTHER = "other" @@ -145,14 +147,20 @@ def _texts(sheet: Sheet, row: int) -> Iterator[tuple[int, str]]: # --- Вид листа --- +_STATEMENT_KINDS = { + "statement": SheetKind.STATEMENT, + "continuation": SheetKind.STATEMENT_CONTINUATION, + "other": SheetKind.OTHER, +} + + def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind: - """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР по её признакам; иначе прочее.""" + """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР, её продолжение или прочее — по + `statements.detect` (признаки формы ищутся по всему листу до таблицы).""" head = [text for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1) for _, text in _texts(sheet, row)] if any(_search(rules.headers.sheet_markers, text) for text in head): return SheetKind.OSV - if any(_search(rules.headers.statement_markers, text) for text in head): - return SheetKind.STATEMENT - return SheetKind.OTHER + return _STATEMENT_KINDS[classify_statement_sheet(sheet, rules)] # --- Заголовок таблицы --- diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 83b655a..977f49a 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -172,7 +172,6 @@ class _Model(BaseModel): class OsvHeaderRules(_Model): sheet_markers: list[Pattern] - statement_markers: list[Pattern] groups: dict[SumGroup, list[Pattern]] debit: list[Pattern] credit: list[Pattern] diff --git a/api/src/parser/statements/detect.py b/api/src/parser/statements/detect.py new file mode 100644 index 0000000..5fc27f2 --- /dev/null +++ b/api/src/parser/statements/detect.py @@ -0,0 +1,138 @@ +"""Блоки форм на листе и вид листа форм (design D-2, D-3). + +Блок начинается с заголовка формы или кода ОКУД; новый блок открывается, только если между заголовками уже были +строки с кодами (иначе «Бухгалтерский баланс» и «0710001» на соседних строках — один блок). Титул «Бухгалтерская +отчетность» типа не задаёт. Лист без заголовка, но с маркером «Форма 0710001 с.2», подписью «Код» с периодами или +колонкой кодов — продолжение. +""" + +import re +from dataclasses import dataclass +from typing import Literal + +from src.parser.grid import Sheet +from src.parser.rules import FormType, Rules +from src.parser.statements.columns import cell_label, find_code_column, line_code + +__all__ = ["FormBlock", "StatementSheetKind", "classify_statement_sheet", "continuation_form", "find_forms"] + +StatementSheetKind = Literal["statement", "continuation", "other"] +_OKUD_CODE = re.compile(r"0710\d{3}") + + +@dataclass(frozen=True) +class FormBlock: + kind: FormType + form_name: str | None + okud: str | None + title_row: int | None + first_row: int + last_row: int + + +@dataclass(frozen=True) +class _Candidate: + row: int + title: str | None + title_kind: FormType | None + okud: str | None + okud_kind: FormType | None + + +def _candidates(sheet: Sheet, rules: Rules) -> list[_Candidate]: + headers = rules.statement_headers + found: list[_Candidate] = [] + for row in range(1, sheet.n_rows + 1): + title, title_kind, okud, okud_kind = None, None, None, None + for col, _ in sheet.row_cells(row): + label = cell_label(sheet, row, col) + kind = headers.form_type(label) + if kind is not None and title is None: + title, title_kind = label, kind + # Don't treat continuation markers as regular OKUD blocks + if not headers.is_continuation(label): + by_okud = headers.okud_type(label) + if by_okud is not None and okud is None: + code = _OKUD_CODE.search(label) + okud, okud_kind = (code.group(0) if code else label), by_okud + if title is not None or okud is not None: + found.append(_Candidate(row, title, title_kind, okud, okud_kind)) + return found + + +def _code_rows(sheet: Sheet, rules: Rules) -> list[int]: + found: list[int] = [] + for row in range(1, sheet.n_rows + 1): + if any(line_code(cell, rules) for _, cell in sheet.row_cells(row)): + found.append(row) + return found + + +def find_forms(sheet: Sheet, rules: Rules) -> tuple[list[FormBlock], list[str]]: + """Блоки форм сверху вниз и предупреждения (заголовок и ОКУД противоречат — тип по ОКУД).""" + candidates = _candidates(sheet, rules) + if not candidates: + return [], [] + code_rows = _code_rows(sheet, rules) + groups: list[list[_Candidate]] = [] + for candidate in candidates: + if groups and not any(groups[-1][0].row < row < candidate.row for row in code_rows): + groups[-1].append(candidate) + else: + groups.append([candidate]) + blocks: list[FormBlock] = [] + warnings: list[str] = [] + for index, group in enumerate(groups): + title = next((item for item in group if item.title is not None), None) + okud = next((item for item in group if item.okud is not None), None) + kind = okud.okud_kind if okud is not None else title.title_kind # type: ignore[union-attr] + if title is not None and okud is not None and title.title_kind != okud.okud_kind: + warnings.append( + f"лист «{sheet.name}»: заголовок «{title.title}» и ОКУД {okud.okud} противоречат, тип по ОКУД" + ) + first_row = 1 if index == 0 else group[0].row + last_row = sheet.n_rows if index == len(groups) - 1 else groups[index + 1][0].row - 1 + blocks.append( + FormBlock( + kind=kind, # type: ignore[arg-type] + form_name=title.title if title is not None else None, + okud=okud.okud if okud is not None else None, + title_row=group[0].row, + first_row=first_row, + last_row=last_row, + ) + ) + return blocks, warnings + + +def continuation_form(sheet: Sheet, rules: Rules) -> tuple[FormType | None, str | None]: + """Маркер «Форма 0710001 с.2»: тип формы по ОКУД в маркере и сам текст; без маркера — `(None, None)`.""" + headers = rules.statement_headers + for row in range(1, sheet.n_rows + 1): + for col, _ in sheet.row_cells(row): + label = cell_label(sheet, row, col) + if headers.is_continuation(label): + return headers.okud_type(label), label + return None, None + + +def _has_table_header(sheet: Sheet, rules: Rules) -> bool: + headers, periods = rules.statement_headers, rules.statement_periods + for row in range(1, sheet.n_rows + 1): + cells = sheet.row_cells(row) + caption = next((col for col, cell in cells if headers.is_code_caption(cell.text)), None) + if caption is None: + continue + if any(col > caption and periods.period_end(cell_label(sheet, row, col)) is not None for col, _ in cells): + return True + return False + + +def classify_statement_sheet(sheet: Sheet, rules: Rules) -> StatementSheetKind: + if find_forms(sheet, rules)[0]: + return "statement" + if continuation_form(sheet, rules)[1] is not None or _has_table_header(sheet, rules): + return "continuation" + if find_code_column(sheet, rules, 1, sheet.n_rows) is not None: + return "continuation" + return "other" diff --git a/api/tests/fixtures/test_rules_real.py b/api/tests/fixtures/test_rules_real.py index d9bfc1f..8d1d6b7 100644 --- a/api/tests/fixtures/test_rules_real.py +++ b/api/tests/fixtures/test_rules_real.py @@ -41,7 +41,6 @@ def test_real_osv_phrases_are_covered_by_dictionaries(osv_file: Path, rules: Rul return any(pattern.search(text) for text in texts for pattern in patterns) assert any_cell(headers.sheet_markers), "нет заголовка ведомости" - assert not any_cell(headers.statement_markers), "ОСВ не должна выглядеть как форма ББ/ОФР" for group in headers.groups: assert any_cell(headers.groups[group]), f"нет блока сумм {group}" assert any_cell(headers.debit) diff --git a/api/tests/unit/parser/statements/test_detect.py b/api/tests/unit/parser/statements/test_detect.py new file mode 100644 index 0000000..d9c4c4e --- /dev/null +++ b/api/tests/unit/parser/statements/test_detect.py @@ -0,0 +1,137 @@ +"""Блоки форм на листе, тип формы, лист-продолжение (design D-2, D-3; spec «Листы форм и их продолжения находятся по +содержимому»).""" + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.detect import SheetKind, classify_sheet +from src.parser.rules import Rules, load_rules +from src.parser.statements.detect import classify_statement_sheet, continuation_form, find_forms + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +NPZ_FIRST_SHEET = [ + [None, None, "Бухгалтерская отчетность"], + [None, None, "за январь - сентябрь 2025 г."], + [*at({3: "Организация:", 7: 'ООО "НПЗ"', 22: "по ОКПО", 23: "16098811"})], + [ + *at( + { + 3: "Идентификационный номер налогоплательщика", + 22: "ИНН", + 23: "2315995868", + } + ) + ], + [], + [None, None, "Бухгалтерский баланс"], + [None, None, "на 30 сентября 2025 г."], + [*at({22: "Форма по ОКУД", 23: "0710001"})], + [ + *at( + { + 3: "Пояснения", + 5: "Наименование показателя", + 13: "Код", + 16: "На 30 сентября 2025 г.", + 21: "На 31 декабря 2024 г.", + } + ) + ], + [*at({5: "Гудвил", 13: "1105", 16: 0, 21: 0})], + [*at({5: "Нематериальные активы", 13: "1110", 16: 586260, 21: 905786})], + [*at({5: "Итого по разделу I", 13: "1100", 16: 586260, 21: 905786})], +] + + +def test_title_after_report_header_gives_one_balance_block(rules: Rules) -> None: + sheet = sheet_from_rows(NPZ_FIRST_SHEET) + + blocks, warnings = find_forms(sheet, rules) + + assert len(blocks) == 1 and warnings == [] + block = blocks[0] + assert block.kind == "BALANCE" and block.form_name == "Бухгалтерский баланс" and block.okud == "0710001" + assert (block.title_row, block.first_row, block.last_row) == (6, 1, sheet.n_rows) + + +def test_two_forms_on_one_sheet_split_at_second_title(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + ["Бухгалтерский баланс"], + ["Наименование", "Код", "На 31.12.2024"], + ["Запасы", "1210", 10], + ["БАЛАНС", "1600", 10], + [], + ["Отчет о финансовых результатах"], + ["Наименование", "Код", "За 2024 г."], + ["Выручка", "2110", 100], + ] + ) + + blocks, _ = find_forms(sheet, rules) + + assert [(block.kind, block.first_row, block.last_row) for block in blocks] == [("BALANCE", 1, 5), ("INCOME", 6, 8)] + + +def test_okud_without_title_and_disclosure(rules: Rules) -> None: + sheet = sheet_from_rows([["Форма по ОКУД", "0710002"], ["Код", "За 2024 г."], ["2110", 1]]) + assert find_forms(sheet, rules)[0][0].kind == "INCOME" + + disclosure = sheet_from_rows([["Пояснения к бухгалтерскому балансу и отчету о финансовых результатах"], ["текст"]]) + assert find_forms(disclosure, rules)[0][0].kind == "DISCLOSURE" + odds = sheet_from_rows([["Отчет о движении денежных средств"], ["Код", "За 2024 г."], ["4110", 1]]) + assert find_forms(odds, rules)[0][0].kind == "DISCLOSURE" + + +def test_title_and_okud_conflict_prefers_okud_with_warning(rules: Rules) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["Форма по ОКУД", "0710002"]]) + + blocks, warnings = find_forms(sheet, rules) + + assert blocks[0].kind == "INCOME" and warnings and "0710002" in warnings[0] + + +def test_classify_statement_continuation_and_other(rules: Rules) -> None: + one_c_page_two = sheet_from_rows( + [ + [*at({17: "Форма 0710001 с.2"})], + ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], + [None, "Уставный капитал", "1310", 250000], + ] + ) + npz_page = sheet_from_rows( + [ + ["в том числе:"], + ["Биологические активы", "11901", 0, 0], + ["Итого по разделу I", "1100", 5, 5], + ["Запасы", "1210", 1, 1], + ] + ) + header_only = sheet_from_rows( + [ + ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], + ["Запасы", None, None, 5], + ] + ) + other = sheet_from_rows([["Справка"], ["Наименование", "Сумма"], ["Аренда", 100]]) + + assert classify_statement_sheet(sheet_from_rows(NPZ_FIRST_SHEET), rules) == "statement" + assert classify_statement_sheet(one_c_page_two, rules) == "continuation" + assert classify_statement_sheet(npz_page, rules) == "continuation" + assert classify_statement_sheet(header_only, rules) == "continuation" + assert classify_statement_sheet(other, rules) == "other" + assert continuation_form(one_c_page_two, rules) == ("BALANCE", "Форма 0710001 с.2") + assert continuation_form(npz_page, rules) == (None, None) + + +def test_sheet_kind_wiring(rules: Rules) -> None: + assert classify_sheet(sheet_from_rows(NPZ_FIRST_SHEET), rules) is SheetKind.STATEMENT + statement_continuation_sheet = sheet_from_rows([["Форма 0710002 с.2"], ["Код", "За 2024 г."], ["2510", 0]]) + assert classify_sheet(statement_continuation_sheet, rules) is SheetKind.STATEMENT_CONTINUATION + osv_sheet = sheet_from_rows([["Оборотно-сальдовая ведомость по счету 60"], ["Бухгалтерский баланс"]]) + assert classify_sheet(osv_sheet, rules) is SheetKind.OSV diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index 0b16162..458903f 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -346,12 +346,6 @@ def test_credit_synonyms(rules: Rules, text: str) -> None: assert not any(p.search(text) for p in rules.headers.debit) -@pytest.mark.parametrize("text", ["Бухгалтерский баланс", "Форма по ОКУД 0710001", "Отчёт о финансовых результатах"]) -def test_statement_markers(rules: Rules, text: str) -> None: - assert any(p.search(text) for p in rules.headers.statement_markers) - assert not any(p.search(text) for p in rules.headers.sheet_markers) - - @pytest.mark.parametrize( ("kind", "text"), [