From 8990ab26fe7a7c363d63eb48f461377f98eaac29 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 16:00:32 +0400 Subject: [PATCH] =?UTF-8?q?feat(osv):=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D0=B0=20per?= =?UTF-8?q?iod=5Fstart/period=5Fend=20=D0=B8=20is=5Ffrom=5Fyear=5Fstart=20?= =?UTF-8?q?=D0=B2=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B5=20(=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BA=D1=82=20v1.2,=20OQ-131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #14 Co-Authored-By: Claude Sonnet Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/src/parser/header.py | 42 ++++++++++++++++++----- api/src/parser/osv.py | 8 +++++ api/tests/unit/parser/test_book_parser.py | 12 +++++++ api/tests/unit/parser/test_header.py | 32 +++++++++++++++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 2c9c03a..a4d90bf 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -6,6 +6,8 @@ пустая строка (для полей контракта-строк) или `None` и предупреждение на русском. """ +import calendar +import datetime import re from collections.abc import Iterable from dataclasses import dataclass @@ -43,6 +45,8 @@ class DocHeader: period_year: int | None period_quarter: Quarter | None is_cumulative: bool | None + period_start: datetime.date | None + period_end: datetime.date | None data_type: DataType | None currency: str | None header_variant: HeaderVariant @@ -303,11 +307,29 @@ class _Period: year: int | None quarter: Quarter | None is_cumulative: bool | None + start: datetime.date | None = None + end: datetime.date | None = None _MONTHS_IN_QUARTER = 3 +def _month_end(year: int, month: int) -> datetime.date: + return datetime.date(year, month, calendar.monthrange(year, month)[1]) + + +def _bounds( + year: int | None, quarter: Quarter | None, is_cumulative: bool | None +) -> tuple[datetime.date | None, datetime.date | None]: + """Границы квартального/годового периода: конец — конец квартала; начало — 1 января у нарастающего, иначе первый + месяц квартала; без года или квартала — неизвестны.""" + if year is None or quarter is None: + return None, None + end_month = (QUARTERS.index(quarter) + 1) * _MONTHS_IN_QUARTER + start_month = 1 if is_cumulative else end_month - _MONTHS_IN_QUARTER + 1 + return datetime.date(year, start_month, 1), _month_end(year, end_month) + + def _quarter_of_end(month: int) -> Quarter | None: """Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц).""" if month % _MONTHS_IN_QUARTER: @@ -323,11 +345,13 @@ def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Pe документ. `null` + предупреждение оставляют решение аналитику (OQ-131). """ quarter = _quarter_of_end(span.end_month) + start = datetime.date(span.start_year, span.start_month, 1) + end = _month_end(span.end_year, 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) + return _Period(span.end_year, quarter, True, start, end) if quarter is not None and same_year and span.months == _MONTHS_IN_QUARTER: - return _Period(span.end_year, quarter, False) + return _Period(span.end_year, quarter, False, start, end) if span.start_month != 1 or not same_year: warnings.append( f"период «{text}»: скользящий период {span.months} мес. " @@ -339,7 +363,7 @@ def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Pe f"период «{text}»: конец диапазона не совпадает с концом квартала: " "квартал и признак нарастающего итога не определены" ) - return _Period(span.end_year, None, None) + return _Period(span.end_year, None, None, start, end) def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: @@ -354,11 +378,11 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: warnings.append(f"период «{text}»: год не найден") # ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить # по данным (opening Q3 == opening Q1 -> нарастающая) - return _Period( - year=None if year is None else int(year.group(0)), - quarter=None if rule is None else rule.quarter, - is_cumulative=None if rule is None else rule.is_cumulative, - ) + year_value = None if year is None else int(year.group(0)) + quarter = None if rule is None else rule.quarter + is_cumulative = None if rule is None else rule.is_cumulative + start, end = _bounds(year_value, quarter, is_cumulative) + return _Period(year_value, quarter, is_cumulative, start, end) def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> DataType | None: @@ -419,6 +443,8 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead period_year=period.year, period_quarter=period.quarter, is_cumulative=period.is_cumulative, + period_start=period.start, + period_end=period.end, data_type=data_type, currency=currency, header_variant=variant, diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 98ef895..b5d8191 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -5,6 +5,7 @@ только подставляется в `locator` и сообщения, поиск по нему не ведётся. """ +import datetime from collections.abc import Iterable, Sequence from dataclasses import replace @@ -44,6 +45,10 @@ def _columns(header: TableHeader) -> dict[str, str]: return columns +def _from_year_start(start: datetime.date | None) -> bool | None: + return None if start is None else (start.month, start.day) == (1, 1) + + def _header(doc: DocHeader) -> OsvHeader: return OsvHeader( organization_name=doc.organization_name, @@ -53,6 +58,9 @@ def _header(doc: DocHeader) -> OsvHeader: period_year=doc.period_year, period_quarter=doc.period_quarter, is_cumulative=doc.is_cumulative, + period_start=doc.period_start, + period_end=doc.period_end, + is_from_year_start=_from_year_start(doc.period_start), data_type=doc.data_type, currency=doc.currency, header_variant=doc.header_variant, diff --git a/api/tests/unit/parser/test_book_parser.py b/api/tests/unit/parser/test_book_parser.py index 98c71f8..5b4ffda 100644 --- a/api/tests/unit/parser/test_book_parser.py +++ b/api/tests/unit/parser/test_book_parser.py @@ -5,6 +5,7 @@ ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту. """ +import datetime import io import itertools import shutil @@ -236,6 +237,17 @@ def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None: assert_valid_result(outcome) +def test_period_bounds_reach_osv_header(rules: Rules) -> None: + """Контракт v1.2: границы периода и признак «с начала года» заполнены парсером ОСВ.""" + header = only_document(book(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")).header + + assert (header.period_start, header.period_end, header.is_from_year_start) == ( + datetime.date(2025, 1, 1), + datetime.date(2025, 3, 31), + True, + ) + + def test_missing_total_needs_check(rules: Rules) -> None: """R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`.""" outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX") diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index 9993cfb..fdd9dce 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -78,6 +78,8 @@ def test_variant1_header_fields(rules: Rules) -> None: period_year=2024, period_quarter="Q3", is_cumulative=False, + period_start=datetime.date(2024, 7, 1), + period_end=datetime.date(2024, 9, 30), data_type="BU", currency="RUB", header_variant=1, @@ -529,3 +531,33 @@ def test_variant2_without_inn_column_leaves_has_inn_unset(rules: Rules) -> None: assert header.inn_col is None assert all(g.has_inn is None for g in groups) + + +# --- Границы периода (контракт v1.2, OQ-131) --- + + +@pytest.mark.parametrize( + ("period_text", "start", "end", "from_year_start"), + [ + ("1 квартал 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31), True), + ("3 квартал 2025 г.", datetime.date(2025, 7, 1), datetime.date(2025, 9, 30), False), + ("9 месяцев 2025 г.", datetime.date(2025, 1, 1), datetime.date(2025, 9, 30), True), + ("2024 г.", datetime.date(2024, 1, 1), datetime.date(2024, 12, 31), True), + ("Октябрь 2024 г. - Сентябрь 2025 г.", datetime.date(2024, 10, 1), datetime.date(2025, 9, 30), False), + ("Апрель - Июнь 2025 г.", datetime.date(2025, 4, 1), datetime.date(2025, 6, 30), False), + ], +) +def test_period_bounds( + rules: Rules, period_text: str, start: datetime.date, end: datetime.date, from_year_start: bool +) -> None: + doc = _variant1(rules, title=_title(period_text)) + + assert doc.period_start is not None + assert (doc.period_start, doc.period_end) == (start, end) + assert (doc.period_start == datetime.date(doc.period_start.year, 1, 1)) is from_year_start + + +def test_period_bounds_unknown_when_period_unrecognised(rules: Rules) -> None: + doc = _variant1(rules, title=_title("за неизвестно что")) + + assert doc.period_start is None and doc.period_end is None