From 12f470175455812880de88b0145b04fd2849051d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:17:11 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC,=20=D0=B2=D0=B8=D0=B4=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=82=D0=B0=20=D0=B8=20=D1=88=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B0-=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8F=D1=85=20arm?= =?UTF-8?q?=5Fstatements=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/parser/statements/forms.py | 220 ++++++++++++++++++ api/src/parser/statements/header_cells.py | 160 +++++++++++++ .../unit/parser/statements/test_forms.py | 120 ++++++++++ .../parser/statements/test_header_cells.py | 107 +++++++++ 4 files changed, 607 insertions(+) create mode 100644 api/src/parser/statements/forms.py create mode 100644 api/src/parser/statements/header_cells.py create mode 100644 api/tests/unit/parser/statements/test_forms.py create mode 100644 api/tests/unit/parser/statements/test_header_cells.py diff --git a/api/src/parser/statements/forms.py b/api/src/parser/statements/forms.py new file mode 100644 index 0000000..6b2e59e --- /dev/null +++ b/api/src/parser/statements/forms.py @@ -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 diff --git a/api/src/parser/statements/header_cells.py b/api/src/parser/statements/header_cells.py new file mode 100644 index 0000000..8072a92 --- /dev/null +++ b/api/src/parser/statements/header_cells.py @@ -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) diff --git a/api/tests/unit/parser/statements/test_forms.py b/api/tests/unit/parser/statements/test_forms.py new file mode 100644 index 0000000..80ba1d3 --- /dev/null +++ b/api/tests/unit/parser/statements/test_forms.py @@ -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], "Код") diff --git a/api/tests/unit/parser/statements/test_header_cells.py b/api/tests/unit/parser/statements/test_header_cells.py new file mode 100644 index 0000000..d9c35b8 --- /dev/null +++ b/api/tests/unit/parser/statements/test_header_cells.py @@ -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")