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:
parent
ce8372d754
commit
12f4701754
220
api/src/parser/statements/forms.py
Normal file
220
api/src/parser/statements/forms.py
Normal 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
|
||||||
160
api/src/parser/statements/header_cells.py
Normal file
160
api/src/parser/statements/header_cells.py
Normal 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)
|
||||||
120
api/tests/unit/parser/statements/test_forms.py
Normal file
120
api/tests/unit/parser/statements/test_forms.py
Normal 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], "Код")
|
||||||
107
api/tests/unit/parser/statements/test_header_cells.py
Normal file
107
api/tests/unit/parser/statements/test_header_cells.py
Normal 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")
|
||||||
Loading…
x
Reference in New Issue
Block a user