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 г.»;
# без совпадения период идёт до конца строки
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
from collections.abc import Iterable
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.result import OsvGroup
from src.parser.detect import TableHeader
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"]
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
QUARTERS: tuple[Quarter, ...] = get_args(Quarter)
HeaderVariant = Literal[1, 2]
# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
@ -147,12 +148,33 @@ class _PeriodHit:
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:
"""Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт.
Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно
начало не узнано, период — весь хвост до конца строки.
"""
ranged = _cut_month_range(tail, rules)
if ranged is not None:
return ranged
start = 0
while start < len(tail):
end = rules.periods.end.search(tail[start:])
@ -252,7 +274,47 @@ class _Period:
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:
span = rules.periods.month_range(text)
if span is not None:
return _month_range_period(span, text, warnings)
rule = rules.periods.match(text)
year = rules.periods.year.search(text)
if rule is None:

View File

@ -24,6 +24,7 @@ from src.contract.common import DataType
__all__ = [
"DEFAULT_RULES_DIR",
"DataTypeRules",
"MonthRange",
"OsvGroupRules",
"OsvHeaderRules",
"Pattern",
@ -97,6 +98,11 @@ class Pattern:
"""Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
return self._regex.search(normalized)
@property
def group_names(self) -> frozenset[str]:
"""Имена именованных групп паттерна: словарь требует их у записей месяцев и диапазона."""
return frozenset(self._regex.groupindex)
def __repr__(self) -> str:
return f"Pattern({self.source!r})"
@ -162,10 +168,67 @@ class PeriodRule(_Model):
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):
rules: list[PeriodRule]
year: 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:
"""Первое правило, паттерн которого совпал с текстом периода; порядок в 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 == ()
# --- Диапазон месяцев с годами: скользящий период (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) ---

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
@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:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"