Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
119 lines
4.3 KiB
Python
119 lines
4.3 KiB
Python
"""Разбор кода строки и отрезки кодов форм."""
|
|
|
|
import re
|
|
from collections import Counter
|
|
from collections.abc import Iterable, Mapping, Sequence
|
|
from typing import NamedTuple
|
|
|
|
from arm_statements.loader import CodeSection, Rules
|
|
from arm_statements.model import DocumentType
|
|
from arm_statements.text import replace_lookalikes
|
|
|
|
_CODE = re.compile(r"\d{4,5}(?:\.0)?")
|
|
_BAR = "|"
|
|
|
|
|
|
class CodeParse(NamedTuple):
|
|
code: str | None
|
|
fixed: bool # при разборе заменён двойник
|
|
|
|
|
|
def code_parts(text: str | None) -> tuple[str, ...]:
|
|
"""Непустые строки ячейки, обрезанные."""
|
|
if text is None:
|
|
return ()
|
|
return tuple(part.strip() for part in text.splitlines() if part.strip())
|
|
|
|
|
|
def section_of(code: str, rules: Rules) -> CodeSection | None:
|
|
head = int(code[:4])
|
|
for item in rules.code_ranges.ranges:
|
|
if item.low <= head <= item.high:
|
|
return item.section
|
|
return None
|
|
|
|
|
|
def _strip_edges(text: str) -> str:
|
|
start, end = 0, len(text)
|
|
while start < end and not text[start].isalnum():
|
|
start += 1
|
|
while end > start and not text[end - 1].isalnum():
|
|
end -= 1
|
|
return text[start:end]
|
|
|
|
|
|
def _as_code(text: str, rules: Rules) -> str | None:
|
|
core = _strip_edges(text)
|
|
if _CODE.fullmatch(core) is None:
|
|
return None
|
|
code = core.split(".", 1)[0]
|
|
return code if section_of(code, rules) is not None else None
|
|
|
|
|
|
def _parse_line(line: str, rules: Rules) -> CodeParse:
|
|
compact = "".join(line.split())
|
|
# сначала `|` как двойник единицы; если результата-кода нет — `|` как краевая пунктуация
|
|
for candidate in (compact, compact.replace(_BAR, "")):
|
|
replaced = replace_lookalikes(candidate, rules.lookalikes)
|
|
code = _as_code(replaced, rules)
|
|
if code is not None:
|
|
return CodeParse(code, replaced != candidate)
|
|
return CodeParse(None, False)
|
|
|
|
|
|
def parse_code(text: str | None, rules: Rules) -> CodeParse:
|
|
"""Однострочный текст → код; многострочный → `CodeParse(None, False)`."""
|
|
parts = code_parts(text)
|
|
if len(parts) != 1:
|
|
return CodeParse(None, False)
|
|
return _parse_line(parts[0], rules)
|
|
|
|
|
|
def is_code_cell(text: str | None, rules: Rules) -> bool:
|
|
parts = code_parts(text)
|
|
return bool(parts) and all(_parse_line(part, rules).code is not None for part in parts)
|
|
|
|
|
|
def majority_section(codes: Iterable[str], rules: Rules) -> CodeSection | None:
|
|
"""Отрезок большинства; ничья — первый по порядку отрезков словаря."""
|
|
counts: Counter[CodeSection] = Counter()
|
|
for code in codes:
|
|
section = section_of(code, rules)
|
|
if section is not None:
|
|
counts[section] += 1
|
|
best: CodeSection | None = None
|
|
for item in rules.code_ranges.ranges:
|
|
if counts[item.section] and (best is None or counts[item.section] > counts[best]):
|
|
best = item.section
|
|
return best
|
|
|
|
|
|
def document_type_of(section: CodeSection) -> DocumentType:
|
|
mapping: Mapping[CodeSection, DocumentType] = {
|
|
CodeSection.BALANCE: DocumentType.BALANCE,
|
|
CodeSection.INCOME: DocumentType.INCOME,
|
|
}
|
|
return mapping.get(section, DocumentType.DISCLOSURE)
|
|
|
|
|
|
def sheet_joins(
|
|
form_type: DocumentType,
|
|
sheet_codes: Sequence[str],
|
|
sheet_amount_columns: int,
|
|
form_amount_columns: int,
|
|
rules: Rules,
|
|
) -> bool:
|
|
"""Лист без маркера и шапки присоединяется к форме: коды в её отрезке; при ≥3 кодах колонки сумм совпадают."""
|
|
if len(sheet_codes) < rules.code_ranges.min_codes_continuation:
|
|
return False
|
|
form_section = {DocumentType.BALANCE: CodeSection.BALANCE, DocumentType.INCOME: CodeSection.INCOME}.get(form_type)
|
|
for code in sheet_codes:
|
|
section = section_of(code, rules)
|
|
if section is None or document_type_of(section) is not form_type:
|
|
return False
|
|
if form_section is not None and section is not form_section:
|
|
return False
|
|
if len(sheet_codes) >= rules.code_ranges.min_codes:
|
|
return sheet_amount_columns == form_amount_columns
|
|
return True
|