feat(statements): шапка формы по меткам — организация, ИНН не аудитора, дата, единицы, ОКПО/ОКОПФ/ОКФС, адрес

Реализация Task 9: parse_header() извлекает значения полей из шапки документа ББ/ОФР
по меткам словаря Rules.statement_headers с особыми правилами:
- ИНН ниже блока аудитора пропускается (Review Focus)
- Организация ищется после метки, при пустом значении — выше с признаком ОПФ
- Дата из подзаголовка формы или тройки число/месяц/год
- Единицы, адрес, ОКПО, ОКОПФ/ОКФС по меткам; все найденные поля получают confidence=100
- При отсутствии продолжения блока метка берётся как form_name, остальные поля None

Тесты: 6 базовых сценариев + test_inn_skips_auditor_block (обязательный).

Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 16:20:09 +04:00
parent f279157486
commit 55a75112aa
2 changed files with 340 additions and 0 deletions

View File

@ -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

View File

@ -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"