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` и предупреждение на русском.
"""
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,

View File

@ -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,

View File

@ -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")

View File

@ -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