Andrey Epifancev 46ac9367ee chore(arm_statements): копия пакета arm-statements@6bce80b (#128)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:38:30 +04:00

219 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Шаг 6 и конвейер кандидата: шаги 2–8 одного кандидата и нормализация набора кандидатов."""
from collections.abc import Sequence
from dataclasses import replace
from arm_statements.checks import checks_from
from arm_statements.codes import document_type_of, majority_section
from arm_statements.columns import DocumentDates
from arm_statements.evaluation import EvalLine, EvalValue
from arm_statements.header import parse_header
from arm_statements.issues import WarningCode, make_warning
from arm_statements.lines import build_lines, units_warnings
from arm_statements.loader import Rules, default_rules
from arm_statements.model import (
CandidateResult,
DocStatus,
DocumentType,
FormCandidate,
FormVariant,
LineStatus,
NormalizationResult,
Source,
StatementDocument,
StatementHeader,
StatementLine,
StatementWarning,
)
from arm_statements.review import evaluate, review_header
from arm_statements.rows import RawLine, collect_raw_lines
from arm_statements.segments import Layout, find_layout
from arm_statements.status import document_status, status_input
from arm_statements.text import has_digit, is_disguised_digit
_SHEET_PREFIX = "sheet:"
def _eval_lines(lines: Sequence[StatementLine]) -> list[EvalLine]:
"""Строки сервиса в общий вид шагов 6–8: ключ — `ref`, колонка — `str(column_index)`."""
return [
EvalLine(
key=line.ref,
order=line.row_index,
line_status=line.line_status,
line_code=line.line_code,
code_confidence=line.line_code_confidence,
code_fixed=False,
values=tuple(
EvalValue(str(v.column_index), v.amount, v.text, v.confidence, v.is_recognized, fixed=False)
for v in line.values
),
)
for line in lines
]
def _form_name(candidate: FormCandidate) -> str | None:
title = candidate.evidence.title
if title is None or not title.text.strip():
return None
return title.text
def _has_content(candidate: FormCandidate, rules: Rules) -> bool:
"""Содержимое таблицы: ячейка с цифрой или «цифрой под видом буквы»."""
return any(
cell is not None and (has_digit(cell.text) or is_disguised_digit(cell.text, rules.lookalikes))
for row in candidate.rows
for cell in row
)
def _lost_table(candidate: FormCandidate, rules: Rules) -> StatementWarning:
"""Документа нет (нет кодов и `type_hint`): предупреждение файла по источнику и содержимому."""
if candidate.source is Source.EXCEL:
sheet = candidate.locator.removeprefix(_SHEET_PREFIX).split(",")[0]
message = f"лист «{sheet}» не содержит формы ББ/ОФР"
return make_warning(WarningCode.SHEET_OTHER, message, rules, locator=f"{_SHEET_PREFIX}{sheet}")
if _has_content(candidate, rules):
message = "таблица с данными не распознана как форма: нет столбца кодов и типа формы"
return make_warning(WarningCode.TABLE_LOST, message, rules, locator=candidate.locator)
return make_warning(WarningCode.TABLE_SKIPPED, "таблица без данных пропущена", rules, locator=candidate.locator)
def _without_lines( # noqa: PLR0913 -- сборка документа без строк: поля заданы схемой
candidate: FormCandidate,
document_type: DocumentType,
header: StatementHeader,
warnings: Sequence[StatementWarning],
*,
dates: DocumentDates | None,
rules: Rules,
) -> StatementDocument:
"""Документ без строк: `DISCLOSURE` (`dates=None` — шапку не проверяем, P1-6) или без столбца кодов (P1-19)."""
collected = list(warnings)
if dates is not None:
header, header_warnings = review_header(header, dates, rules)
collected.extend(header_warnings)
document = StatementDocument(
document_type=document_type,
form_variant=None if document_type is DocumentType.DISCLOSURE else FormVariant.FULL,
locator=candidate.locator,
header=header,
columns=(),
lines=(),
checks=None,
status=DocStatus.DONE,
warnings=tuple(collected),
)
return replace(document, status=document_status(status_input(document)))
def _lines_document( # noqa: PLR0913 -- сборка документа со строками: поля заданы схемой
candidate: FormCandidate,
document_type: DocumentType,
header: StatementHeader,
layout: Layout,
*,
raw: Sequence[RawLine],
row_warnings: Sequence[StatementWarning],
conflict: Sequence[StatementWarning],
rules: Rules,
) -> StatementDocument:
lines, range_warnings = build_lines(raw, candidate.source, rules)
units = units_warnings(lines, header, candidate.warnings, rules)
column_keys = [str(column.column_index) for column in layout.columns]
evaluation = evaluate(_eval_lines(lines), column_keys, document_type, layout.dates.effective, rules, accepted=False)
checks = checks_from(evaluation.outcomes, {key: int(key) for key in column_keys})
header, header_warnings = review_header(header, dates=layout.dates, rules=rules)
reviewed = tuple(
replace(
line,
needs_review=evaluation.line_needs_review.get(line.ref, False),
values=tuple(
replace(v, needs_review=evaluation.value_needs_review.get((line.ref, str(v.column_index)), False))
for v in line.values
),
)
for line in lines
)
document = StatementDocument(
document_type=document_type,
form_variant=evaluation.variant,
locator=candidate.locator,
header=header,
columns=layout.columns,
lines=reviewed,
checks=checks,
status=DocStatus.DONE,
warnings=(
*candidate.warnings,
*layout.warnings,
*row_warnings,
*range_warnings,
*units,
*conflict,
*header_warnings,
),
)
return replace(document, status=document_status(status_input(document)))
def normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult:
"""Шаги 2–8 кандидата; документа нет — предупреждение файла (`TABLE_LOST`, `TABLE_SKIPPED`, `SHEET_OTHER`).
Когда документа нет, `candidate.warnings` (предупреждения адаптера) никуда не попадают: хост обрабатывает их сам.
"""
rules = rules if rules is not None else default_rules()
header = replace(parse_header(candidate.header, rules), form_name=_form_name(candidate))
layout = find_layout(candidate, header, rules)
hint = candidate.type_hint
if not layout.code_found:
if hint is None:
return CandidateResult(None, (_lost_table(candidate, rules),))
base = [*candidate.warnings, *layout.warnings]
if hint is DocumentType.DISCLOSURE:
return CandidateResult(
_without_lines(candidate, hint, header, candidate.warnings, dates=None, rules=rules), ()
)
message = "столбец кодов строк не найден"
base.append(make_warning(WarningCode.CODE_COLUMN_NOT_FOUND, message, rules))
return CandidateResult(_without_lines(candidate, hint, header, base, dates=layout.dates, rules=rules), ())
raw, row_warnings = collect_raw_lines(candidate, layout, rules)
ok_codes = [item.code for item in raw if item.line_status is LineStatus.OK and item.code is not None]
section = majority_section(ok_codes, rules)
if section is not None:
document_type = document_type_of(section)
elif hint is not None:
document_type = hint
else:
return CandidateResult(None, (_lost_table(candidate, rules),))
conflict: list[StatementWarning] = []
if hint is not None and hint is not document_type:
message = f"тип по кодам строк ({document_type}) противоречит типу по заголовку ({hint})"
conflict.append(make_warning(WarningCode.TYPE_CONFLICT, message, rules))
if document_type is DocumentType.DISCLOSURE:
warnings = [*candidate.warnings, *conflict]
return CandidateResult(_without_lines(candidate, document_type, header, warnings, dates=None, rules=rules), ())
document = _lines_document(
candidate, document_type, header, layout, raw=raw, row_warnings=row_warnings, conflict=conflict, rules=rules
)
return CandidateResult(document, ())
def normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult:
"""Кандидаты по порядку; исключения не перехватываются (изоляцию кандидатов делает хост)."""
rules = rules if rules is not None else default_rules()
documents: list[StatementDocument] = []
file_warnings: list[StatementWarning] = []
for candidate in candidates:
result = normalize_candidate(candidate, rules)
if result.document is not None:
documents.append(result.document)
file_warnings.extend(result.file_warnings)
return NormalizationResult(tuple(documents), tuple(file_warnings))