300 lines
8.0 KiB
Python
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)
|