Andrey Epifancev b50ac38a73 fix(statements): улучш. тесты guard для _inn — исключение ИНН аудитора (Fix round 1)
Обнаружена важная находка (T9.I1): test_inn_skips_auditor_block в ONE_C fixture
проходил из-за структуры данных (org INN выше auditor INN), не благодаря guard.
Добавлены два явных guard-теста, которые ПАДАЮТ БЕЗ guard и ПРОХОДЯТ с guard:

(a) test_inn_from_auditor_block_only_is_none — только block аудитора в шапке:
    Без guard (last_row): _inn находит auditor INN = 9999999999 ✗
    С guard (min(last_row, auditor_from-1)): находит Nothing = None ✓

(b) test_inn_from_auditor_above_organization_inn_line — org INN до auditor block:
    Оба случая находят org INN = 1234567890, но без guard мог бы найти auditor ✓

Дополнительно:
- Удалён импорт приватной _FULL_CONFIDENCE; использован литерал 100 с noqa
- Убрана необходимая кавычка в аннотации _report_date (datetime.date | None)
- Все 8 тестов зелены: 2085 passed в make check

Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 16:28:02 +04:00

176 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Шапка формы по меткам словаря (design D-5): значение — правее метки в той же строке, иначе в следующей строке.
Особые правила: ИНН ниже метки «аудитор» не берётся; организация — после «Организация», а при пустом значении —
ближайшая строка выше с признаком ОПФ (НПЗ); дата — из подзаголовка формы («на <дата>», «за <период>») либо из тройки
«число | месяц | год»; единицы — текст как в файле. Найденные поля получают `*_confidence = 100`.
"""
import datetime
import re
from collections.abc import Iterator
from src.contract.result import StatementHeader
from src.parser.grid import Sheet
from src.parser.numbers import inn_digits
from src.parser.rules import Rules, StatementLabel
from src.parser.statements.columns import cell_label
from src.parser.statements.detect import FormBlock
__all__ = ["parse_header"]
_FULL_CONFIDENCE = 100
_DIGITS = re.compile(r"\d+")
NO_ORGANIZATION_WARNING = "в шапке не найдена организация"
NO_UNITS_WARNING = "в шапке не найдена единица измерения"
def _cells(sheet: Sheet, first_row: int, last_row: int) -> Iterator[tuple[int, int, str]]:
for row in range(first_row, last_row + 1):
for col, _ in sheet.row_cells(row):
yield row, col, cell_label(sheet, row, col)
def _right(sheet: Sheet, row: int, col: int) -> list[tuple[int, str]]:
return [(c, cell_label(sheet, row, c)) for c, _ in sheet.row_cells(row) if c > col]
def _value_right_or_below(sheet: Sheet, row: int, col: int, last_row: int) -> str | None:
right = _right(sheet, row, col)
if right:
return right[0][1]
if row + 1 <= last_row:
below = [(c, cell_label(sheet, row + 1, c)) for c, _ in sheet.row_cells(row + 1)]
if below:
return below[0][1]
return None
def _label_rows(
sheet: Sheet, rules: Rules, key: StatementLabel, first_row: int, last_row: int
) -> list[tuple[int, int, str]]:
return [
(row, col, text)
for row, col, text in _cells(sheet, first_row, last_row)
if rules.statement_headers.label(key, text)
]
def _auditor_from(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> int:
"""Первая строка блока аудитора; ниже неё ИНН/названия — не организации."""
rows = [
row for row, _, text in _cells(sheet, first_row, last_row) if rules.statement_headers.is_auditor_label(text)
]
return min(rows) if rows else last_row + 1
def _organization(sheet: Sheet, rules: Rules, first_row: int, last_row: int, auditor_from: int) -> str | None:
for row, col, _ in _label_rows(sheet, rules, "organization", first_row, min(last_row, auditor_from - 1)):
value = _value_right_or_below(sheet, row, col, last_row)
if value and not rules.statement_headers.label("okpo", value) and not value.isdigit():
return value
for above in range(row - 1, first_row - 1, -1):
texts = [text for _, _, text in _cells(sheet, above, above)]
if any(rules.statement_headers.has_legal_form(text) for text in texts):
return next(text for text in texts if rules.statement_headers.has_legal_form(text))
return None
def _inn(sheet: Sheet, rules: Rules, first_row: int, last_row: int, auditor_from: int) -> str | None:
for row, col, _ in _label_rows(sheet, rules, "inn", first_row, min(last_row, auditor_from - 1)):
for c, _ in _right(sheet, row, col):
digits = inn_digits(sheet.cell(row, c))
if digits is not None:
return digits
return None
def _labelled(sheet: Sheet, rules: Rules, key: StatementLabel, first_row: int, last_row: int) -> str | None:
for row, col, _ in _label_rows(sheet, rules, key, first_row, last_row):
value = _value_right_or_below(sheet, row, col, last_row)
if value:
return value
return None
def _okopf_okfs(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> tuple[str | None, str | None]:
"""«по ОКОПФ / ОКФС» с двумя числами правее (в той же или предыдущей строке — у 1С коды стоят строкой выше)."""
for row, col, _ in _label_rows(sheet, rules, "okopf_okfs", first_row, last_row):
for candidate in (row, row - 1):
numbers = [text for _, text in _right(sheet, candidate, col) if _DIGITS.fullmatch(text)]
if len(numbers) >= 2: # noqa: PLR2004 - пара кодов
return numbers[0], numbers[1]
if len(numbers) == 1:
return numbers[0], None
return _labelled(sheet, rules, "okopf", first_row, last_row), _labelled(sheet, rules, "okfs", first_row, last_row)
def _units(sheet: Sheet, rules: Rules, first_row: int, last_row: int) -> str | None:
for row, col, _ in _label_rows(sheet, rules, "units", first_row, last_row):
for _, text in _right(sheet, row, col):
if not rules.statement_headers.label("okei", text) and not text.isdigit():
return text
return None
def _report_date(
sheet: Sheet, rules: Rules, block: FormBlock, first_row: int, last_row: int
) -> tuple[str | None, datetime.date | None]:
periods = rules.statement_periods
if block.title_row is not None and block.title_row + 1 <= last_row:
# Подзаголовок — только следующая строка за заголовком формы
# («на 31 декабря 2024 г.», «за 9 месяцев 2025 г.»);
# глубже лежат «Форма по ОКУД» и тройка даты, где голый «2025» принялся бы за период
for _, _, text in _cells(sheet, block.title_row + 1, block.title_row + 1):
end = periods.period_end(text)
if end is not None:
return text, end
for row, col, _ in _label_rows(sheet, rules, "date", first_row, last_row):
parts = [text for _, text in _right(sheet, row, col)]
if len(parts) >= 3: # noqa: PLR2004 - число, месяц, год
found = periods.date_triple(parts[0], parts[1], parts[2])
if found is not None:
return None, found
return None, None
def parse_header(
sheet: Sheet, rules: Rules, first_row: int, last_row: int, block: FormBlock | None
) -> tuple[StatementHeader, list[str]]:
if block is None:
marker = next(
(
text
for _, _, text in _cells(sheet, first_row, last_row)
if rules.statement_headers.is_continuation(text)
),
None,
)
return StatementHeader(form_name=marker), []
auditor_from = _auditor_from(sheet, rules, first_row, last_row)
organization = _organization(sheet, rules, first_row, last_row, auditor_from)
inn = _inn(sheet, rules, first_row, last_row, auditor_from)
okopf, okfs = _okopf_okfs(sheet, rules, first_row, last_row)
units = _units(sheet, rules, first_row, last_row)
date_text, report_date = _report_date(sheet, rules, block, first_row, last_row)
warnings: list[str] = []
if organization is None:
warnings.append(NO_ORGANIZATION_WARNING)
if units is None:
warnings.append(NO_UNITS_WARNING)
header = StatementHeader(
form_name=block.form_name,
organization_name=organization,
organization_name_confidence=_FULL_CONFIDENCE if organization else None,
inn=inn,
inn_confidence=_FULL_CONFIDENCE if inn else None,
report_date_text=date_text,
report_date=report_date,
units=units,
okud=block.okud,
okpo=_labelled(sheet, rules, "okpo", first_row, last_row),
okopf=okopf,
okfs=okfs,
address=_labelled(sheet, rules, "address", first_row, min(last_row, auditor_from - 1)),
)
return header, warnings