feat(osv): период-диапазон месяцев «Октябрь 2024 г. – Сентябрь 2025 г.» (скользящий, не с начала года)

Раньше первый месяц диапазона давал «2024 Q4, нарастающий». Теперь словарь periods.yaml (months, range)
разбирает диапазон: период режется по году конца, январь–конец квартала — нарастающий, ровно квартал — квартальный,
остальное (скользящие 12 месяцев) — год конца, квартал и is_cumulative = null + предупреждение «не с начала года».
Контракт v1.1 не менялся.

Refs #11

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 12:49:11 +04:00
parent e3eeda8d84
commit 30f78c42e7
5 changed files with 238 additions and 2 deletions

View File

@ -25,3 +25,11 @@ year: '(?<!\d)(19|20)\d{2}(?!\d)'
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»; # Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
# без совпадения период идёт до конца строки # без совпадения период идёт до конца строки
end: '\bг\.|\bгода?\b' end: '\bг\.|\bгода?\b'
# Названия месяцев в любом падеже; имя группы — номер месяца (m01 … m12): по нему код узнаёт месяц без списка слов
months: '(?P<m01>январ)|(?P<m02>феврал)|(?P<m03>март)|(?P<m04>апрел)|(?P<m05>ма[йя])|(?P<m06>июн)|(?P<m07>июл)|(?P<m08>август)|(?P<m09>сентябр)|(?P<m10>октябр)|(?P<m11>ноябр)|(?P<m12>декабр)'
# Диапазон месяцев с годом конца: «Октябрь 2024 г. - Сентябрь 2025 г.», «с октября 2024 по сентябрь 2025 г.»,
# «Апрель - Июнь 2025 г.». Группы: m1/y1 — месяц и год начала (год может отсутствовать), m2/y2 — месяц и год конца.
# Диапазон от января без года начала («январь-март 2025 г.») берут правила выше; здесь он не учитывается (код).
range: '(?P<m1>(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P<y1>(19|20)\d{2})?\s*(г\.?|года?)?\s*(по\s+|до\s+)?(?P<m2>(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P<y2>(19|20)\d{2})'

View File

@ -10,17 +10,18 @@ import bisect
import re import re
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from typing import Any, Literal, get_args
from src.contract.common import DataType from src.contract.common import DataType
from src.contract.result import OsvGroup from src.contract.result import OsvGroup
from src.parser.detect import TableHeader from src.parser.detect import TableHeader
from src.parser.grid import Sheet from src.parser.grid import Sheet
from src.parser.rules import GroupType, Pattern, Rules, first_match, normalize_text from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, normalize_text
__all__ = ["DocHeader", "build_group_schema", "parse_doc_header"] __all__ = ["DocHeader", "build_group_schema", "parse_doc_header"]
Quarter = Literal["Q1", "Q2", "Q3", "Q4"] Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
QUARTERS: tuple[Quarter, ...] = get_args(Quarter)
HeaderVariant = Literal[1, 2] HeaderVariant = Literal[1, 2]
# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1» # Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
@ -147,12 +148,33 @@ class _PeriodHit:
text: str text: str
def _cut_month_range(tail: str, rules: Rules) -> str | None:
"""Период — диапазон месяцев с годами («Октябрь 2024 г. - Сентябрь 2025 г.»): до года конца и «г.» после него.
Первое «г.» диапазона (после года начала) период не заканчивает: «Октябрь 2024 г.» — лишь его начало.
"""
if rules.periods.month_range(tail) is None:
return None
found = rules.periods.range.search(tail)
if found is None:
return None
stop = _source_offset(tail, found.end())
rest = tail[stop:]
mark = rules.periods.end.search(rest)
if mark is not None and mark.start() == 0:
stop += _source_offset(rest, mark.end())
return tail[:stop].strip()
def _cut_period(tail: str, rules: Rules) -> str: def _cut_period(tail: str, rules: Rules) -> str:
"""Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт. """Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт.
Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно
начало не узнано, период — весь хвост до конца строки. начало не узнано, период — весь хвост до конца строки.
""" """
ranged = _cut_month_range(tail, rules)
if ranged is not None:
return ranged
start = 0 start = 0
while start < len(tail): while start < len(tail):
end = rules.periods.end.search(tail[start:]) end = rules.periods.end.search(tail[start:])
@ -252,7 +274,47 @@ class _Period:
is_cumulative: bool | None is_cumulative: bool | None
_MONTHS_IN_QUARTER = 3
def _quarter_of_end(month: int) -> Quarter | None:
"""Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц)."""
if month % _MONTHS_IN_QUARTER:
return None
return QUARTERS[month // _MONTHS_IN_QUARTER - 1]
def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Period:
"""Период-диапазон месяцев: с января по конец квартала — нарастающий, ровно квартал — квартальный; остальное
(скользящие 12 месяцев, полгода с апреля) — квартал и признак не определены: год — год конца диапазона.
Скользящий период нельзя выдать ни за квартальный, ни за нарастающий: ядро привяжет его к версии как не тот
документ. `null` + предупреждение оставляют решение аналитику (OQ-131).
"""
quarter = _quarter_of_end(span.end_month)
same_year = span.start_year == span.end_year
if quarter is not None and same_year and span.start_month == 1:
return _Period(span.end_year, quarter, True)
if quarter is not None and same_year and span.months == _MONTHS_IN_QUARTER:
return _Period(span.end_year, quarter, False)
if span.start_month != 1 or not same_year:
warnings.append(
f"период «{text}»: скользящий период {span.months} мес. "
f"(с {span.start_month:02d}.{span.start_year} по {span.end_month:02d}.{span.end_year}), не с начала года: "
"квартал и признак нарастающего итога не определены"
)
else:
warnings.append(
f"период «{text}»: конец диапазона не совпадает с концом квартала: "
"квартал и признак нарастающего итога не определены"
)
return _Period(span.end_year, None, None)
def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
span = rules.periods.month_range(text)
if span is not None:
return _month_range_period(span, text, warnings)
rule = rules.periods.match(text) rule = rules.periods.match(text)
year = rules.periods.year.search(text) year = rules.periods.year.search(text)
if rule is None: if rule is None:

View File

@ -24,6 +24,7 @@ from src.contract.common import DataType
__all__ = [ __all__ = [
"DEFAULT_RULES_DIR", "DEFAULT_RULES_DIR",
"DataTypeRules", "DataTypeRules",
"MonthRange",
"OsvGroupRules", "OsvGroupRules",
"OsvHeaderRules", "OsvHeaderRules",
"Pattern", "Pattern",
@ -97,6 +98,11 @@ class Pattern:
"""Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны.""" """Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
return self._regex.search(normalized) return self._regex.search(normalized)
@property
def group_names(self) -> frozenset[str]:
"""Имена именованных групп паттерна: словарь требует их у записей месяцев и диапазона."""
return frozenset(self._regex.groupindex)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"Pattern({self.source!r})" return f"Pattern({self.source!r})"
@ -162,10 +168,67 @@ class PeriodRule(_Model):
is_cumulative: bool is_cumulative: bool
_MONTHS_IN_YEAR = 12
_RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"})
_MONTH_GROUPS = frozenset(f"m{number:02d}" for number in range(1, _MONTHS_IN_YEAR + 1))
@dataclass(frozen=True)
class MonthRange:
"""Диапазон месяцев периода: «Октябрь 2024 г. – Сентябрь 2025 г.» -> (10, 2024, 9, 2025)."""
start_month: int
start_year: int
end_month: int
end_year: int
@property
def months(self) -> int:
"""Число месяцев диапазона включительно: октябрь 2024 – сентябрь 2025 = 12."""
return (self.end_year - self.start_year) * _MONTHS_IN_YEAR + self.end_month - self.start_month + 1
class PeriodRules(_Model): class PeriodRules(_Model):
rules: list[PeriodRule] rules: list[PeriodRule]
year: Pattern year: Pattern
end: Pattern end: Pattern
# Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2
months: Pattern
range: Pattern
@model_validator(mode="after")
def _check_month_patterns(self) -> "PeriodRules":
if self.months.group_names != _MONTH_GROUPS:
raise ValueError("months: ожидаются именованные группы m01…m12 (по одной на месяц)")
missing = _RANGE_GROUPS - self.range.group_names
if missing:
raise ValueError(f"range: нет обязательных именованных групп {sorted(missing)}")
return self
def month_number(self, text: str) -> int | None:
"""Номер месяца по названию в любом падеже («октября» -> 10); не месяц — `None`."""
found = self.months.search(text)
return None if found is None or found.lastgroup is None else int(found.lastgroup.removeprefix("m"))
def month_range(self, text: str) -> MonthRange | None:
"""Диапазон месяцев с годом конца: начало без года — в том же году, а если месяц начала позже месяца конца
(октябрь – сентябрь) — в предыдущем.
Диапазон, который начинается с января и не называет год начала («январь-март 2025 г.»), словарь периодов
разбирает своими правилами (`match`), поэтому здесь это `None`.
"""
found = self.range.search(text)
if found is None:
return None
start, end = self.month_number(found.group("m1")), self.month_number(found.group("m2"))
if start is None or end is None:
return None
end_year = int(found.group("y2"))
year_text = found.group("y1")
if year_text is None and start == 1:
return None
start_year = int(year_text) if year_text is not None else end_year - (1 if start > end else 0)
return MonthRange(start, start_year, end, end_year)
def match(self, text: str) -> PeriodRule | None: def match(self, text: str) -> PeriodRule | None:
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим.""" """Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""

View File

@ -427,6 +427,57 @@ def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, qua
assert header.warnings == () assert header.warnings == ()
# --- Диапазон месяцев с годами: скользящий период (issue #11 п. 1, OQ-131) ---
@pytest.mark.parametrize(
"period",
[
"Октябрь 2024 г. - Сентябрь 2025 г.",
"с октября 2024 г. по сентябрь 2025 г.",
"октябрь 2024 - сентябрь 2025",
"Октябрь 2024 г. - Сентябрь 2025 г. (по подразделениям)",
],
)
def test_rolling_period_is_not_read_as_first_month_quarter(rules: Rules, period: str) -> None:
header = _variant1(rules, title=_title(period))
assert header.period_text == period.removesuffix(" (по подразделениям)")
# Конец диапазона определяет год; квартал и «нарастающий» не определены: 12 месяцев — не квартал и не с начала года
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None)
assert any("не с начала года" in warning and "12 мес" in warning for warning in header.warnings)
def test_month_range_of_one_quarter_is_a_quarter_not_cumulative(rules: Rules) -> None:
header = _variant1(rules, title=_title("Апрель 2025 г. - Июнь 2025 г."))
assert header.period_text == "Апрель 2025 г. - Июнь 2025 г."
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q2", False)
assert header.warnings == ()
def test_month_range_inside_year_not_from_january_is_not_a_quarter(rules: Rules) -> None:
header = _variant1(rules, title=_title("Апрель 2025 г. - Сентябрь 2025 г."))
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None)
assert any("не с начала года" in warning for warning in header.warnings)
def test_month_range_from_january_to_quarter_end_stays_cumulative(rules: Rules) -> None:
header = _variant1(rules, title=_title("Январь 2025 г. - Сентябрь 2025 г."))
assert header.period_text == "Январь 2025 г. - Сентябрь 2025 г."
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q3", True)
assert header.warnings == ()
def test_month_range_from_january_to_mid_quarter_has_no_quarter(rules: Rules) -> None:
header = _variant1(rules, title=_title("Январь 2025 г. - Май 2025 г."))
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None)
assert any("период" in warning for warning in header.warnings)
# --- Даты числами / датами (xlsb) (T5.5) --- # --- Даты числами / датами (xlsb) (T5.5) ---

View File

@ -184,6 +184,58 @@ def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules
assert end.search("с 01.01.2025 по 31.03.2025") is None assert end.search("с 01.01.2025 по 31.03.2025") is None
@pytest.mark.parametrize(
("text", "expected"),
[("январь", 1), ("Мая", 5), ("мая", 5), ("октября", 10), ("Декабрь", 12), ("квартал", None), ("", None)],
)
def test_month_number_by_name_in_any_case(rules: Rules, text: str, expected: int | None) -> None:
assert rules.periods.month_number(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("Октябрь 2024 г. - Сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
("с октября 2024 по сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
("апрель - июнь 2025 г.", (4, 2025, 6, 2025, 3)),
("сентябрь - май 2025", (9, 2024, 5, 2025, 9)),
("Январь 2025 г. - Сентябрь 2025 г.", (1, 2025, 9, 2025, 9)),
],
)
def test_month_range_with_years(rules: Rules, text: str, expected: tuple[int, int, int, int, int]) -> None:
found = rules.periods.month_range(text)
assert found is not None
assert (found.start_month, found.start_year, found.end_month, found.end_year, found.months) == expected
@pytest.mark.parametrize("text", ["январь-март 2025 г.", "9 месяцев 2025 г.", "3 квартал 2025 г.", "2025 г.", ""])
def test_month_range_is_none_for_other_period_texts(rules: Rules, text: str) -> None:
assert rules.periods.month_range(text) is None
def test_periods_months_without_all_month_groups_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["months"] = "(?P<m01>январ)"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*m01"):
load_rules(target)
def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["range"] = "январ"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*range"):
load_rules(target)
def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None: def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
target = _copy_rules(tmp_path) target = _copy_rules(tmp_path)
path = target / "periods.yaml" path = target / "periods.yaml"