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:
parent
aed37cc189
commit
19f70fea65
276
arm_statements/dates.py
Normal file
276
arm_statements/dates.py
Normal 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),
|
||||||
|
}
|
||||||
|
)
|
||||||
94
arm_statements/tests/test_dates.py
Normal file
94
arm_statements/tests/test_dates.py
Normal 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)}
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user