Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
163 lines
7.4 KiB
Python
163 lines
7.4 KiB
Python
"""Шаг 2: колонки значений, подписи и даты колонок."""
|
||
|
||
from collections.abc import Sequence
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from arm_statements.amounts import parse_amount
|
||
from arm_statements.codes import is_code_cell
|
||
from arm_statements.dates import expected_dates, parse_period_label, resolve_relative
|
||
from arm_statements.issues import WarningCode, make_warning
|
||
from arm_statements.layout import Grid, cell_at
|
||
from arm_statements.loader import Rules
|
||
from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, StatementHeader, StatementWarning
|
||
|
||
_FULL_CONFIDENCE = 100.0
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ColumnDraft:
|
||
"""Колонка сегмента до сопоставления; `date` — только у абсолютной подписи."""
|
||
|
||
grid_col: int
|
||
label: str
|
||
label_confidence: float | None
|
||
kind: PeriodKind
|
||
date: date | None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class DocumentDates:
|
||
effective: date | None
|
||
confident_effective: date | None
|
||
latest_absolute: date | None
|
||
confident_latest_absolute: date | None
|
||
|
||
|
||
def _text(cell: Cell | None) -> str:
|
||
return "" if cell is None else cell.text.strip()
|
||
|
||
|
||
def _code_rows(rows: Grid, data_rows: Sequence[int], code_col: int, rules: Rules) -> list[int]:
|
||
return [r for r in data_rows if is_code_cell(_text(cell_at(rows, r, code_col)), rules)]
|
||
|
||
|
||
def _is_number(cell: Cell | None, source: Source, rules: Rules) -> bool:
|
||
if cell is None:
|
||
return False
|
||
if isinstance(cell.value, Decimal):
|
||
return True
|
||
return parse_amount(cell.text, source, rules).amount is not None
|
||
|
||
|
||
def has_numbers( # noqa: PLR0913, PLR0917 - сигнатура задана планом (Task 9)
|
||
rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules
|
||
) -> bool:
|
||
"""В строках с кодом есть ячейка столбца с числом (значение-число или разобранная сумма)."""
|
||
return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules))
|
||
|
||
|
||
def _width(rows: Grid, row_indexes: Sequence[int]) -> int:
|
||
return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0)
|
||
|
||
|
||
def amount_columns(rows: Grid, code_col: int, rules: Rules, *, source: Source = Source.EXCEL) -> tuple[int, ...]:
|
||
"""Столбцы правее кодов, где в строках с кодом есть числа, по возрастанию."""
|
||
every = range(len(rows))
|
||
return tuple(
|
||
col
|
||
for col in range(code_col + 1, _width(rows, every))
|
||
if has_numbers(rows, every, col, code_col, source, rules)
|
||
)
|
||
|
||
|
||
def _label(rows: Grid, header_rows: tuple[int, ...], col: int) -> Cell | None:
|
||
"""Подпись колонки: строки заголовка через пробел, уверенность — минимум; одна ячейка берётся как есть."""
|
||
cells = [c for r in header_rows if (c := cell_at(rows, r, col)) is not None and c.text.strip()]
|
||
if not cells:
|
||
return None
|
||
if len(cells) == 1:
|
||
return cells[0]
|
||
text = " ".join(c.text.strip() for c in cells)
|
||
return Cell(text, None, min(c.confidence for c in cells), cells[0].ref)
|
||
|
||
|
||
def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана планом (Task 9)
|
||
rows: Grid,
|
||
header_rows: tuple[int, ...],
|
||
data_rows: Sequence[int],
|
||
code_col: int,
|
||
source: Source,
|
||
rules: Rules,
|
||
) -> tuple[list[ColumnDraft], list[StatementWarning]]:
|
||
"""Колонки значений сегмента и предупреждения о пропущенных/недатированных столбцах."""
|
||
drafts: list[ColumnDraft] = []
|
||
warnings: list[StatementWarning] = []
|
||
for col in range(code_col + 1, _width(rows, [*header_rows, *data_rows])):
|
||
numbers = has_numbers(rows, data_rows, col, code_col, source, rules)
|
||
if not header_rows:
|
||
if numbers:
|
||
drafts.append(ColumnDraft(col, "", None, PeriodKind.NONE, None))
|
||
continue
|
||
cell = _label(rows, header_rows, col)
|
||
parsed = parse_period_label(cell, rules)
|
||
if cell is not None and parsed.excluded:
|
||
message = f"столбец {col} («{cell.text}») пропущен: не период"
|
||
warnings.append(make_warning(WarningCode.COLUMN_SKIPPED, message, rules, column_index=col))
|
||
elif cell is not None and parsed.kind is not PeriodKind.NONE:
|
||
drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date))
|
||
elif numbers:
|
||
label, conf = (cell.text, cell.confidence) if cell is not None else ("", None)
|
||
message = f"у столбца {col} с числами нет разобранной подписи периода"
|
||
warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col))
|
||
drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None))
|
||
return drafts, warnings
|
||
|
||
|
||
def _latest(drafts: Sequence[ColumnDraft], *, confident: bool) -> date | None:
|
||
days = [
|
||
d.date
|
||
for d in drafts
|
||
if d.kind is PeriodKind.ABSOLUTE
|
||
and d.date is not None
|
||
and (not confident or d.label_confidence == _FULL_CONFIDENCE)
|
||
]
|
||
return max(days, default=None)
|
||
|
||
|
||
def document_dates(drafts: Sequence[ColumnDraft], header: StatementHeader) -> DocumentDates:
|
||
"""Эффективная дата и её «уверенный» двойник: неуверенное само себя не подтверждает."""
|
||
latest = _latest(drafts, confident=False)
|
||
confident_latest = _latest(drafts, confident=True)
|
||
report = header.report_date
|
||
sure_report = report.value if report.confidence == _FULL_CONFIDENCE else None
|
||
return DocumentDates(
|
||
effective=report.value or latest,
|
||
confident_effective=sure_report or confident_latest,
|
||
latest_absolute=latest,
|
||
confident_latest_absolute=confident_latest,
|
||
)
|
||
|
||
|
||
def finalize_columns(
|
||
drafts: Sequence[ColumnDraft], dates: DocumentDates, rules: Rules
|
||
) -> tuple[tuple[StatementColumn, ...], list[StatementWarning]]:
|
||
"""Колонки документа: даты относительных подписей и проверка неуверенных абсолютных."""
|
||
expected = None if dates.confident_effective is None else expected_dates(dates.confident_effective)
|
||
columns: list[StatementColumn] = []
|
||
warnings: list[StatementWarning] = []
|
||
for draft in drafts:
|
||
if draft.kind is PeriodKind.ABSOLUTE:
|
||
period = draft.date
|
||
unsure = draft.label_confidence is not None and draft.label_confidence < _FULL_CONFIDENCE
|
||
if unsure and (expected is None or period not in expected):
|
||
message = f"дата подписи столбца {draft.grid_col} («{draft.label}») не подтверждена"
|
||
warnings.append(
|
||
make_warning(WarningCode.COLUMN_DATE_UNCERTAIN, message, rules, column_index=draft.grid_col)
|
||
)
|
||
else:
|
||
period = resolve_relative(draft.kind, dates.effective)
|
||
columns.append(StatementColumn(draft.grid_col, draft.label, draft.label_confidence, period, draft.kind))
|
||
return tuple(columns), warnings
|