226 lines
7.8 KiB
Python
226 lines
7.8 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],
|
|
min_arrival_date: Optional[str] = None,
|
|
) -> 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,
|
|
"min_arrival_date": min_arrival_date,
|
|
"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,
|
|
}
|