From 30f78c42e7a96ef92a42a0151a9088718fb0381e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 12:49:11 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4-=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=8F=D1=86=D0=B5=D0=B2=20=C2=AB=D0=9E=D0=BA?= =?UTF-8?q?=D1=82=D1=8F=D0=B1=D1=80=D1=8C=202024=20=D0=B3.=20=E2=80=93=20?= =?UTF-8?q?=D0=A1=D0=B5=D0=BD=D1=82=D1=8F=D0=B1=D1=80=D1=8C=202025=20?= =?UTF-8?q?=D0=B3.=C2=BB=20(=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=B7=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=B9,=20=D0=BD=D0=B5=20=D1=81=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=D0=BB=D0=B0=20=D0=B3=D0=BE=D0=B4=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше первый месяц диапазона давал «2024 Q4, нарастающий». Теперь словарь periods.yaml (months, range) разбирает диапазон: период режется по году конца, январь–конец квартала — нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — год конца, квартал и is_cumulative = null + предупреждение «не с начала года». Контракт v1.1 не менялся. Refs #11 Co-Authored-By: Claude Sonnet 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/rules/periods.yaml | 8 ++++ api/src/parser/header.py | 66 +++++++++++++++++++++++++++- api/src/parser/rules.py | 63 ++++++++++++++++++++++++++ api/tests/unit/parser/test_header.py | 51 +++++++++++++++++++++ api/tests/unit/parser/test_rules.py | 52 ++++++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml index 0f4e7dc..2362e93 100644 --- a/api/rules/periods.yaml +++ b/api/rules/periods.yaml @@ -25,3 +25,11 @@ year: '(? «3 квартал 2024 г.»; # без совпадения период идёт до конца строки end: '\bг\.|\bгода?\b' + +# Названия месяцев в любом падеже; имя группы — номер месяца (m01 … m12): по нему код узнаёт месяц без списка слов +months: '(?Pянвар)|(?Pфеврал)|(?Pмарт)|(?Pапрел)|(?Pма[йя])|(?Pиюн)|(?Pиюл)|(?Pавгуст)|(?Pсентябр)|(?Pоктябр)|(?Pноябр)|(?Pдекабр)' + +# Диапазон месяцев с годом конца: «Октябрь 2024 г. - Сентябрь 2025 г.», «с октября 2024 по сентябрь 2025 г.», +# «Апрель - Июнь 2025 г.». Группы: m1/y1 — месяц и год начала (год может отсутствовать), m2/y2 — месяц и год конца. +# Диапазон от января без года начала («январь-март 2025 г.») берут правила выше; здесь он не учитывается (код). +range: '(?P(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P(19|20)\d{2})?\s*(г\.?|года?)?\s*(по\s+|до\s+)?(?P(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P(19|20)\d{2})' diff --git a/api/src/parser/header.py b/api/src/parser/header.py index f645d79..e4ad61c 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -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: diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index a3c3788..603aab5 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -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 — от специфичных к общим.""" diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index 238fe7a..9993cfb 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -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) --- diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index e914873..21c074c 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -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январ)" + 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"