"""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.2.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": { "with_group_code": ["group_code", "arrival", "departure"], "without_group_code": ["daily_record_id"], }, "missing_group_code_booking_room": "blank", "unmatched_group_code_booking_room": "blank", "price_group_key": ["room_category_label", "static_total_price"], "filename_pattern": "--.xlsx", "sheet_pattern": " ", "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)