feat(statements): блоки форм, вид листа и шапка-ячейки на словарях arm_statements (#54)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:17:11 +04:00
parent ce8372d754
commit 12f4701754
4 changed files with 607 additions and 0 deletions

View File

@ -0,0 +1,220 @@
"""Блоки форм ББ/ОФР на листе и вид листа на словарях `arm_statements` (план 4, задача 4; design D-2, D-3).
Блок начинается с заголовка формы или кода ОКУД; новый блок открывается, только если между заголовками уже были
строки с кодами (иначе «Бухгалтерский баланс» и «0710001» на соседних строках — один блок). Титул «Бухгалтерская
отчетность» типа не задаёт. Лист без заголовка, но с маркером «Форма 0710001 с.2», подписью «Код» с периодами или
столбцом кодов — продолжение. Тексты под шаблоны словаря нормализует пакет: метки — `normalize_label`, подпись «Код»
и относительные периоды — `normalize_caption`.
"""
import re
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Literal
from arm_statements import (
Cell,
DocumentType,
amount_columns,
code_column,
is_continuation,
normalize_caption,
normalize_label,
parse_code,
parse_date,
type_hint,
)
from arm_statements import Rules as StatementRules
from src.parser.grid import Sheet
from src.parser.statements.cells import excel_cell, grid_rows
__all__ = [
"FormBlock",
"StatementRules",
"StatementSheetKind",
"caption_matches",
"classify_statement_sheet",
"code_rows",
"codes_of",
"continuation_marker",
"find_blocks",
"is_weak_continuation",
"matches",
"row_cells",
"sheet_amount_columns",
"sheet_code_column",
"sheet_grid",
]
StatementSheetKind = Literal["statement", "continuation", "other"]
_WEAK_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
@dataclass(frozen=True)
class FormBlock:
"""Блок формы: `title`/`okud` — `(row, col)` ячеек-признаков."""
type_hint: DocumentType
title: tuple[int, int] | None
okud: tuple[int, int] | None
first_row: int
last_row: int
def matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool:
"""`search` по тексту, нормализованному как метки шапки (`normalize_label`)."""
label = normalize_label(text)
return any(pattern.search(label) for pattern in patterns)
def caption_matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool:
"""`search` по тексту, нормализованному как подписи колонок (`normalize_caption`)."""
caption = normalize_caption(text)
return any(pattern.search(caption) for pattern in patterns)
def row_cells(sheet: Sheet, row: int) -> list[tuple[int, Cell]]:
"""Непустые ячейки строки `(col, cell)` по возрастанию col; покрытые объединением позиции пропускаются."""
found: list[tuple[int, Cell]] = []
for col, _ in sheet.row_cells(row):
cell = excel_cell(sheet, row, col, set())
if cell is not None:
found.append((col, cell))
return found
def sheet_grid(sheet: Sheet, first_row: int, last_row: int) -> list[tuple[Cell | None, ...]]:
"""Сетка строк `first_row..last_row` на всю ширину листа; неразобранные форматы здесь не собираются."""
return grid_rows(sheet, first_row, last_row, sheet.n_cols, set())
def sheet_code_column(
sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, relaxed: DocumentType | None = None
) -> int | None:
"""Столбец кодов (1-based) по правилу шага 2 пакета; `relaxed` — ослабленный поиск в отрезке формы."""
found = code_column(sheet_grid(sheet, first_row, last_row), rules, relaxed=relaxed)
return None if found is None else found + 1
def code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]:
"""Строки, где ячейка столбца `col` — код строки формы."""
found: list[int] = []
for row in range(first_row, last_row + 1):
cell = excel_cell(sheet, row, col, set())
if cell is not None and parse_code(cell.text, rules).code is not None:
found.append(row)
return found
def codes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]:
found: list[str] = []
for row in range(first_row, last_row + 1):
cell = excel_cell(sheet, row, col, set())
code = None if cell is None else parse_code(cell.text, rules).code
if code is not None:
found.append(code)
return found
def sheet_amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int:
"""Число столбцов правее столбца кодов, где в строке с кодом есть число (как колонки сегмента без заголовка)."""
return len(amount_columns(sheet_grid(sheet, first_row, last_row), code_col - 1, rules))
@dataclass(frozen=True)
class _Candidate:
row: int
title: tuple[int, int, str] | None
okud: tuple[int, int, str] | None
def _candidates(sheet: Sheet, rules: StatementRules) -> list[_Candidate]:
found: list[_Candidate] = []
for row in range(1, sheet.n_rows + 1):
title: tuple[int, int, str] | None = None
okud: tuple[int, int, str] | None = None
for col, cell in row_cells(sheet, row):
if title is None and type_hint(None, cell.text, rules) is not None:
title = (row, col, cell.text)
# маркер продолжения («Форма 0710001 с.2») — не заголовок нового блока по ОКУД
if okud is None and not is_continuation(cell.text, rules) and type_hint(cell.text, None, rules) is not None:
okud = (row, col, cell.text)
if title is not None or okud is not None:
found.append(_Candidate(row, title, okud))
return found
def find_blocks(sheet: Sheet, rules: StatementRules) -> list[FormBlock]:
"""Блоки форм сверху вниз; тип блока — по ОКУД, иначе по заголовку."""
candidates = _candidates(sheet, rules)
if not candidates:
return []
col = sheet_code_column(sheet, rules, 1, sheet.n_rows)
with_codes = code_rows(sheet, rules, col, 1, sheet.n_rows) if col is not None else []
groups: list[list[_Candidate]] = []
for candidate in candidates:
if groups and not any(groups[-1][0].row < row < candidate.row for row in with_codes):
groups[-1].append(candidate)
else:
groups.append([candidate])
blocks: list[FormBlock] = []
for index, group in enumerate(groups):
title = next((item.title for item in group if item.title is not None), None)
okud = next((item.okud for item in group if item.okud is not None), None)
kind = type_hint(None if okud is None else okud[2], None if title is None else title[2], rules)
if kind is None: # группа всегда несёт заголовок или ОКУД с типом; защита от пустой группы
continue
is_last = index == len(groups) - 1
blocks.append(
FormBlock(
type_hint=kind,
title=None if title is None else (title[0], title[1]),
okud=None if okud is None else (okud[0], okud[1]),
first_row=1 if index == 0 else group[0].row,
last_row=sheet.n_rows if is_last else groups[index + 1][0].row - 1,
)
)
return blocks
def continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None:
"""Первая ячейка-маркер продолжения («Форма 0710001 с.2»); маркер с ОКУД вне словаря — тоже маркер."""
for row in range(1, sheet.n_rows + 1):
for _, cell in row_cells(sheet, row):
if is_continuation(cell.text, rules):
return cell
return None
def _has_table_header(sheet: Sheet, rules: StatementRules) -> bool:
relative = [pattern for _, pattern in rules.periods.relative]
for row in range(1, sheet.n_rows + 1):
cells = row_cells(sheet, row)
caption = next((col for col, cell in cells if caption_matches([rules.headers.code_caption], cell.text)), None)
if caption is None:
continue
for col, cell in cells:
if col > caption and (parse_date(cell.text, rules) is not None or caption_matches(relative, cell.text)):
return True
return False
def classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind:
if find_blocks(sheet, rules):
return "statement"
if (
continuation_marker(sheet, rules) is not None
or _has_table_header(sheet, rules)
or sheet_code_column(sheet, rules, 1, sheet.n_rows) is not None
):
return "continuation"
return "other"
def is_weak_continuation(sheet: Sheet, rules: StatementRules, form_type: DocumentType) -> bool:
"""Лист с кодами отрезка формы, но меньше `min_codes` (одна строка-продолжение). Годится лишь после листа формы:
решает вызывающий; первая страница формы с таким числом кодов формой не считается."""
if form_type not in _WEAK_TYPES:
return False
return sheet_code_column(sheet, rules, 1, sheet.n_rows, relaxed=form_type) is not None

View File

@ -0,0 +1,160 @@
"""Шапка формы по меткам словаря пакета: ячейки-значения `Cell` для `arm_statements.parse_header` (план 4, задача 4).
Значение — правее метки в той же строке, иначе в следующей. Особые правила: ИНН ниже метки «аудитор» не берётся;
организация — после «Организация», а при пустом значении — ближайшая строка выше с признаком ОПФ (НПЗ); дата — из
подзаголовка формы либо из тройки «число | месяц | год»; единицы — ячейка как в файле. Разбор значений — в пакете.
"""
import re
from arm_statements import Cell, Evidence, HeaderCells, parse_date
from arm_statements import Rules as StatementRules
from src.parser.grid import Sheet
from src.parser.statements.cells import excel_cell
from src.parser.statements.forms import FormBlock, matches
__all__ = ["header_cells"]
_INN_LENGTHS = (10, 12)
_NOT_DIGIT = re.compile(r"\D")
_DIGITS = re.compile(r"\d+")
_FULL_CONFIDENCE = 100.0
_TRIPLE = 3 # число, месяц, год
_Line = list[tuple[int, Cell]]
class _Header:
def __init__(self, sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, formats: set[str]) -> None:
self.rules = rules
self.first_row = first_row
self.last_row = last_row
self.lines: dict[int, _Line] = {}
for row in range(first_row, last_row + 1):
line: _Line = []
for col, _ in sheet.row_cells(row):
cell = excel_cell(sheet, row, col, formats)
if cell is not None:
line.append((col, cell))
self.lines[row] = line
self.auditor_from = self._auditor_from()
def _auditor_from(self) -> int:
"""Первая строка блока аудитора; ниже неё ИНН/названия — не организации."""
patterns = self.rules.headers.auditor
rows = [row for row, line in self.lines.items() if any(matches(patterns, cell.text) for _, cell in line)]
return min(rows) if rows else self.last_row + 1
def labels(self, key: str, last_row: int | None = None) -> list[tuple[int, int]]:
patterns = self.rules.headers.labels.get(key, ())
stop = self.last_row if last_row is None else last_row
return [
(row, col)
for row, line in self.lines.items()
if row <= stop
for col, cell in line
if matches(patterns, cell.text)
]
def right(self, row: int, col: int) -> _Line:
return [(c, cell) for c, cell in self.lines.get(row, []) if c > col]
def right_or_below(self, row: int, col: int) -> Cell | None:
right = self.right(row, col)
if right:
return right[0][1]
below = self.lines.get(row + 1, [])
return below[0][1] if below else None
def labelled(self, key: str, last_row: int | None = None) -> Cell | None:
for row, col in self.labels(key, last_row):
value = self.right_or_below(row, col)
if value is not None:
return value
return None
def organization(self) -> Cell | None:
legal_form = self.rules.headers.legal_form
okpo = self.rules.headers.labels.get("okpo", ())
for row, col in self.labels("organization", self.auditor_from - 1):
value = self.right_or_below(row, col)
if value is not None and not matches(okpo, value.text) and not value.text.isdigit():
return value
for above in range(row - 1, self.first_row - 1, -1):
found = next((cell for _, cell in self.lines[above] if matches(legal_form, cell.text)), None)
if found is not None:
return found
return None
def inn(self) -> Cell | None:
for row, col in self.labels("inn", self.auditor_from - 1):
for _, cell in self.right(row, col):
if len(_NOT_DIGIT.sub("", cell.text)) in _INN_LENGTHS:
return cell
return None
def okopf_okfs(self) -> tuple[Cell | None, Cell | None]:
"""«по ОКОПФ / ОКФС» с двумя числами правее — в той же строке или строкой выше (у 1С коды стоят выше)."""
for row, col in self.labels("okopf_okfs"):
for candidate in (row, row - 1):
numbers = [cell for _, cell in self.right(candidate, col) if _DIGITS.fullmatch(cell.text)]
if numbers:
return numbers[0], numbers[1] if len(numbers) > 1 else None
return self.labelled("okopf"), self.labelled("okfs")
def units(self) -> Cell | None:
okei = self.rules.headers.labels.get("okei", ())
for row, col in self.labels("units"):
for _, cell in self.right(row, col):
if not matches(okei, cell.text) and not cell.text.isdigit():
return cell
return None
def report_date(self, title_row: int | None) -> Cell | None:
if title_row is not None:
# Подзаголовок — только строка сразу под заголовком формы («на 31 декабря 2024 г.»); глубже лежат
# «Форма по ОКУД» и тройка даты; голое число («2025» из тройки) подзаголовком не считается
for _, cell in self.lines.get(title_row + 1, []):
if not cell.text.isdigit() and parse_date(cell.text, self.rules) is not None:
return cell
for row, col in self.labels("date"):
parts = self.right(row, col)
if len(parts) >= _TRIPLE:
found = self._triple(parts[0][1], parts[1][1], parts[2][1])
if found is not None:
return found
return None
def _triple(self, day: Cell, month: Cell, year: Cell) -> Cell | None:
if not day.text.isdigit() or not year.text.isdigit():
return None
if month.text.isdigit():
text = f"{day.text.zfill(2)}.{month.text.zfill(2)}.{year.text}"
else:
text = f"{day.text} {month.text} {year.text}"
if parse_date(text, self.rules) is None:
return None
return Cell(text=text, value=None, confidence=_FULL_CONFIDENCE, ref=day.ref)
def header_cells(
sheet: Sheet, rules: StatementRules, block: FormBlock, last_row: int, unparsed_formats: set[str]
) -> tuple[HeaderCells, Evidence]:
"""Ячейки шапки блока в строках `block.first_row..last_row` и признаки формы (`Evidence`)."""
header = _Header(sheet, rules, block.first_row, last_row, unparsed_formats)
okopf, okfs = header.okopf_okfs()
title_cell = None if block.title is None else excel_cell(sheet, *block.title, unparsed_formats)
okud_cell = None if block.okud is None else excel_cell(sheet, *block.okud, unparsed_formats)
cells = HeaderCells(
organization_name=header.organization(),
inn=header.inn(),
report_date=header.report_date(None if block.title is None else block.title[0]),
units=header.units(),
okud=okud_cell,
okpo=header.labelled("okpo"),
okopf=okopf,
okfs=okfs,
okei=header.labelled("okei"),
address=header.labelled("address", min(last_row, header.auditor_from - 1)),
)
return cells, Evidence(okud=okud_cell, title=title_cell)

View File

@ -0,0 +1,120 @@
"""Блоки форм, вид листа и столбцы на словарях `arm_statements` (план 4, задача 4)."""
import pytest
from gridbuilder import at, sheet_from_rows
from arm_statements import DocumentType, continuation_type, default_rules
from src.parser.statements.forms import (
StatementRules,
caption_matches,
classify_statement_sheet,
codes_of,
continuation_marker,
find_blocks,
is_weak_continuation,
matches,
sheet_amount_columns,
sheet_code_column,
)
CODE_COL = 3
@pytest.fixture(scope="module")
def rules() -> StatementRules:
return default_rules()
def test_title_and_okud_without_codes_between_form_one_block(rules: StatementRules) -> None:
sheet = sheet_from_rows(
[
["Бухгалтерская отчетность"],
[],
[None, "Бухгалтерский баланс"],
[],
[*at({3: "Форма по ОКУД", 5: "0710001"})],
[*at({1: "Актив", 3: "Код", 4: "На 30.09.2025"})],
[*at({1: "Основные средства", 3: "1150", 4: 10})],
]
)
[block] = find_blocks(sheet, rules)
assert (block.type_hint, block.title, block.okud, block.first_row, block.last_row) == (
DocumentType.BALANCE,
(3, 2),
(5, 5),
1,
7,
)
def test_second_title_after_codes_opens_new_block(rules: StatementRules) -> None:
balance = [at({1: name, 3: code, 4: 10}) for name, code in (("а", 1110), ("б", 1150), ("в", 1170), ("г", 1600))]
income = [at({1: name, 3: code, 4: 10}) for name, code in (("д", 2110), ("е", 2120), ("ж", 2400))]
sheet = sheet_from_rows(
[["Бухгалтерский баланс"], *balance, ["Отчет о финансовых результатах"], *income],
)
blocks = find_blocks(sheet, rules)
assert [(b.type_hint, b.first_row, b.last_row) for b in blocks] == [
(DocumentType.BALANCE, 1, 5),
(DocumentType.INCOME, 6, 9),
]
def test_okud_wins_over_title(rules: StatementRules) -> None:
blocks = find_blocks(sheet_from_rows([["Бухгалтерский баланс"], ["по ОКУД", "0710002"]]), rules)
assert [block.type_hint for block in blocks] == [DocumentType.INCOME]
def test_report_title_alone_is_not_a_form(rules: StatementRules) -> None:
assert find_blocks(sheet_from_rows([["Бухгалтерская отчетность"]]), rules) == []
def test_continuation_marker_is_not_a_block(rules: StatementRules) -> None:
sheet = sheet_from_rows([["Форма 0710001 с.2"], ["Пассив", "Код", "На 30.09.2025"], ["Капитал", "1310", 10]])
assert find_blocks(sheet, rules) == []
assert classify_statement_sheet(sheet, rules) == "continuation"
def test_code_column_ignores_title_codes_block(rules: StatementRules) -> None:
titles = ["Коды", "0710001", 30, "09", 2025, 12345678, 7700000000, 12267, 16, 384]
codes = [1110, 1150, 1170, 1100, 1210, 1230]
rows = [at({6: value}) for value in titles]
rows += [at({3: code, 4: 10}) for code in codes]
sheet = sheet_from_rows(rows)
assert sheet_code_column(sheet, rules, 1, sheet.n_rows) == CODE_COL
assert codes_of(sheet, rules, CODE_COL, 1, sheet.n_rows) == [str(code) for code in codes]
def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None:
rows = [at({1: "Актив", 3: "Код", 4: "На 30.09.2025", 7: "Изменение"})]
rows += [at({1: "Статья", 3: code, 4: 1, 5: 2, 6: 3, 7: "рост"}) for code in (1110, 1150, 1170, 1100)]
sheet = sheet_from_rows(rows)
assert sheet_amount_columns(sheet, rules, CODE_COL, 1, sheet.n_rows) == CODE_COL
def test_weak_and_other_sheets(rules: StatementRules) -> None:
weak = sheet_from_rows([["Чистая прибыль", "2400", 10, 8]])
assert classify_statement_sheet(weak, rules) == "other"
assert is_weak_continuation(weak, rules, DocumentType.INCOME)
assert not is_weak_continuation(weak, rules, DocumentType.BALANCE)
notes = sheet_from_rows([["Примечания"], ["Текст пояснения к отчетности"]])
assert classify_statement_sheet(notes, rules) == "other"
assert not is_weak_continuation(notes, rules, DocumentType.INCOME)
def test_marker_with_unknown_okud_is_continuation(rules: StatementRules) -> None:
rows = [["Форма 0710000 с.2"]] + [at({1: "Статья", 3: code, 4: 10}) for code in (1110, 1150, 1170)]
sheet = sheet_from_rows(rows)
assert find_blocks(sheet, rules) == []
assert classify_statement_sheet(sheet, rules) == "continuation"
marker = continuation_marker(sheet, rules)
assert marker is not None
assert marker.text == "Форма 0710000 с.2"
assert continuation_type(marker.text, rules) is None
def test_labels_with_punctuation_match(rules: StatementRules) -> None:
assert matches(rules.headers.labels["address"], "Местонахождение (адрес)")
assert matches(rules.headers.labels["okopf_okfs"], "по ОКОПФ / ОКФС")
assert matches(rules.headers.legal_form, "АО «Пример»")
assert caption_matches([rules.headers.code_caption], "Код")

View File

@ -0,0 +1,107 @@
"""Шапка-ячейки формы на словарях `arm_statements` (план 4, задача 4)."""
import pytest
from gridbuilder import at, sheet_from_rows
from arm_statements import default_rules, parse_header
from src.parser.statements.forms import StatementRules, find_blocks
from src.parser.statements.header_cells import header_cells
@pytest.fixture(scope="module")
def rules() -> StatementRules:
return default_rules()
def _cells(rows: list[list[object]], rules: StatementRules, name: str = "Баланс"): # noqa: ANN202
sheet = sheet_from_rows(rows, name=name)
[block] = find_blocks(sheet, rules)
return header_cells(sheet, rules, block, block.last_row, set())
def test_header_cells_of_1c_block(rules: StatementRules) -> None:
cells, evidence = _cells(
[
[*at({3: "Бухгалтерский баланс"})],
[*at({3: "на 30 сентября 2025 г."})],
[*at({5: "Форма по ОКУД", 6: "0710001"})],
[*at({3: "Организация", 4: "АО «Пример»", 5: "по ОКПО", 6: "12345678"})],
[*at({3: "ИНН", 4: "7700000000"})],
[*at({3: "Организационно-правовая форма", 5: "по ОКОПФ / ОКФС", 6: "12267", 7: "16"})],
[*at({3: "Единица измерения:", 4: "в тыс. рублей", 5: "по ОКЕИ", 6: "384"})],
],
rules,
)
assert cells.organization_name is not None
assert cells.organization_name.text == "АО «Пример»"
assert cells.inn is not None
assert cells.inn.ref == "Баланс!D5"
assert cells.okopf is not None
assert cells.okfs is not None
assert (cells.okopf.text, cells.okfs.text) == ("12267", "16")
assert cells.report_date is not None
assert cells.report_date.text == "на 30 сентября 2025 г."
assert evidence.okud is not None
assert evidence.title is not None
assert (evidence.okud.text, evidence.title.text) == ("0710001", "Бухгалтерский баланс")
head = parse_header(cells, rules)
assert head.units.value == "тыс. руб."
assert str(head.report_date.value) == "2025-09-30"
def test_auditor_inn_is_not_taken(rules: StatementRules) -> None:
cells, _ = _cells(
[
["Бухгалтерский баланс"],
[*at({1: "Организация", 2: "ООО «Пример»"})],
[*at({1: "ИНН", 2: "7700000000"})],
[*at({1: "Аудитор"})],
[*at({1: "ИНН", 2: "7811111111"})],
],
rules,
)
assert cells.inn is not None
assert cells.inn.text == "7700000000"
def test_auditor_inn_alone_gives_none(rules: StatementRules) -> None:
cells, _ = _cells([["Бухгалтерский баланс"], ["Аудитор"], ["ИНН", "7811111111"]], rules)
assert cells.inn is None
def test_organization_by_legal_form_above_label(rules: StatementRules) -> None:
cells, _ = _cells(
[
["Бухгалтерский баланс"],
['ООО "НПЗ"'],
[*at({1: "Организация", 3: "по ОКПО", 4: "16098811"})],
],
rules,
)
assert cells.organization_name is not None
assert cells.organization_name.text == 'ООО "НПЗ"'
def test_report_date_from_triple(rules: StatementRules) -> None:
numeric, _ = _cells(
[["Бухгалтерский баланс"], [*at({1: "Дата (число, месяц, год)", 2: "30", 3: "09", 4: "2025"})]], rules
)
assert numeric.report_date is not None
assert numeric.report_date.text == "30.09.2025"
assert numeric.report_date.ref == "Баланс!B2"
worded, _ = _cells(
[["Бухгалтерский баланс"], [*at({1: "Дата (число, месяц, год)", 2: "30", 3: "сентября", 4: "2025"})]], rules
)
assert worded.report_date is not None
assert worded.report_date.text == "30 сентября 2025"
for cells in (numeric, worded):
assert str(parse_header(cells, rules).report_date.value) == "2025-09-30"
def test_units_only_by_okei(rules: StatementRules) -> None:
cells, _ = _cells([["Бухгалтерский баланс"], [*at({1: "по ОКЕИ", 2: "384"})]], rules)
assert cells.units is None
assert cells.okei is not None
assert cells.okei.text == "384"
head = parse_header(cells, rules)
assert (head.units.value, head.okei.value) == ("тыс. руб.", "384")