"""Typed contracts for database-backed monthly channel reports.""" from __future__ import annotations import hashlib import json from dataclasses import dataclass from datetime import date from decimal import Decimal from typing import Any, Dict, Mapping, Optional, Sequence, Tuple PROCESSOR_VERSION = "1.0.0" RESULT_SCHEMA_VERSION = "1.0" XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" CHANNEL_HEADERS: Tuple[str, ...] = ( "ARRIVAL", "DEPARTURE", "NIGHTS", "ADULTS", "CHILDREN", "BLOCK_CODE", "NO_OF_ROOMS", "COMPANY_NAME", "CONFIRMATION_NO", "DISP_ROOM_NO", "RATE_AMOUNT", "FULL_NAME", "RES_COMMENT", "TRACE_TEXT", "PRODUCTS", "RATE_CODE", "ROOM_CATEGORY_LABEL", "REAL PRICE", "TOTAL PRICE", ) KB_HEADER = "KB(100/晚/间)" KB_SHEET = "DY-AI-Easy-KB" KB_CHANNEL_HEADERS: Tuple[str, ...] = CHANNEL_HEADERS + (KB_HEADER,) STANDARD_SHEETS: Tuple[str, ...] = ( "LIANTAI-GROUP", "LIANTAI-FIT", "QBD", KB_SHEET, "FENGRUN", ) class ErrorCode: REQUEST_INVALID = "MONTHLY_REPORT_REQUEST_INVALID" SOURCE_INVALID = "MONTHLY_REPORT_SOURCE_INVALID" SOURCE_SNAPSHOT_STALE = "MONTHLY_REPORT_SOURCE_SNAPSHOT_STALE" OUTPUT_VALIDATION_FAILED = "MONTHLY_REPORT_OUTPUT_VALIDATION_FAILED" PUBLISH_FAILED = "MONTHLY_REPORT_PUBLISH_FAILED" DATABASE_FAILED = "MONTHLY_REPORT_DATABASE_FAILED" INTERNAL_ERROR = "MONTHLY_REPORT_INTERNAL_ERROR" class MonthlyReportError(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 DailyVersionPin: business_date: date daily_version_id: int @dataclass(frozen=True) class ChannelObservation: business_date: date daily_version_id: int worksheet: str worksheet_order: Optional[int] row_count: int @dataclass(frozen=True) class MonthlyFact: daily_record_id: int daily_version_id: int business_date: date channel_key: str arrival: date departure: date nights: int adults: int children: int block_code: Optional[str] no_of_rooms: int company_name: str confirmation_no: str disp_room_no: str effective_rate_amount: Decimal full_name: str res_comment: Optional[str] trace_text: Optional[str] products: Optional[str] rate_code: str room_category_label: Optional[str] real_price: Decimal total_price: Decimal kb_amount: Optional[Decimal] @staticmethod def _decimal_text(value: Decimal) -> str: rendered = format(value, "f") if "." in rendered: rendered = rendered.rstrip("0").rstrip(".") return rendered or "0" def to_payload(self, include_kb: bool) -> Dict[str, Any]: payload: Dict[str, Any] = { "ARRIVAL": self.arrival.isoformat(), "DEPARTURE": self.departure.isoformat(), "NIGHTS": self.nights, "ADULTS": self.adults, "CHILDREN": self.children, "BLOCK_CODE": self.block_code or "", "NO_OF_ROOMS": self.no_of_rooms, "COMPANY_NAME": self.company_name, "CONFIRMATION_NO": self.confirmation_no, "DISP_ROOM_NO": self.disp_room_no, "RATE_AMOUNT": self._decimal_text(self.effective_rate_amount), "FULL_NAME": self.full_name, "RES_COMMENT": self.res_comment or "", "TRACE_TEXT": self.trace_text or "", "PRODUCTS": self.products or "", "RATE_CODE": self.rate_code, "ROOM_CATEGORY_LABEL": self.room_category_label or "", "REAL PRICE": self._decimal_text(self.real_price), "TOTAL PRICE": self._decimal_text(self.total_price), } if include_kb: if self.kb_amount is None: raise MonthlyReportError( ErrorCode.SOURCE_INVALID, "KB channel source amount is missing", ) payload[KB_HEADER] = self._decimal_text(self.kb_amount) return payload @dataclass(frozen=True) class MonthlySnapshot: facts: Tuple[MonthlyFact, ...] daily_versions: Tuple[DailyVersionPin, ...] channel_observations: Tuple[ChannelObservation, ...] preferred_channel_order: Tuple[str, ...] = tuple() @dataclass(frozen=True) class ChannelSheet: worksheet: str worksheet_order: int rows: Tuple[MonthlyFact, ...] @property def headers(self) -> Tuple[str, ...]: return KB_CHANNEL_HEADERS if self.worksheet == KB_SHEET else CHANNEL_HEADERS def to_payload(self) -> Dict[str, Any]: include_kb = self.worksheet == KB_SHEET return { "worksheet": self.worksheet, "worksheet_order": self.worksheet_order, "headers": list(self.headers), "rows": [row.to_payload(include_kb) for row in self.rows], } @dataclass(frozen=True) class MonthlyReport: report_year: int report_month: int as_of_date: date filename: str channels: Tuple[ChannelSheet, ...] daily_versions: Tuple[DailyVersionPin, ...] @property def row_count(self) -> int: return sum(len(channel.rows) for channel in self.channels) @property def channel_manifest(self) -> Tuple[Tuple[str, int, int], ...]: return tuple( (channel.worksheet, channel.worksheet_order, len(channel.rows)) for channel in self.channels ) def to_workbook_payload(self) -> Dict[str, Any]: return { "schema_version": RESULT_SCHEMA_VERSION, "report_year": self.report_year, "report_month": self.report_month, "as_of_date": self.as_of_date.isoformat(), "filename": self.filename, "channels": [channel.to_payload() for channel in self.channels], } def rule_set_sha256() -> str: payload: Mapping[str, Any] = { "processor_version": PROCESSOR_VERSION, "result_schema_version": RESULT_SCHEMA_VERSION, "headers": CHANNEL_HEADERS, "kb_header": KB_HEADER, "standard_sheets": STANDARD_SHEETS, "source": "finance.v_active_daily_facts", "row_order": ["arrival", "daily_record_id"], "unknown_channel_order": [ "current_report_manifest", "daily_worksheet_first_seen", "lexical_fallback", ], "filename": "各渠道情况-YYYY年MM月-更新至M.D.xlsx", "formula_policy": "no_formulas", } return hashlib.sha256( json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") ).hexdigest() def immutable_tuple(values: Sequence[Any]) -> Tuple[Any, ...]: return tuple(values)