"""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, }