Обнаружена важная находка (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
176 lines
8.1 KiB
Python
176 lines
8.1 KiB
Python
"""Шапка формы по меткам словаря (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
|