feat: словарь периодов — даты, относительные подписи бланка 66н, исключения колонок

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:23:13 +04:00
parent aed37cc189
commit 19f70fea65
2 changed files with 370 additions and 0 deletions

276
arm_statements/dates.py Normal file
View File

@ -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),
}
)

View File

@ -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)}
)