diff --git a/arm_statements/header.py b/arm_statements/header.py new file mode 100644 index 0000000..a27c8e9 --- /dev/null +++ b/arm_statements/header.py @@ -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) diff --git a/arm_statements/tests/test_header.py b/arm_statements/tests/test_header.py new file mode 100644 index 0000000..071216e --- /dev/null +++ b/arm_statements/tests/test_header.py @@ -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 == "ООО «Пример»"