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