После выбора строки по подписи кода проверяется и нижняя строка (годы): если подпись шапки отдельно не период, а вместе с нижней — период, строки склеиваются. Тесты: Excel, OCR, повторная шапка в два ряда. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
317 lines
14 KiB
Python
317 lines
14 KiB
Python
"""Шаг 2: столбец кодов, нумерация, строка заголовка, роли строк, столбец наименования."""
|
||
|
||
import re
|
||
from collections import Counter
|
||
from collections.abc import Sequence
|
||
from decimal import Decimal
|
||
|
||
from arm_statements.codes import code_parts, is_code_cell, parse_code, section_of
|
||
from arm_statements.dates import parse_period_label
|
||
from arm_statements.loader import CodeSection, Rules
|
||
from arm_statements.model import Cell, DocumentType, PeriodKind, RowRole
|
||
from arm_statements.text import fix_lookalikes, normalize_caption
|
||
|
||
Grid = Sequence[Sequence[Cell | None]]
|
||
|
||
_MIN_NUMBERING_CELLS = 3
|
||
_SHORT_NOTE = 3 # «Пояснения» без подписи: значения не длиннее 3 символов
|
||
_SKIP_STEP = 2 # скачок через одну пропущенную цифру нумерации
|
||
_NUMBER = re.compile(r"[0-9]+")
|
||
_FULL_CONFIDENCE = 100.0
|
||
|
||
|
||
def _text(cell: Cell | None) -> str:
|
||
return "" if cell is None else cell.text.strip()
|
||
|
||
|
||
def cell_at(rows: Grid, row: int, col: int) -> Cell | None:
|
||
"""Ячейка сетки; вне строки или сетки — `None`."""
|
||
if row < 0 or row >= len(rows) or col < 0:
|
||
return None
|
||
line = rows[row]
|
||
return line[col] if col < len(line) else None
|
||
|
||
|
||
def _width(rows: Grid, first: int, last: int) -> int:
|
||
return max((len(rows[r]) for r in range(max(first, 0), min(last, len(rows) - 1) + 1)), default=0)
|
||
|
||
|
||
def _relaxed_count(rows: Grid, span: range, col: int, section: CodeSection, rules: Rules) -> int:
|
||
count = 0
|
||
for row in span:
|
||
text = _text(cell_at(rows, row, col))
|
||
if not text or not is_code_cell(text, rules):
|
||
continue
|
||
codes = [parse_code(part, rules).code for part in code_parts(text)]
|
||
if all(code is not None and section_of(code, rules) is section for code in codes):
|
||
count += 1
|
||
return count
|
||
|
||
|
||
def _share_ok(codes: int, nonempty: int, min_share: Decimal) -> bool:
|
||
return nonempty > 0 and Decimal(codes) >= min_share * Decimal(nonempty)
|
||
|
||
|
||
def _tally(rows: Grid, start: int, last: int, col: int, rules: Rules) -> tuple[int, int]:
|
||
"""(кодов, непустых ячеек) столбца в строках `start..last`."""
|
||
codes = nonempty = 0
|
||
for row in range(start, last + 1):
|
||
text = _text(cell_at(rows, row, col))
|
||
if text:
|
||
nonempty += 1
|
||
if is_code_cell(text, rules):
|
||
codes += 1
|
||
return codes, nonempty
|
||
|
||
|
||
def _best(candidates: dict[int, int]) -> int | None:
|
||
"""Столбец с наибольшим числом кодов; ничья — левый."""
|
||
best: int | None = None
|
||
for col in sorted(candidates):
|
||
if best is None or candidates[col] > candidates[best]:
|
||
best = col
|
||
return best
|
||
|
||
|
||
def _caption_candidates(rows: Grid, first: int, last: int, width: int, rules: Rules) -> dict[int, int]:
|
||
"""Правило (а): столбец с подписью кода; под подписью — ячейки ниже неё до `last`."""
|
||
ranges = rules.code_ranges
|
||
found: dict[int, int] = {}
|
||
for col in range(width):
|
||
first_code = next(
|
||
(r for r in range(first, last + 1) if is_code_cell(_text(cell_at(rows, r, col)), rules)), last + 1
|
||
)
|
||
captions = [
|
||
r
|
||
for r in range(first, min(first_code, last + 1))
|
||
if rules.headers.code_caption.search(normalize_caption(_text(cell_at(rows, r, col))))
|
||
]
|
||
if not captions:
|
||
continue
|
||
codes, nonempty = _tally(rows, captions[-1] + 1, last, col, rules)
|
||
if codes > 0 and (codes >= ranges.min_codes or _share_ok(codes, nonempty, ranges.min_share)):
|
||
found[col] = codes
|
||
return found
|
||
|
||
|
||
def find_code_column(
|
||
rows: Grid, first: int, last: int, rules: Rules, *, relaxed_section: CodeSection | None = None
|
||
) -> int | None:
|
||
"""Столбец кодов по правилу (а)/(б) шага 2; `relaxed_section` — ослабленный поиск продолжения."""
|
||
width = _width(rows, first, last)
|
||
if relaxed_section is not None:
|
||
counts = {
|
||
col: _relaxed_count(rows, range(first, last + 1), col, relaxed_section, rules) for col in range(width)
|
||
}
|
||
counts = {col: n for col, n in counts.items() if n >= rules.code_ranges.min_codes_continuation}
|
||
return _best(counts)
|
||
by_caption = _best(_caption_candidates(rows, first, last, width, rules))
|
||
if by_caption is not None:
|
||
return by_caption
|
||
ranges = rules.code_ranges
|
||
scored: dict[int, int] = {}
|
||
for col in range(width):
|
||
codes, nonempty = _tally(rows, first, last, col, rules)
|
||
if codes >= ranges.min_codes and _share_ok(codes, nonempty, ranges.min_share):
|
||
scored[col] = codes
|
||
return _best(scored)
|
||
|
||
|
||
def code_column(rows: Grid, rules: Rules, *, relaxed: DocumentType | None = None) -> int | None:
|
||
"""Столбец кодов всей сетки — то же правило, что у шага 2 (для адаптеров)."""
|
||
section: CodeSection | None = None
|
||
if relaxed is DocumentType.DISCLOSURE:
|
||
raise ValueError("relaxed=DISCLOSURE не поддерживается: у пояснений несколько отрезков кодов")
|
||
if relaxed is DocumentType.BALANCE:
|
||
section = CodeSection.BALANCE
|
||
elif relaxed is DocumentType.INCOME:
|
||
section = CodeSection.INCOME
|
||
if not rows:
|
||
return None
|
||
return find_code_column(rows, 0, len(rows) - 1, rules, relaxed_section=section)
|
||
|
||
|
||
def first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rules) -> int | None:
|
||
for row in range(first, last + 1):
|
||
if is_code_cell(_text(cell_at(rows, row, code_col)), rules):
|
||
return row
|
||
return None
|
||
|
||
|
||
def is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool:
|
||
"""≥ 3 непустых ячеек-целых, слева направо +1, начало 1 или 2, не больше одного пропуска."""
|
||
numbers: list[int] = []
|
||
for cell in cells:
|
||
text = _text(cell)
|
||
if not text:
|
||
continue
|
||
fixed = fix_lookalikes(text, rules.lookalikes, anywhere=True).strip()
|
||
if _NUMBER.fullmatch(fixed) is None:
|
||
return False
|
||
numbers.append(int(fixed))
|
||
if len(numbers) < _MIN_NUMBERING_CELLS or numbers[0] not in (1, 2):
|
||
return False
|
||
skips = 0
|
||
for prev, current in zip(numbers, numbers[1:], strict=False):
|
||
step = current - prev
|
||
if step == 1:
|
||
continue
|
||
if step == _SKIP_STEP and skips == 0:
|
||
skips += 1
|
||
continue
|
||
return False
|
||
return True
|
||
|
||
|
||
def _is_period(text: str, ref: str, rules: Rules) -> bool:
|
||
cell = Cell(text, None, _FULL_CONFIDENCE, ref)
|
||
return parse_period_label(cell, rules).kind is not PeriodKind.NONE
|
||
|
||
|
||
def _has_header_periods(rows: Grid, row: int, code_col: int, rules: Rules) -> bool:
|
||
"""Две и более подписи-периода правее кода, либо одна — не голый год."""
|
||
labels = [
|
||
parse_period_label(cell, rules)
|
||
for col, cell in enumerate(rows[row])
|
||
if col > code_col and cell is not None and cell.text.strip()
|
||
]
|
||
periods = [label for label in labels if label.kind is not PeriodKind.NONE]
|
||
strong = [label for label in periods if not label.bare_year]
|
||
return bool(strong) or len(periods) >= 2 # noqa: PLR2004 - две подписи подряд — заголовок, одна «2025» — титул
|
||
|
||
|
||
def _glued_with_upper(rows: Grid, upper: int, lower: int, code_col: int, rules: Rules) -> bool:
|
||
"""P1-9: подпись верхней строки отдельно — не период, а «верхняя + нижняя» — период."""
|
||
for col, cell in enumerate(rows[upper]):
|
||
if col <= code_col:
|
||
continue
|
||
top = _text(cell)
|
||
bottom = _text(cell_at(rows, lower, col))
|
||
if not top or not bottom:
|
||
continue
|
||
if not _is_period(top, "", rules) and _is_period(f"{top} {bottom}", "", rules):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _upper_candidate(rows: Grid, row: int, first: int, rules: Rules) -> int | None:
|
||
above = row - 1
|
||
while above >= first and is_numbering_row(rows[above], rules):
|
||
above -= 1
|
||
return above if above >= first else None
|
||
|
||
|
||
def _lower_candidate(rows: Grid, row: int, first_code: int, rules: Rules) -> int | None:
|
||
below = row + 1
|
||
while below < first_code and is_numbering_row(rows[below], rules):
|
||
below += 1
|
||
return below if below < first_code else None
|
||
|
||
|
||
def find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rules: Rules) -> tuple[int, ...]:
|
||
"""Строка заголовка (одна или две, склеенные); не найдена — `()`."""
|
||
candidates = [r for r in range(first, first_code) if not is_numbering_row(rows[r], rules)]
|
||
captioned = [
|
||
r for r in candidates if rules.headers.code_caption.search(normalize_caption(_text(cell_at(rows, r, code_col))))
|
||
]
|
||
header: int | None = captioned[-1] if captioned else None
|
||
if header is None:
|
||
header = next((r for r in reversed(candidates) if _has_header_periods(rows, r, code_col, rules)), None)
|
||
if header is None:
|
||
return ()
|
||
upper = _upper_candidate(rows, header, first, rules)
|
||
if upper is not None and _glued_with_upper(rows, upper, header, code_col, rules):
|
||
return (upper, header)
|
||
lower = _lower_candidate(rows, header, first_code, rules)
|
||
if lower is not None and _glued_with_upper(rows, header, lower, code_col, rules):
|
||
return (header, lower)
|
||
return (header,)
|
||
|
||
|
||
def segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]:
|
||
"""Роли строк `first..last`: выше заголовка — TITLE (только при найденном заголовке)."""
|
||
roles: list[RowRole] = []
|
||
top = min(header_rows) if header_rows else None
|
||
for row in range(first, last + 1):
|
||
if row in header_rows:
|
||
roles.append(RowRole.HEADER)
|
||
elif top is not None and row < top:
|
||
roles.append(RowRole.TITLE)
|
||
elif is_numbering_row(rows[row], rules):
|
||
roles.append(RowRole.NUMBERING)
|
||
else:
|
||
roles.append(RowRole.DATA)
|
||
return roles
|
||
|
||
|
||
def _caption_column(
|
||
rows: Grid, header_rows: tuple[int, ...], code_col: int, pattern: re.Pattern[str], *, left_of_code: bool
|
||
) -> int | None:
|
||
for row in header_rows:
|
||
for col, cell in enumerate(rows[row]):
|
||
if col == code_col or (left_of_code and col > code_col):
|
||
continue
|
||
if pattern.search(normalize_caption(_text(cell))):
|
||
return col
|
||
return None
|
||
|
||
|
||
def _code_rows(rows: Grid, span: range, skip: tuple[int, ...], code_col: int, rules: Rules) -> list[int]:
|
||
return [r for r in span if r not in skip and is_code_cell(_text(cell_at(rows, r, code_col)), rules)]
|
||
|
||
|
||
def _short_columns(rows: Grid, data: Sequence[int], code_col: int, skip: int | None) -> list[int]:
|
||
result: list[int] = []
|
||
for col in range(code_col):
|
||
if col == skip:
|
||
continue
|
||
values = [_text(cell_at(rows, r, col)) for r in data]
|
||
filled = [v for v in values if v]
|
||
if filled and all(len(v) <= _SHORT_NOTE for v in filled):
|
||
result.append(col)
|
||
return result
|
||
|
||
|
||
def _text_column(rows: Grid, data: Sequence[int], code_col: int, skip: set[int]) -> int | None:
|
||
counts: Counter[int] = Counter()
|
||
for col in range(code_col):
|
||
if col in skip:
|
||
continue
|
||
counts[col] = sum(1 for r in data if any(ch.isalpha() for ch in _text(cell_at(rows, r, col))))
|
||
best: int | None = None
|
||
for col in sorted(counts): # ничья — ближайший к коду
|
||
if counts[col] > 0 and (best is None or counts[col] >= counts[best]):
|
||
best = col
|
||
return best
|
||
|
||
|
||
def find_name_column( # noqa: PLR0913, PLR0917 - сигнатура задана планом (Task 8)
|
||
rows: Grid, header_rows: tuple[int, ...], first: int, last: int, code_col: int, rules: Rules
|
||
) -> tuple[int | None, int | None]:
|
||
"""(столбец наименования, столбец «Пояснения»)."""
|
||
name_col = _caption_column(rows, header_rows, code_col, rules.headers.name_caption, left_of_code=False)
|
||
notes_col = _caption_column(rows, header_rows, code_col, rules.headers.explanations_caption, left_of_code=True)
|
||
data = _code_rows(rows, range(first, last + 1), header_rows, code_col, rules)
|
||
if name_col is None:
|
||
skip = {notes_col} if notes_col is not None else set(_short_columns(rows, data, code_col, None))
|
||
name_col = _text_column(rows, data, code_col, skip)
|
||
if notes_col is None:
|
||
short = _short_columns(rows, data, code_col, name_col)
|
||
notes_col = short[-1] if short else None
|
||
return name_col, notes_col
|
||
|
||
|
||
def name_cell(
|
||
cells: Sequence[Cell | None], name_col: int | None, code_col: int, explanations_col: int | None
|
||
) -> Cell | None:
|
||
"""Ячейка наименования; пустая — ближайшая левее кода ячейка с буквами, кроме «Пояснений»."""
|
||
if name_col is not None and name_col < len(cells):
|
||
cell = cells[name_col]
|
||
if cell is not None and cell.text.strip():
|
||
return cell
|
||
for col in range(min(code_col, len(cells)) - 1, -1, -1):
|
||
cell = cells[col]
|
||
if col != explanations_col and cell is not None and any(ch.isalpha() for ch in cell.text):
|
||
return cell
|
||
return None
|