286 lines
9.6 KiB
Python
286 lines
9.6 KiB
Python
"""Pure deterministic rules for monthly channel report payloads."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
import re
|
|
from collections import defaultdict
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from typing import Dict, Iterable, List, Sequence, Tuple
|
|
|
|
from monthly_reports.contracts import (
|
|
KB_SHEET,
|
|
STANDARD_SHEETS,
|
|
ChannelObservation,
|
|
ChannelSheet,
|
|
DailyVersionPin,
|
|
ErrorCode,
|
|
MonthlyFact,
|
|
MonthlyReport,
|
|
MonthlyReportError,
|
|
MonthlySnapshot,
|
|
)
|
|
|
|
|
|
INVALID_SHEET_CHARS = re.compile(r"[:\\/?*\[\]]")
|
|
|
|
|
|
def month_bounds(year: int, month: int) -> Tuple[date, date]:
|
|
if year < 1900 or year > 9999 or month < 1 or month > 12:
|
|
raise MonthlyReportError(ErrorCode.REQUEST_INVALID, "report month is invalid")
|
|
return date(year, month, 1), date(year, month, calendar.monthrange(year, month)[1])
|
|
|
|
|
|
def validate_as_of(year: int, month: int, as_of_date: date) -> None:
|
|
start, end = month_bounds(year, month)
|
|
if as_of_date < start or as_of_date > end:
|
|
raise MonthlyReportError(
|
|
ErrorCode.REQUEST_INVALID,
|
|
"as-of date must fall inside the report month",
|
|
)
|
|
|
|
|
|
def _valid_sheet_name(value: str) -> bool:
|
|
return (
|
|
bool(value)
|
|
and value == value.strip()
|
|
and len(value) <= 31
|
|
and INVALID_SHEET_CHARS.search(value) is None
|
|
)
|
|
|
|
|
|
def _append_unique(target: List[str], seen: set[str], values: Iterable[str]) -> None:
|
|
for value in values:
|
|
if not _valid_sheet_name(value):
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"source channel worksheet name is invalid",
|
|
)
|
|
if value not in seen:
|
|
target.append(value)
|
|
seen.add(value)
|
|
|
|
|
|
def channel_order(snapshot: MonthlySnapshot) -> Tuple[str, ...]:
|
|
ordered: List[str] = []
|
|
seen: set[str] = set()
|
|
_append_unique(ordered, seen, STANDARD_SHEETS)
|
|
_append_unique(
|
|
ordered,
|
|
seen,
|
|
(name for name in snapshot.preferred_channel_order if name not in STANDARD_SHEETS),
|
|
)
|
|
observations: Sequence[ChannelObservation] = sorted(
|
|
snapshot.channel_observations,
|
|
key=lambda item: (
|
|
item.business_date,
|
|
item.worksheet_order is None,
|
|
item.worksheet_order if item.worksheet_order is not None else 2**31,
|
|
item.worksheet,
|
|
),
|
|
)
|
|
_append_unique(ordered, seen, (item.worksheet for item in observations))
|
|
_append_unique(
|
|
ordered,
|
|
seen,
|
|
sorted({fact.channel_key for fact in snapshot.facts if fact.channel_key not in seen}),
|
|
)
|
|
return tuple(ordered)
|
|
|
|
|
|
def _require_nonnegative(value: int, label: str) -> None:
|
|
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
raise MonthlyReportError(ErrorCode.SOURCE_INVALID, f"{label} is invalid")
|
|
|
|
|
|
def _validate_fact(fact: MonthlyFact, start: date, as_of_date: date) -> None:
|
|
if (
|
|
fact.daily_record_id <= 0
|
|
or fact.daily_version_id <= 0
|
|
or fact.business_date < start
|
|
or fact.business_date > as_of_date
|
|
or fact.arrival != fact.business_date
|
|
or fact.departure < fact.arrival
|
|
or fact.nights != (fact.departure - fact.arrival).days
|
|
or not _valid_sheet_name(fact.channel_key)
|
|
or fact.no_of_rooms <= 0
|
|
):
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly source fact shape is invalid",
|
|
)
|
|
_require_nonnegative(fact.adults, "adults")
|
|
_require_nonnegative(fact.children, "children")
|
|
for text in (
|
|
fact.company_name,
|
|
fact.confirmation_no,
|
|
fact.disp_room_no,
|
|
fact.full_name,
|
|
fact.rate_code,
|
|
):
|
|
if not str(text).strip():
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly source text is missing",
|
|
)
|
|
for amount in (
|
|
fact.effective_rate_amount,
|
|
fact.real_price,
|
|
fact.total_price,
|
|
):
|
|
if not isinstance(amount, Decimal) or not amount.is_finite() or amount < 0:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly source amount is invalid",
|
|
)
|
|
if fact.total_price != fact.real_price * fact.no_of_rooms * fact.nights:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly source total does not match static fact inputs",
|
|
)
|
|
if fact.channel_key == KB_SHEET:
|
|
if fact.kb_amount != Decimal(fact.no_of_rooms * 100):
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly KB source amount is invalid",
|
|
)
|
|
elif fact.kb_amount is not None:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"non-KB channel unexpectedly contains a KB amount",
|
|
)
|
|
|
|
|
|
def _validate_pins(
|
|
facts: Sequence[MonthlyFact],
|
|
pins: Sequence[DailyVersionPin],
|
|
start: date,
|
|
as_of_date: date,
|
|
) -> Tuple[DailyVersionPin, ...]:
|
|
by_date: Dict[date, int] = {}
|
|
for pin in pins:
|
|
if (
|
|
pin.business_date < start
|
|
or pin.business_date > as_of_date
|
|
or pin.daily_version_id <= 0
|
|
or (
|
|
pin.business_date in by_date
|
|
and by_date[pin.business_date] != pin.daily_version_id
|
|
)
|
|
):
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly daily-version manifest is invalid",
|
|
)
|
|
by_date[pin.business_date] = pin.daily_version_id
|
|
for fact in facts:
|
|
if by_date.get(fact.business_date) != fact.daily_version_id:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly fact is not covered by the current daily manifest",
|
|
)
|
|
return tuple(
|
|
DailyVersionPin(business_date=current, daily_version_id=version_id)
|
|
for current, version_id in sorted(by_date.items())
|
|
)
|
|
|
|
|
|
def _validate_observations(
|
|
facts: Sequence[MonthlyFact],
|
|
observations: Sequence[ChannelObservation],
|
|
pins: Sequence[DailyVersionPin],
|
|
) -> None:
|
|
pin_by_date = {pin.business_date: pin.daily_version_id for pin in pins}
|
|
observed: Dict[Tuple[int, str], int] = {}
|
|
orders: set[Tuple[int, int]] = set()
|
|
for item in observations:
|
|
if (
|
|
pin_by_date.get(item.business_date) != item.daily_version_id
|
|
or not _valid_sheet_name(item.worksheet)
|
|
or item.row_count < 0
|
|
or (item.worksheet_order is not None and item.worksheet_order <= 0)
|
|
):
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly channel observation is invalid",
|
|
)
|
|
key = (item.daily_version_id, item.worksheet)
|
|
if key in observed:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly channel observation is duplicated",
|
|
)
|
|
observed[key] = item.row_count
|
|
if item.worksheet_order is not None:
|
|
order_key = (item.daily_version_id, item.worksheet_order)
|
|
if order_key in orders:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly channel observation order is duplicated",
|
|
)
|
|
orders.add(order_key)
|
|
|
|
actual: Dict[Tuple[int, str], int] = defaultdict(int)
|
|
for fact in facts:
|
|
actual[(fact.daily_version_id, fact.channel_key)] += 1
|
|
for key, row_count in actual.items():
|
|
if observed.get(key) != row_count:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly channel observation does not match retained facts",
|
|
)
|
|
|
|
|
|
def build_monthly_report(
|
|
year: int,
|
|
month: int,
|
|
as_of_date: date,
|
|
snapshot: MonthlySnapshot,
|
|
) -> MonthlyReport:
|
|
validate_as_of(year, month, as_of_date)
|
|
start, _end = month_bounds(year, month)
|
|
facts = tuple(sorted(snapshot.facts, key=lambda item: item.daily_record_id))
|
|
if len({fact.daily_record_id for fact in facts}) != len(facts):
|
|
raise MonthlyReportError(ErrorCode.SOURCE_INVALID, "monthly source rows are duplicated")
|
|
for fact in facts:
|
|
_validate_fact(fact, start, as_of_date)
|
|
if facts and max(fact.arrival for fact in facts) != as_of_date:
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly as-of date must equal the greatest included ARRIVAL",
|
|
)
|
|
pins = _validate_pins(facts, snapshot.daily_versions, start, as_of_date)
|
|
_validate_observations(facts, snapshot.channel_observations, pins)
|
|
ordered_channels = channel_order(snapshot)
|
|
grouped: Dict[str, List[MonthlyFact]] = defaultdict(list)
|
|
for fact in facts:
|
|
grouped[fact.channel_key].append(fact)
|
|
channels = tuple(
|
|
ChannelSheet(
|
|
worksheet=name,
|
|
worksheet_order=index,
|
|
rows=tuple(
|
|
sorted(grouped.get(name, []), key=lambda item: (item.arrival, item.daily_record_id))
|
|
),
|
|
)
|
|
for index, name in enumerate(ordered_channels, 1)
|
|
)
|
|
if sum(len(channel.rows) for channel in channels) != len(facts):
|
|
raise MonthlyReportError(
|
|
ErrorCode.SOURCE_INVALID,
|
|
"monthly source channel coverage is incomplete",
|
|
)
|
|
filename = (
|
|
f"各渠道情况-{year:04d}年{month:02d}月-"
|
|
f"更新至{as_of_date.month}.{as_of_date.day}.xlsx"
|
|
)
|
|
return MonthlyReport(
|
|
report_year=year,
|
|
report_month=month,
|
|
as_of_date=as_of_date,
|
|
filename=filename,
|
|
channels=channels,
|
|
daily_versions=pins,
|
|
)
|