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 г.»;
|
# Конец текста периода в строке шапки: «за 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})'
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 — от специфичных к общим."""
|
||||||
|
|||||||
@ -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) ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user