231 lines
6.7 KiB
Python
231 lines
6.7 KiB
Python
"""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": "total_price_equals_real_price_times_nights_times_rooms_v1",
|
||
}
|
||
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)
|