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:
parent
43ee753d74
commit
8990ab26fe
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user