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

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