feat: разбор шапки формы (ИНН без двойников, единицы по ОКЕИ) и тип-подсказка по ОКУД и заголовку

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:30:00 +04:00
parent 5f259be9ac
commit f73f386378
2 changed files with 181 additions and 0 deletions

125
arm_statements/header.py Normal file
View File

@ -0,0 +1,125 @@
"""Шапка кандидата формы и тип-подсказка по ОКУД и заголовку (§12)."""
import re
from datetime import date
from arm_statements.dates import parse_date
from arm_statements.loader import Rules
from arm_statements.model import Cell, DateField, DocumentType, HeaderCells, HeaderField, StatementHeader
from arm_statements.text import fix_lookalikes, normalize_caption, normalize_label
_INN_LENGTHS = (10, 12)
_NOT_DIGIT = re.compile(r"\D")
def _field(cell: Cell | None) -> HeaderField:
if cell is None:
return HeaderField(None, None, None)
return HeaderField(cell.text, cell.confidence, None)
def _stripped(cell: Cell | None) -> HeaderField:
if cell is None:
return HeaderField(None, None, None)
return HeaderField(cell.text, cell.confidence, cell.text.strip() or None)
def _digits(cell: Cell | None) -> HeaderField:
"""Только цифры текста (пусто — None); двойники не заменяются."""
if cell is None:
return HeaderField(None, None, None)
return HeaderField(cell.text, cell.confidence, _NOT_DIGIT.sub("", cell.text) or None)
def _inn(cell: Cell | None) -> HeaderField:
base = _digits(cell)
if base.value is not None and len(base.value) not in _INN_LENGTHS:
return HeaderField(base.text, base.confidence, None)
return base
def _report_date(cell: Cell | None, rules: Rules) -> DateField:
if cell is None:
return DateField(None, None, None)
value: date | None
if isinstance(cell.value, date):
value = cell.value
else:
value = parse_date(fix_lookalikes(cell.text, rules.lookalikes), rules)
return DateField(cell.text, cell.confidence, value)
def _okud(cell: Cell | None, rules: Rules) -> HeaderField:
if cell is None:
return HeaderField(None, None, None)
match = rules.headers.okud_code.search(normalize_label(cell.text))
return HeaderField(cell.text, cell.confidence, match.group(0) if match else None)
def _units(cell: Cell | None, okei: HeaderField, rules: Rules) -> HeaderField:
base = _field(cell)
if cell is not None:
caption = normalize_caption(cell.text)
for pattern, canonical in rules.units.spellings:
if pattern.search(caption):
return HeaderField(base.text, base.confidence, canonical)
if okei.value is not None:
return HeaderField(base.text, base.confidence, rules.units.okei.get(okei.value))
return base
def parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader:
"""Шапка кандидата: текст и уверенность — из ячейки, значение — по правилам поля."""
okei = _digits(cells.okei)
return StatementHeader(
organization_name=_stripped(cells.organization_name),
inn=_inn(cells.inn),
report_date=_report_date(cells.report_date, rules),
units=_units(cells.units, okei, rules),
okud=_okud(cells.okud, rules),
okpo=_digits(cells.okpo),
okopf=_digits(cells.okopf),
okfs=_digits(cells.okfs),
okei=okei,
address=_stripped(cells.address),
needs_review_fields=(),
)
def _type_by_okud(text: str | None, rules: Rules) -> DocumentType | None:
if not text:
return None
label = normalize_label(text)
for doc_type, patterns in rules.headers.okud.items():
if any(pattern.search(label) for pattern in patterns):
return doc_type
return None
def type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None:
"""Тип по ОКУД, иначе по заголовку формы; заголовки колонок не участвуют."""
by_okud = _type_by_okud(okud_text, rules)
if by_okud is not None:
return by_okud
if not title_text:
return None
label = normalize_label(title_text)
for doc_type, patterns in rules.headers.form_titles.items():
if any(pattern.search(label) for pattern in patterns):
return doc_type
return None
def is_continuation(text: str | None, rules: Rules) -> bool:
"""Маркер страницы-продолжения «Форма 0710001 с.2» (ОКУД из словаря не обязателен)."""
if not text:
return False
label = normalize_label(text)
return any(pattern.search(label) for pattern in rules.headers.continuation)
def continuation_type(text: str | None, rules: Rules) -> DocumentType | None:
"""Тип по ОКУД из маркера продолжения; не маркер или неизвестный ОКУД — None."""
if not is_continuation(text, rules):
return None
return _type_by_okud(text, rules)

View File

@ -0,0 +1,56 @@
from datetime import date
from arm_statements.header import continuation_type, is_continuation, parse_header, type_hint
from arm_statements.loader import Rules
from arm_statements.model import DocumentType, HeaderCells
from arm_statements.tests.builders import hdr
def test_inn_digits_only_without_lookalikes(rules: Rules) -> None:
head = parse_header(hdr(inn="|7700000000|"), rules)
assert head.inn.value == "7700000000"
dirty = parse_header(hdr(inn=("3|1 21 2|", 80.0)), rules)
assert dirty.inn.value is None
assert (dirty.inn.text, dirty.inn.confidence) == ("3|1 21 2|", 80.0)
def test_units_by_text_then_okei(rules: Rules) -> None:
assert parse_header(hdr(units="в тыс. рублей"), rules).units.value == "тыс. руб."
by_okei = parse_header(hdr(okei="384"), rules)
assert (by_okei.units.value, by_okei.okei.value, by_okei.units.text) == ("тыс. руб.", "384", None)
assert parse_header(hdr(units="руб."), rules).units.value == "руб."
def test_report_date_and_codes(rules: Rules) -> None:
head = parse_header(hdr(report_date="30.09.2О25", okud="Форма по ОКУД 0710001", okopf="12267"), rules)
assert head.report_date.value == date(2025, 9, 30)
assert (head.okud.value, head.okopf.value) == ("0710001", "12267")
def test_type_hint_okud_wins(rules: Rules) -> None:
assert type_hint("0710002", "Бухгалтерский баланс", rules) is DocumentType.INCOME
assert type_hint("Форма по ОКУД 0710001", None, rules) is DocumentType.BALANCE
assert type_hint("0710005", None, rules) is DocumentType.DISCLOSURE
assert type_hint(None, "Пояснения к бухгалтерскому балансу", rules) is DocumentType.DISCLOSURE
assert type_hint(None, "Отчет о прибылях и убытках", rules) is DocumentType.INCOME
assert type_hint(None, "Бухгалтерская отчетность", rules) is None
def test_continuation_marker(rules: Rules) -> None:
assert continuation_type("Форма 0710002 с.2", rules) is DocumentType.INCOME
assert continuation_type("Бухгалтерский баланс", rules) is None
assert is_continuation("Форма 0710000 с. 3", rules) and continuation_type("Форма 0710000 с. 3", rules) is None
assert not is_continuation("Бухгалтерский баланс", rules) and not is_continuation(None, rules)
def test_type_hint_uses_label_normalization(rules: Rules) -> None:
assert type_hint(None, "ОТЧЁТ О ФИНАНСОВЫХ\nРЕЗУЛЬТАТАХ", rules) is DocumentType.INCOME
def test_absent_cells_and_empty_values(rules: Rules) -> None:
head = parse_header(HeaderCells(), rules)
assert (head.inn.text, head.inn.confidence, head.inn.value) == (None, None, None)
assert head.report_date.value is None and head.needs_review_fields == ()
blank = parse_header(hdr(okpo=" ", organization_name=" ООО «Пример» "), rules)
assert blank.okpo.value is None
assert blank.organization_name.value == "ООО «Пример»"