feat(osv): границы периода period_start/period_end и is_from_year_start в шапке (контракт v1.2, OQ-131)

Refs #14

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 16:00:32 +04:00
parent 43ee753d74
commit 8990ab26fe
4 changed files with 86 additions and 8 deletions

View File

@ -6,6 +6,8 @@
пустая строка (для полей контракта-строк) или `None` и предупреждение на русском. пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
""" """
import calendar
import datetime
import re import re
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
@ -43,6 +45,8 @@ class DocHeader:
period_year: int | None period_year: int | None
period_quarter: Quarter | None period_quarter: Quarter | None
is_cumulative: bool | None is_cumulative: bool | None
period_start: datetime.date | None
period_end: datetime.date | None
data_type: DataType | None data_type: DataType | None
currency: str | None currency: str | None
header_variant: HeaderVariant header_variant: HeaderVariant
@ -303,11 +307,29 @@ class _Period:
year: int | None year: int | None
quarter: Quarter | None quarter: Quarter | None
is_cumulative: bool | None is_cumulative: bool | None
start: datetime.date | None = None
end: datetime.date | None = None
_MONTHS_IN_QUARTER = 3 _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: def _quarter_of_end(month: int) -> Quarter | None:
"""Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц).""" """Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц)."""
if month % _MONTHS_IN_QUARTER: if month % _MONTHS_IN_QUARTER:
@ -323,11 +345,13 @@ def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Pe
документ. `null` + предупреждение оставляют решение аналитику (OQ-131). документ. `null` + предупреждение оставляют решение аналитику (OQ-131).
""" """
quarter = _quarter_of_end(span.end_month) 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 same_year = span.start_year == span.end_year
if quarter is not None and same_year and span.start_month == 1: 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: 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: if span.start_month != 1 or not same_year:
warnings.append( warnings.append(
f"период «{text}»: скользящий период {span.months} мес. " f"период «{text}»: скользящий период {span.months} мес. "
@ -339,7 +363,7 @@ def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Pe
f"период «{text}»: конец диапазона не совпадает с концом квартала: " 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: 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}»: год не найден") warnings.append(f"период «{text}»: год не найден")
# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить # ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить
# по данным (opening Q3 == opening Q1 -> нарастающая) # по данным (opening Q3 == opening Q1 -> нарастающая)
return _Period( year_value = None if year is None else int(year.group(0))
year=None if year is None else int(year.group(0)), quarter = None if rule is None else rule.quarter
quarter=None if rule is None else rule.quarter, is_cumulative = None if rule is None else rule.is_cumulative
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: 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_year=period.year,
period_quarter=period.quarter, period_quarter=period.quarter,
is_cumulative=period.is_cumulative, is_cumulative=period.is_cumulative,
period_start=period.start,
period_end=period.end,
data_type=data_type, data_type=data_type,
currency=currency, currency=currency,
header_variant=variant, header_variant=variant,

View File

@ -5,6 +5,7 @@
только подставляется в `locator` и сообщения, поиск по нему не ведётся. только подставляется в `locator` и сообщения, поиск по нему не ведётся.
""" """
import datetime
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from dataclasses import replace from dataclasses import replace
@ -44,6 +45,10 @@ def _columns(header: TableHeader) -> dict[str, str]:
return columns 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: def _header(doc: DocHeader) -> OsvHeader:
return OsvHeader( return OsvHeader(
organization_name=doc.organization_name, organization_name=doc.organization_name,
@ -53,6 +58,9 @@ def _header(doc: DocHeader) -> OsvHeader:
period_year=doc.period_year, period_year=doc.period_year,
period_quarter=doc.period_quarter, period_quarter=doc.period_quarter,
is_cumulative=doc.is_cumulative, 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, data_type=doc.data_type,
currency=doc.currency, currency=doc.currency,
header_variant=doc.header_variant, header_variant=doc.header_variant,

View File

@ -5,6 +5,7 @@
ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту. ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту.
""" """
import datetime
import io import io
import itertools import itertools
import shutil import shutil
@ -236,6 +237,17 @@ def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None:
assert_valid_result(outcome) 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: def test_missing_total_needs_check(rules: Rules) -> None:
"""R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`.""" """R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`."""
outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX") outcome = book(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX")

View File

@ -78,6 +78,8 @@ def test_variant1_header_fields(rules: Rules) -> None:
period_year=2024, period_year=2024,
period_quarter="Q3", period_quarter="Q3",
is_cumulative=False, is_cumulative=False,
period_start=datetime.date(2024, 7, 1),
period_end=datetime.date(2024, 9, 30),
data_type="BU", data_type="BU",
currency="RUB", currency="RUB",
header_variant=1, 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 header.inn_col is None
assert all(g.has_inn is None for g in groups) 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