277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""Даты и подписи колонок: разбор текстовых и числовых форм, относительные подписи бланка 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),
|
||
}
|
||
)
|