diff --git a/api/src/parser/statements/header.py b/api/src/parser/statements/header.py new file mode 100644 index 0000000..6a061c2 --- /dev/null +++ b/api/src/parser/statements/header.py @@ -0,0 +1,175 @@ +"""Шапка формы по меткам словаря (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 diff --git a/api/tests/unit/parser/statements/test_header.py b/api/tests/unit/parser/statements/test_header.py new file mode 100644 index 0000000..1641a00 --- /dev/null +++ b/api/tests/unit/parser/statements/test_header.py @@ -0,0 +1,165 @@ +"""Шапка формы по меткам (design D-5; spec «Шапка документа»).""" + +import datetime + +import pytest +from gridbuilder import at, sheet_from_rows + +from src.parser.rules import Rules, load_rules +from src.parser.statements.detect import find_forms +from src.parser.statements.header import _FULL_CONFIDENCE, parse_header + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +ONE_C = [ + [*at({9: "Бухгалтерский баланс"})], + [*at({9: "на 31 декабря 2024 г."})], + [*at({34: "Коды"})], + [*at({33: "Форма по ОКУД", 34: "0710001"})], + [*at({33: "Дата (число, месяц, год)", 34: "31", 37: "12", 40: "2024"})], + [*at({2: "Организация", 4: 'Акционерное общество "Унагранде Компани"', 33: "по ОКПО", 34: "47888105"})], + [*at({2: "Идентификационный номер налогоплательщика", 33: "ИНН", 34: "3249004697"})], + [ + *at( + { + 2: "Вид экономической\nдеятельности", + 7: "Производство сыра", + 30: "по\nОКВЭД 2", + 34: "10.51.3", + } + ) + ], + [*at({2: "Организационно-правовая форма / форма собственности", 34: "12267", 39: "16"})], + [*at({2: "Непубличные акционерные общества", 14: " /", 15: "Частная собственность", 33: "по ОКОПФ / ОКФС"})], + [*at({2: "Единица измерения:", 7: "в тыс. рублей", 33: "по ОКЕИ", 34: "384"})], + [*at({2: "Местонахождение (адрес)"})], + [*at({2: "242440, Брянская обл, г Севск, ул Ленина, д. 130"})], + [*at({2: "Наименование аудиторской организации"})], + [*at({2: 'АО "ЮНИКОН"'})], + [*at({2: "Идентификационный номер налогоплательщика аудиторской организации", 33: "ИНН", 34: "7716021332"})], + [*at({3: "Пояснения", 5: "Наименование показателя", 22: "Код", 25: "На 31 декабря 2024 г."})], + [*at({5: "Нематериальные активы", 22: "1110", 25: 1})], +] + + +def _block(sheet, rules): # type: ignore[no-untyped-def] + return find_forms(sheet, rules)[0][0] + + +def test_one_c_header(rules: Rules) -> None: + sheet = sheet_from_rows(ONE_C) + + header, warnings = parse_header(sheet, rules, 1, 16, _block(sheet, rules)) + + assert header.form_name == "Бухгалтерский баланс" and header.okud == "0710001" + assert header.organization_name == 'Акционерное общество "Унагранде Компани"' + assert header.inn == "3249004697" # Review Focus 2: не ИНН аудитора + assert header.okpo == "47888105" + assert header.okopf == "12267" and header.okfs == "16" + assert header.units == "в тыс. рублей" + assert header.report_date_text == "на 31 декабря 2024 г." and header.report_date == datetime.date(2024, 12, 31) + assert header.address == "242440, Брянская обл, г Севск, ул Ленина, д. 130" + assert header.organization_name_confidence == _FULL_CONFIDENCE and header.inn_confidence == _FULL_CONFIDENCE + assert warnings == [] + + +def test_organization_above_label_and_income_period(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({3: "Отчет о финансовых результатах"})], + [*at({3: "за январь - сентябрь 2025 г."})], + [*at({13: "Форма по ОКУД", 14: "0710002"})], + [*at({7: "Отчетная дата\n(число, месяц, год)", 14: "30", 15: "09", 16: "2025"})], + [*at({7: 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"'})], + [*at({3: "Организация:", 13: "по ОКПО", 14: "16098811"})], + [*at({3: "Единица измерения", 8: "тыс. руб.", 13: "по ОКЕИ", 14: "384"})], + [ + *at( + { + 3: "Пояснения", + 5: "Наименование показателя", + 10: "Код", + 11: "За январь - сентябрь 2025 г.", + } + ) + ], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 7, _block(sheet, rules)) + + assert header.organization_name == 'ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"' + assert header.inn is None + assert header.okpo == "16098811" + assert header.units == "тыс. руб." + assert header.report_date == datetime.date(2025, 9, 30) + assert header.report_date_text == "за январь - сентябрь 2025 г." + + +def test_date_from_numeric_triple_and_name_below_label(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({2: "БУХГАЛТЕРСКИЙ БАЛАНС"})], + [*at({17: "Форма по ОКУД", 18: "0710001"})], + [*at({17: "Дата (число, месяц, год)", 18: 30, 19: 9, 20: 2025})], + [ + *at( + { + 2: "Организация (орган исполнительной власти)", + 7: 'ООО "АГРОФИРМА "МЕЖЕНИНОВСКАЯ"', + 17: "по ОКПО", + 18: "85262693", + } + ) + ], + [*at({2: "Идентификационный номер налогоплательщика", 17: "ИНН", 18: "7016006635"})], + [*at({2: "Организационно-правовая форма***", 7: "ООО", 15: "по ОКОПФ/\nОКФС", 18: "65", 19: "16"})], + [*at({2: "Единица измерения:", 7: "тыс. руб", 17: "по ОКЕИ", 18: "384"})], + [*at({2: "Местонахождение (адрес)", 7: "636141, Томская обл"})], + ] + ) + + header, _ = parse_header(sheet, rules, 1, 8, _block(sheet, rules)) + + assert header.report_date == datetime.date(2025, 9, 30) + assert header.report_date_text is None + assert header.inn == "7016006635" + assert header.okopf == "65" and header.okfs == "16" + assert header.address == "636141, Томская обл" + + +def test_continuation_header_is_empty(rules: Rules) -> None: + sheet = sheet_from_rows( + [ + [*at({17: "Форма 0710001 с.2"})], + ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г."], + ] + ) + + header, warnings = parse_header(sheet, rules, 1, 1, None) + + assert header.form_name == "Форма 0710001 с.2" + assert header.organization_name is None and header.inn is None + assert header.report_date is None and warnings == [] + + +def test_missing_units_and_organization_warn(rules: Rules) -> None: + sheet = sheet_from_rows([["Бухгалтерский баланс"], ["на 31 декабря 2024 г."]]) + + header, warnings = parse_header(sheet, rules, 1, 2, _block(sheet, rules)) + + assert header.units is None and header.organization_name is None + assert any("единица измерения" in warning for warning in warnings) + assert any("организация" in warning for warning in warnings) + + +def test_inn_skips_auditor_block(rules: Rules) -> None: + """Review Focus: ИНН аудитора (строка 16) не должен попасть в header.inn.""" + sheet = sheet_from_rows(ONE_C) + header, _ = parse_header(sheet, rules, 1, 16, _block(sheet, rules)) + assert header.inn == "3249004697", f"Expected organization INN 3249004697, got {header.inn}" + assert header.inn != "7716021332", "Auditor INN 7716021332 should not be included"