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:
parent
e3eeda8d84
commit
30f78c42e7
@ -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})'
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 — от специфичных к общим."""
|
||||
|
||||
@ -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) ---
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user