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

300 lines
8.0 KiB
Python

"""Stable contracts and privacy-minimized data models for company reports."""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
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"
ENGLISH_MONTH_NAMES: Tuple[str, ...] = (
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
)
ENGLISH_MONTH_ABBREVIATIONS: Tuple[str, ...] = (
"",
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
)
REPORT_HEADERS: Tuple[str, ...] = (
"ARRIVAL",
"DEPARTURE",
"NIGHTS",
"BLOCK_CODE",
"RES_COMMENT",
"Booking Room",
"Total Booking Price",
)
@dataclass(frozen=True)
class CompanyRule:
display_name: str
match_field: str
match_value: str
COMPANY_RULES: Tuple[CompanyRule, ...] = (
CompanyRule("LianTai", "company_key", "LIAN TAI"),
CompanyRule("QBD", "company_key", "QBD"),
CompanyRule("DY-AI-Easy-KB", "channel_key", "DY-AI-Easy-KB"),
CompanyRule("FengRun", "company_key", "FENGRUN"),
CompanyRule("HanaTour", "company_key", "HANA TOUR"),
)
COMPANY_NAMES: Tuple[str, ...] = tuple(rule.display_name for rule in COMPANY_RULES)
class ErrorCode:
REQUEST_INVALID = "COMPANY_REPORT_REQUEST_INVALID"
SOURCE_VERSION_MISSING = "COMPANY_REPORT_SOURCE_VERSION_MISSING"
GROUP_CODE_MISSING = "COMPANY_REPORT_GROUP_CODE_MISSING"
GROUP_CODE_NOT_FOUND = "COMPANY_REPORT_GROUP_CODE_NOT_FOUND"
BOOKING_PARSE_FAILED = "COMPANY_REPORT_BOOKING_PARSE_FAILED"
ROOM_ITEMS_MISSING = "COMPANY_REPORT_ROOM_ITEMS_MISSING"
STAY_DATE_INVALID = "COMPANY_REPORT_STAY_DATE_INVALID"
NIGHTS_CONFLICT = "COMPANY_REPORT_NIGHTS_CONFLICT"
TOTAL_PRICE_INVALID = "COMPANY_REPORT_TOTAL_PRICE_INVALID"
OUTPUT_VALIDATION_FAILED = "COMPANY_REPORT_OUTPUT_VALIDATION_FAILED"
PUBLISH_FAILED = "COMPANY_REPORT_PUBLISH_FAILED"
INTERNAL_ERROR = "COMPANY_REPORT_INTERNAL_ERROR"
ERROR_CODES: Tuple[str, ...] = (
ErrorCode.REQUEST_INVALID,
ErrorCode.SOURCE_VERSION_MISSING,
ErrorCode.GROUP_CODE_MISSING,
ErrorCode.GROUP_CODE_NOT_FOUND,
ErrorCode.BOOKING_PARSE_FAILED,
ErrorCode.ROOM_ITEMS_MISSING,
ErrorCode.STAY_DATE_INVALID,
ErrorCode.NIGHTS_CONFLICT,
ErrorCode.TOTAL_PRICE_INVALID,
ErrorCode.OUTPUT_VALIDATION_FAILED,
ErrorCode.PUBLISH_FAILED,
ErrorCode.INTERNAL_ERROR,
)
class WarningCode:
MULTI_PRICE_REVIEW = "COMPANY_REPORT_MULTI_PRICE_REVIEW"
WARNING_CODES: Tuple[str, ...] = (WarningCode.MULTI_PRICE_REVIEW,)
@dataclass(frozen=True)
class FinanceFact:
daily_record_id: int
daily_version_id: int
business_date: date
channel_key: str
company_key: str
company_name: str
block_code: Optional[str]
group_code_key: Optional[str]
res_comment: Optional[str]
room_category_label: Optional[str]
arrival: Optional[date]
departure: Optional[date]
nights: Optional[int]
total_price: Optional[Decimal]
booking_source_match_status: str = "not_checked"
@dataclass(frozen=True)
class BookingRoomItem:
group_code_key: str
parse_version_id: int
segment_no: int
arrival: date
departure: date
nights: int
item_no: int
room_type_raw: str
quantity: int
@dataclass(frozen=True)
class DailyVersionPin:
business_date: date
daily_version_id: int
@dataclass(frozen=True)
class BatchSnapshot:
facts: Tuple[FinanceFact, ...]
room_items: Tuple[BookingRoomItem, ...]
daily_versions: Tuple[DailyVersionPin, ...]
group_parse_versions: Mapping[str, int]
@dataclass(frozen=True)
class ReportProblem:
code: str
stage: str
company: str
period: str
message: str
record_ids: Tuple[int, ...] = field(default_factory=tuple)
def to_dict(self) -> Dict[str, Any]:
return {
"code": self.code,
"stage": self.stage,
"company": self.company,
"period": self.period,
"message": self.message,
"record_ids": list(self.record_ids),
}
@dataclass(frozen=True)
class ReportRow:
arrival: date
departure: date
nights: int
block_code: str
res_comment: str
booking_room: str
total_booking_price: str
normalized_group_code: str
record_ids: Tuple[int, ...]
duplicate_group: bool = False
multi_price_review: bool = False
def to_payload(self) -> Dict[str, Any]:
return {
"arrival": self.arrival.isoformat(),
"departure": self.departure.isoformat(),
"nights": self.nights,
"block_code": self.block_code,
"res_comment": self.res_comment,
"booking_room": self.booking_room,
"total_booking_price": self.total_booking_price,
"duplicate_group": self.duplicate_group,
"multi_price_review": self.multi_price_review,
}
@dataclass(frozen=True)
class PeriodReport:
start_day: int
end_day: int
active: bool
sheet_name: str
rows: Tuple[ReportRow, ...]
@property
def key(self) -> str:
return f"{self.start_day:02d}-{self.end_day:02d}"
def to_payload(self) -> Dict[str, Any]:
return {
"period": self.key,
"active": self.active,
"sheet_name": self.sheet_name,
"rows": [row.to_payload() for row in self.rows],
}
@dataclass(frozen=True)
class CompanyReport:
company: str
report_year: int
report_month: int
as_of_date: date
filename: str
periods: Tuple[PeriodReport, ...]
warnings: Tuple[ReportProblem, ...]
errors: Tuple[ReportProblem, ...]
daily_versions: Tuple[DailyVersionPin, ...]
booking_versions: Mapping[str, int]
@property
def row_count(self) -> int:
return sum(len(period.rows) for period in self.periods)
@property
def valid(self) -> bool:
return not self.errors
def to_workbook_payload(self) -> Dict[str, Any]:
if not self.valid:
raise ValueError("cannot build a workbook payload for an invalid company report")
return {
"schema_version": RESULT_SCHEMA_VERSION,
"company": self.company,
"report_year": self.report_year,
"report_month": self.report_month,
"as_of_date": self.as_of_date.isoformat(),
"filename": self.filename,
"headers": list(REPORT_HEADERS),
"periods": [period.to_payload() for period in self.periods],
}
def rule_set_sha256() -> str:
payload = {
"processor_version": PROCESSOR_VERSION,
"result_schema_version": RESULT_SCHEMA_VERSION,
"headers": REPORT_HEADERS,
"companies": [rule.__dict__ for rule in COMPANY_RULES],
"period_basis": "departure",
"group_code_normalization": "nfkc_trim_upper",
"row_key": ["group_code", "arrival", "departure"],
"price_group_key": ["room_category_label", "static_total_price"],
"filename_pattern": "<Company>-<EnglishFullMonth>-<YYYY>.xlsx",
"sheet_pattern": "<Company> <DD-DD> <EnglishMon> <YYYY>",
"periods": ["01-10", "11-20", "21-natural-month-end"],
"styles": {
"duplicate_fill": "#FFF2CC",
"duplicate_group_font": "#9C6500",
"multi_price_fill": "#FCE4D6",
"multi_price_font": "#C65911",
"header": "black_bold_no_colored_fill",
},
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def company_rule(name: str) -> CompanyRule:
for rule in COMPANY_RULES:
if rule.display_name == name:
return rule
raise ValueError(f"unsupported company: {name}")
def immutable_tuple(values: Sequence[Any]) -> Tuple[Any, ...]:
return tuple(values)