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:
parent
c3df9ff32c
commit
b8bf2f29e4
@ -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`, сообщение — файл, ключ и паттерн
|
||||
|
||||
@ -11,13 +11,6 @@
|
||||
sheet_markers:
|
||||
- 'оборотно\s*сальдовая\s+ведомость'
|
||||
|
||||
# Листы форм ББ/ОФР (Срез 2b): ОСВ-парсер их пропускает с предупреждением
|
||||
statement_markers:
|
||||
- 'бухгалтерский\s+баланс'
|
||||
- '0710001'
|
||||
- 'отчет\s+о\s+финансовых\s+результатах'
|
||||
- '0710002'
|
||||
|
||||
# Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover:
|
||||
# отсюда границы слова `\bоборот(ы)?\b`.
|
||||
groups:
|
||||
|
||||
@ -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)]
|
||||
|
||||
|
||||
# --- Заголовок таблицы ---
|
||||
|
||||
@ -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]
|
||||
|
||||
138
api/src/parser/statements/detect.py
Normal file
138
api/src/parser/statements/detect.py
Normal 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"
|
||||
1
api/tests/fixtures/test_rules_real.py
vendored
1
api/tests/fixtures/test_rules_real.py
vendored
@ -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)
|
||||
|
||||
137
api/tests/unit/parser/statements/test_detect.py
Normal file
137
api/tests/unit/parser/statements/test_detect.py
Normal 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
|
||||
@ -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"),
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user