feat: prepare ARR for controlled public deployment
This commit is contained in:
223
channel_analytics/contracts.py
Normal file
223
channel_analytics/contracts.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""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,
|
||||
}
|
||||
Reference in New Issue
Block a user