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

231 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 = "KB100/晚/间)"
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)