Files
wyndham-ARR/channel_analytics/contracts.py
2026-07-29 16:38:05 +08:00

224 lines
7.7 KiB
Python

"""Pure aggregation rules for the frozen channel BI contract 1.2."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Iterable, List, Optional, Tuple
DASHBOARD_VERSION = "1.2"
DETAIL_VERSION = "1.0"
UNLABELED_ROOM_TYPE = "未标注房型"
MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
class AnalyticsError(RuntimeError):
def __init__(self, code: str, safe_message: str):
super().__init__(safe_message)
self.code = code
self.safe_message = safe_message
@dataclass(frozen=True)
class AggregatedRoomFact:
worksheet: str
room_type: str
rooms_sold: int
total_price: Decimal
room_nights: int
reservation_rows: int
@dataclass
class _RoomMetrics:
rooms_sold: int = 0
total_price: Decimal = Decimal(0)
room_nights: int = 0
reservation_rows: int = 0
def add(self, fact: AggregatedRoomFact) -> None:
self.rooms_sold += fact.rooms_sold
self.total_price += fact.total_price
self.room_nights += fact.room_nights
self.reservation_rows += fact.reservation_rows
@dataclass
class _ScopeMetrics:
rooms: Dict[str, _RoomMetrics] = field(default_factory=dict)
def add(self, fact: AggregatedRoomFact) -> None:
room_type = fact.room_type.strip() or UNLABELED_ROOM_TYPE
self.rooms.setdefault(room_type, _RoomMetrics()).add(fact)
@property
def rooms_sold(self) -> int:
return sum(item.rooms_sold for item in self.rooms.values())
@property
def total_price(self) -> Decimal:
return sum((item.total_price for item in self.rooms.values()), Decimal(0))
@property
def room_nights(self) -> int:
return sum(item.room_nights for item in self.rooms.values())
@property
def reservation_rows(self) -> int:
return sum(item.reservation_rows for item in self.rooms.values())
def validate_month_key(month_key: str) -> Tuple[int, int]:
match = MONTH_RE.fullmatch(month_key)
if not match:
raise AnalyticsError("ANALYTICS_MONTH_INVALID", "month must use YYYY-MM")
year, month = int(match.group(1)), int(match.group(2))
if year < 1900 or month < 1 or month > 12:
raise AnalyticsError("ANALYTICS_MONTH_INVALID", "month is invalid")
return year, month
def _integer(value: Any, field_name: str) -> int:
if isinstance(value, bool):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid") from None
if not number.is_finite() or number < 0 or number != number.to_integral_value():
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
return int(number)
def _decimal(value: Any, field_name: str) -> Decimal:
try:
number = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid") from None
if not number.is_finite() or number < 0:
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
return number
def json_number(value: Decimal) -> int | float:
return int(value) if value == value.to_integral_value() else float(value)
def normalized_fact(fact: AggregatedRoomFact) -> AggregatedRoomFact:
if not isinstance(fact.worksheet, str) or not fact.worksheet.strip():
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "worksheet is invalid")
if not isinstance(fact.room_type, str):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "room type is invalid")
normalized = AggregatedRoomFact(
worksheet=fact.worksheet,
room_type=fact.room_type.strip() or UNLABELED_ROOM_TYPE,
rooms_sold=_integer(fact.rooms_sold, "rooms_sold"),
total_price=_decimal(fact.total_price, "total_price"),
room_nights=_integer(fact.room_nights, "room_nights"),
reservation_rows=_integer(fact.reservation_rows, "reservation_rows"),
)
if normalized.reservation_rows == 0 and (
normalized.rooms_sold or normalized.room_nights or normalized.total_price
):
raise AnalyticsError(
"ANALYTICS_SOURCE_INVALID",
"aggregate row count does not match its values",
)
return normalized
def _room_rows(scope: _ScopeMetrics) -> List[Dict[str, Any]]:
denominator = scope.rooms_sold
return [
{
"room_type": room_type,
"rooms_sold": metrics.rooms_sold,
"rooms_share": metrics.rooms_sold / denominator if denominator else 0,
"total_price": json_number(metrics.total_price),
"room_nights": metrics.room_nights,
"reservation_rows": metrics.reservation_rows,
}
for room_type, metrics in sorted(
scope.rooms.items(),
key=lambda item: (-item[1].rooms_sold, item[0]),
)
]
def _totals(scope: _ScopeMetrics, channel_count: Optional[int] = None) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"rooms_sold": scope.rooms_sold,
"total_price": json_number(scope.total_price),
"room_nights": scope.room_nights,
"reservation_rows": scope.reservation_rows,
"room_type_count": len(scope.rooms),
}
if channel_count is not None:
payload["channel_count"] = channel_count
return payload
def build_dashboard(
month_key: str,
updated_at: str,
max_arrival_date: Optional[str],
source_monthly_sha256: str,
channel_names: Iterable[str],
facts: Iterable[AggregatedRoomFact],
) -> Dict[str, Any]:
validate_month_key(month_key)
names = tuple(channel_names)
if (
not names
or len(set(names)) != len(names)
or any(not isinstance(name, str) or not name.strip() for name in names)
or SHA256_RE.fullmatch(source_monthly_sha256) is None
):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "dashboard manifest is invalid")
channels = {name: _ScopeMetrics() for name in names}
overall = _ScopeMetrics()
for raw_fact in facts:
fact = normalized_fact(raw_fact)
if fact.worksheet not in channels:
raise AnalyticsError(
"ANALYTICS_SOURCE_INVALID",
"aggregate contains a channel outside the report manifest",
)
channels[fact.worksheet].add(fact)
overall.add(fact)
channel_payload = [
{
"worksheet": name,
"totals": _totals(channels[name]),
"room_types": _room_rows(channels[name]),
}
for name in names
]
if (
overall.rooms_sold != sum(item["totals"]["rooms_sold"] for item in channel_payload)
or overall.room_nights != sum(item["totals"]["room_nights"] for item in channel_payload)
or overall.reservation_rows
!= sum(item["totals"]["reservation_rows"] for item in channel_payload)
or overall.total_price
!= sum(
(Decimal(str(item["totals"]["total_price"])) for item in channel_payload),
Decimal(0),
)
):
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "dashboard totals do not balance")
return {
"version": DASHBOARD_VERSION,
"month_key": month_key,
"updated_at": updated_at,
"max_arrival_date": max_arrival_date,
"source_monthly_sha256": source_monthly_sha256,
"overall": {
"totals": _totals(overall, channel_count=len(names)),
"room_types": _room_rows(overall),
},
"channels": channel_payload,
}