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