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

277 lines
10 KiB
Python
Raw Permalink 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.

"""Даты и подписи колонок: разбор текстовых и числовых форм, относительные подписи бланка 66н (§11.3, §12 п. 4).
Вход `parse_date` — текст после `fix_lookalikes`. Регулярки и месяцы — данные (`rules/statement_periods.yaml`),
здесь только обработчики на каждый вид абсолютной формы."""
import calendar
import re
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from arm_statements.loader import PeriodRules, Rules
from arm_statements.model import Cell, PeriodKind
from arm_statements.text import fix_lookalikes, normalize_caption
__all__ = ["PeriodLabel", "expected_dates", "parse_date", "parse_period_label", "resolve_relative"]
_BARE_YEAR = re.compile(r"\d{4}")
_DIGITS = re.compile(r"\d+")
_ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "1V": 4, "11": 2, "111": 3}
_ORDINAL = {"перв": 1, "втор": 2, "трет": 3, "четв": 4}
_QUARTER_MONTHS = 3
_HALF_MONTHS = 6
_DECEMBER = 12
_SHORT_YEAR_LIMIT = 100 # меньше — двузначный год → 20YY
_CENTURY = 2000
_DIGITS_FULL = 8
_DIGITS_YEAR = 4
_DIGITS_SHORT = 6
_PARTS = 3
_SHORT_PART = 2
@dataclass(frozen=True, slots=True)
class PeriodLabel:
kind: PeriodKind
date: date | None
excluded: bool
bare_year: bool
def _year_full(year: str) -> int:
value = int(year)
return value if value >= _SHORT_YEAR_LIMIT else _CENTURY + value
def _month_num(word: str, rules: PeriodRules) -> int | None:
lowered = word.lower()
for stem, number in rules.months:
if lowered.startswith(stem):
return number
return None
def _month_end(year: int, month: int) -> date | None:
if not 1 <= month <= _DECEMBER:
return None
try:
return date(year, month, calendar.monthrange(year, month)[1])
except ValueError:
return None
def _period_end(year: str, month: int | None) -> date | None:
return _month_end(_year_full(year), month) if month else None
def _safe(year: int, month: int | None, day: int) -> date | None:
try:
return date(year, month, day) if month else None
except ValueError:
return None
def _ordinal(word: str) -> int | None:
"""Номер квартала/полугодия из «1», «I», «IV», «первый», «1-й»."""
token = word.strip().upper().rstrip("-Й")
if token in _ROMAN:
return _ROMAN[token]
if token.isdigit():
return int(token)
lowered = token.lower()
for stem, number in _ORDINAL.items():
if lowered.startswith(stem):
return number
return None
def _group(match: re.Match[str], name: str) -> str | None:
return match.group(name) if name in match.re.groupindex else None
def _handle_range_end(match: re.Match[str], rules: PeriodRules) -> date | None:
return _handle_day_month_year(match, rules)
def _handle_day_month_year(match: re.Match[str], rules: PeriodRules) -> date | None:
year, day = _group(match, "y"), _group(match, "d")
if year is None or day is None:
return None
month_word = _group(match, "mw")
month = _month_num(month_word, rules) if month_word is not None else int(_group(match, "m") or 0)
return _safe(_year_full(year), month, int(day))
def _handle_quarter(match: re.Match[str], rules: PeriodRules) -> date | None:
del rules
year, number = _group(match, "y"), _ordinal(_group(match, "q") or "")
return _period_end(year, (number or 0) * _QUARTER_MONTHS) if year else None
def _handle_half(match: re.Match[str], rules: PeriodRules) -> date | None:
del rules
year, number = _group(match, "y"), _ordinal(_group(match, "q") or "")
return _period_end(year, (number or 0) * _HALF_MONTHS) if year else None
def _handle_months(match: re.Match[str], rules: PeriodRules) -> date | None:
del rules
year, count = _group(match, "y"), _group(match, "n")
return _period_end(year, int(count)) if year and count else None
def _handle_month_range(match: re.Match[str], rules: PeriodRules) -> date | None:
year, word = _group(match, "y"), _group(match, "mw")
return _period_end(year, _month_num(word, rules)) if year and word else None
def _handle_year(match: re.Match[str], rules: PeriodRules) -> date | None:
year = _group(match, "y")
return _period_end(year, _DECEMBER) if year and _year_ok(int(year), rules) else None
_HANDLERS: dict[str, Callable[[re.Match[str], PeriodRules], date | None]] = {
"range_end": _handle_range_end,
"day_month_year": _handle_day_month_year,
"quarter": _handle_quarter,
"half": _handle_half,
"months": _handle_months,
"month_range": _handle_month_range,
"year": _handle_year,
}
def _year_ok(year: int, rules: PeriodRules) -> bool:
return rules.min_year <= year <= rules.max_year
def _text_date(text: str, rules: PeriodRules) -> date | None:
for item in rules.absolute:
match = item.pattern.search(text)
if match:
found = _HANDLERS[item.kind](match, rules)
if found:
return found
return None
def _from_parts(first: str, second: str, third: str) -> date | None:
"""ДД ММ ГГ[ГГ] и ГГ[ГГ] ММ ДД (год двух- или четырёхзначный только в позиции года)."""
for day, month, year in ((first, second, third), (third, second, first)):
if len(year) in (_SHORT_PART, _DIGITS_YEAR) and len(day) <= _SHORT_PART and len(month) <= _SHORT_PART:
try:
return date(_year_full(year), int(month), int(day))
except ValueError:
continue
return None
def _from_digits(digits: str) -> date | None:
"""8 слитных цифр: ДДММГГГГ, ГГГГММДД, ГГГГДДММ — первый валидный вариант."""
return (
_from_parts(digits[:2], digits[2:4], digits[4:])
or _from_parts(digits[6:], digits[4:6], digits[:4])
or _from_parts(digits[4:6], digits[6:], digits[:4])
)
def _from_digits_run(digits: str, groups: list[str], rules: PeriodRules) -> date | None:
"""Склеенные цифры «2 0 2 4», «30 09 2 0 2 5»: решает их число."""
if len(digits) == _DIGITS_FULL:
return _from_digits(digits)
if len(digits) == _DIGITS_YEAR:
return date(int(digits), 12, 31) if _year_ok(int(digits), rules) else None
if len(digits) == _DIGITS_SHORT:
return _from_parts(digits[:2], digits[2:4], digits[4:])
if len(groups) > _PARTS: # «30.09.2025 0:00:00»
return _from_parts(*groups[:_PARTS])
return None
def _numeric_date(text: str, rules: PeriodRules) -> date | None:
groups = _DIGITS.findall(text)
if not groups:
return None
if len(groups) == _PARTS:
return _from_parts(*groups)
return _from_digits_run("".join(groups), groups, rules)
def parse_date(text: str | None, rules: Rules) -> date | None:
"""Дата из текста после очистки или None. Длиннее `max_text_length` не разбирается (проверка до регулярок)."""
if text is None:
return None
stripped = text.strip()
if not stripped or len(stripped) > rules.periods.max_text_length:
return None
periods = rules.periods
return (
_text_date(stripped, periods)
or _numeric_date(stripped.split("\n", 1)[0], periods)
or _numeric_date(stripped.replace("\n", " "), periods)
)
def _is_excluded(caption: str, rules: Rules) -> bool:
return any(rx.search(caption) for rx in rules.headers.column_exclusions)
def _classify(text: str, rules: Rules) -> PeriodLabel:
caption = normalize_caption(text)
bare_year = _BARE_YEAR.fullmatch(text.strip()) is not None
if _is_excluded(caption, rules):
return PeriodLabel(PeriodKind.NONE, None, True, False)
for kind, pattern in rules.periods.relative:
if pattern.search(caption):
return PeriodLabel(kind, None, False, bare_year)
found = parse_date(text, rules)
return PeriodLabel(PeriodKind.ABSOLUTE if found else PeriodKind.NONE, found, False, bare_year)
def parse_period_label(cell: Cell | None, rules: Rules) -> PeriodLabel:
"""Подпись колонки → вид периода и дата. Дата-значение ячейки Excel — абсолютная."""
if cell is not None and isinstance(cell.value, date):
return PeriodLabel(PeriodKind.ABSOLUTE, cell.value, False, False)
raw = cell.text.strip() if cell is not None else ""
if not raw:
return PeriodLabel(PeriodKind.NONE, None, False, False)
# длинный текст не чистим от двойников (backtracking); parse_date всё равно вернёт None
text = raw if len(raw) > rules.periods.max_text_length else fix_lookalikes(raw, rules.lookalikes)
return _classify(text, rules)
def _shift_years(day: date, years: int) -> date:
try:
return day.replace(year=day.year - years)
except ValueError: # 29.02 → 28.02
return day.replace(year=day.year - years, day=28)
def resolve_relative(kind: PeriodKind, effective: date | None) -> date | None:
"""Дата относительной подписи; для ABSOLUTE и NONE — None (дата из подписи или от аналитика, P1-24)."""
if effective is None:
return None
if kind is PeriodKind.CURRENT:
return effective
if kind is PeriodKind.PREV_YEAR_END:
return date(effective.year - 1, 12, 31)
if kind is PeriodKind.PREV_PREV_YEAR_END:
return date(effective.year - 2, 12, 31)
if kind is PeriodKind.PREV_PERIOD:
return _shift_years(effective, 1)
return None
def expected_dates(effective: date) -> frozenset[date]:
"""Даты колонок формы при отчётной дате: она сама, она − 1 и − 2 года, 31.12 года − 1 и года − 2."""
return frozenset(
{
effective,
_shift_years(effective, 1),
_shift_years(effective, 2),
date(effective.year - 1, 12, 31),
date(effective.year - 2, 12, 31),
}
)