feat(statements): блоки форм, тип по заголовку и ОКУД, листы-продолжения; classify_sheet без statement_markers

Refs #14
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 15:36:26 +04:00
parent c3df9ff32c
commit b8bf2f29e4
8 changed files with 289 additions and 21 deletions

View File

@ -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`, сообщение — файл, ключ и паттерн

View File

@ -11,13 +11,6 @@
sheet_markers:
- 'оборотно\s*сальдовая\s+ведомость'
# Листы форм ББ/ОФР (Срез 2b): ОСВ-парсер их пропускает с предупреждением
statement_markers:
- 'бухгалтерский\s+баланс'
- '0710001'
- 'отчет\s+о\s+финансовых\s+результатах'
- '0710002'
# Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover:
# отсюда границы слова `\bоборот(ы)?\b`.
groups:

View File

@ -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)]
# --- Заголовок таблицы ---

View File

@ -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]

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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"),
[