From 19f70fea65e2fa5b2d327cc2ea81cc7700d2c607 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:23:13 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=BE=D0=B2=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=B0=D1=82=D1=8B,=20=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B1=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=BA=D0=B0=2066=D0=BD,=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- arm_statements/dates.py | 276 +++++++++++++++++++++++++++++ arm_statements/tests/test_dates.py | 94 ++++++++++ 2 files changed, 370 insertions(+) create mode 100644 arm_statements/dates.py create mode 100644 arm_statements/tests/test_dates.py diff --git a/arm_statements/dates.py b/arm_statements/dates.py new file mode 100644 index 0000000..b76a216 --- /dev/null +++ b/arm_statements/dates.py @@ -0,0 +1,276 @@ +"""Даты и подписи колонок: разбор текстовых и числовых форм, относительные подписи бланка 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), + } + ) diff --git a/arm_statements/tests/test_dates.py b/arm_statements/tests/test_dates.py new file mode 100644 index 0000000..887de26 --- /dev/null +++ b/arm_statements/tests/test_dates.py @@ -0,0 +1,94 @@ +from datetime import date + +import pytest + +from arm_statements.dates import expected_dates, parse_date, parse_period_label, resolve_relative +from arm_statements.loader import Rules +from arm_statements.model import Cell, PeriodKind +from arm_statements.text import fix_lookalikes + +# Обязательные строки scan-gateway test_dates.py::test_parses_real_forms (спека «Шаг 2 — подпись и дата колонки») +REAL_FORMS = [ + ("на 30 сентября 2025 г.", date(2025, 9, 30)), + ("На 30 июня\n2025 г", date(2025, 6, 30)), + ("31 декабря 2024 года", date(2024, 12, 31)), + ("30 сентября 2025 r.", date(2025, 9, 30)), + ("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), + ("I квартал 2026", date(2026, 3, 31)), + ("1-й кв. 2026", date(2026, 3, 31)), + ("первый квартал 2025 г.", date(2025, 3, 31)), + ("1 полугодие 2026", date(2026, 6, 30)), + ("1 п/г 2026", date(2026, 6, 30)), + ("9 месяцев 2025", date(2025, 9, 30)), + ("9 мес. 2025", date(2025, 9, 30)), + ("За январь - июнь\n2025 г.", date(2025, 6, 30)), + ("январь – сентябрь 2025", date(2025, 9, 30)), + ("за 2025 г.", date(2025, 12, 31)), + ("2024 год", date(2024, 12, 31)), + ("30.09.2025", date(2025, 9, 30)), + ("2025 03 31", date(2025, 3, 31)), + ("30/09/25", date(2025, 9, 30)), + ("2 0 2 4", date(2024, 12, 31)), + ("30 09 2 0 2 5", date(2025, 9, 30)), + ("30092025", date(2025, 9, 30)), + ("20250930", date(2025, 9, 30)), + ("30 09 25", date(2025, 9, 30)), + ("30.09.2025 0:00:00", date(2025, 9, 30)), + ("2|0|2|4|", date(2024, 12, 31)), + ("3l.12.2О24", date(2024, 12, 31)), +] + + +@pytest.mark.parametrize(("raw", "expected"), REAL_FORMS) +def test_parses_real_forms(rules: Rules, raw: str, expected: date) -> None: + assert parse_date(fix_lookalikes(raw, rules.lookalikes), rules) == expected + label = parse_period_label(Cell(raw, None, 100.0, "row:0"), rules) + assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, expected) + + +@pytest.mark.parametrize("raw", ["Общество с ограниченной ответственностью", "", "+", "тыс. руб.", "1999999999999"]) +def test_not_a_date(rules: Rules, raw: str) -> None: + assert parse_date(raw, rules) is None + + +def test_long_text_not_parsed(rules: Rules) -> None: + assert parse_date("на 30 сентября 2025 г." + "x" * 200, rules) is None + + +@pytest.mark.parametrize( + ("raw", "kind"), + [ + ("На отчетную дату", PeriodKind.CURRENT), + ("За отчётный период", PeriodKind.CURRENT), + ("На 31 декабря предыдущего года", PeriodKind.PREV_YEAR_END), + ("На 31 декабря года, предшествующего предыдущему", PeriodKind.PREV_PREV_YEAR_END), + ("За аналогичный период предыдущего года", PeriodKind.PREV_PERIOD), + ], +) +def test_relative_labels(rules: Rules, raw: str, kind: PeriodKind) -> None: + assert parse_period_label(Cell(raw, None, 100.0, "row:0"), rules).kind is kind + + +def test_exclusion_and_excel_date_value(rules: Rules) -> None: + assert parse_period_label(Cell("Изменение, %", None, 100.0, "A!F1"), rules).excluded + label = parse_period_label(Cell("30.09.2025", date(2025, 9, 30), 100.0, "A!D1"), rules) + assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, date(2025, 9, 30)) + assert parse_period_label(Cell("2024", None, 100.0, "A!E1"), rules).bare_year + + +def test_empty_label(rules: Rules) -> None: + assert parse_period_label(None, rules).kind is PeriodKind.NONE + label = parse_period_label(Cell(" ", None, 100.0, "A!G1"), rules) + assert (label.kind, label.date, label.excluded, label.bare_year) == (PeriodKind.NONE, None, False, False) + + +def test_resolve_relative_and_expected() -> None: + eff = date(2025, 9, 30) + assert resolve_relative(PeriodKind.PREV_YEAR_END, eff) == date(2024, 12, 31) + assert resolve_relative(PeriodKind.PREV_PREV_YEAR_END, eff) == date(2023, 12, 31) + assert resolve_relative(PeriodKind.PREV_PERIOD, date(2024, 2, 29)) == date(2023, 2, 28) + assert resolve_relative(PeriodKind.CURRENT, None) is None + assert resolve_relative(PeriodKind.ABSOLUTE, eff) is None and resolve_relative(PeriodKind.NONE, eff) is None + assert expected_dates(eff) == frozenset( + {eff, date(2024, 9, 30), date(2023, 9, 30), date(2024, 12, 31), date(2023, 12, 31)} + )