"""Deterministic, bounded parser for uploaded Booking Excel workbooks.""" from __future__ import annotations import hashlib import json import re import unicodedata import zipfile from dataclasses import dataclass from io import BytesIO from pathlib import PurePosixPath from typing import Iterable, Mapping, Optional PROCESSOR_NAME = "booking-excel-importer" PROCESSOR_VERSION = "2.0.0" RESULT_SCHEMA_VERSION = "1.0" XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" MAX_UPLOAD_BYTES = 25 * 1024 * 1024 MAX_ARCHIVE_BYTES = 160 * 1024 * 1024 MAX_ARCHIVE_ENTRIES = 4096 MAX_WORKSHEETS = 64 MAX_HEADER_ROWS = 20 MAX_WORKSHEET_COLUMNS = 256 MAX_WORKSHEET_ROWS = 100_000 MAX_SOURCE_ROWS = 50_000 MAX_EXTRACTED_ITEMS = 100_000 MAX_TOUR_CODE_LENGTH = 128 MAX_HOTEL_TEXT_LENGTH = 8192 MAX_ROOM_LABEL_LENGTH = 256 REVIEW_CONFIRMED = "confirmed" REVIEW_PENDING = "pending" PENDING_ROOM_TYPE_LABEL = "待人工确认" class BookingExcelError(ValueError): """A stable, user-safe workbook validation failure.""" def __init__(self, code: str, safe_message: str, status: int = 422): super().__init__(safe_message) self.code = code self.safe_message = safe_message self.status = status @dataclass(frozen=True) class ExtractedRoomItem: item_no: int room_type_raw: str room_type_code: Optional[str] quantity: int review_status: str source_fragment: str @property def needs_review(self) -> bool: return self.review_status == REVIEW_PENDING def canonical_source(self) -> Mapping[str, object]: return { "item_no": self.item_no, "room_type_raw": self.room_type_raw, "room_type_code": self.room_type_code, "quantity": self.quantity, "review_status": self.review_status, "source_fragment": self.source_fragment, } def to_dict(self) -> dict[str, object]: return dict(self.canonical_source()) @dataclass(frozen=True) class ExcelRow: worksheet: str row_no: int group_code_raw: str hotel_raw: str room_items: tuple[ExtractedRoomItem, ...] @property def group_code_key(self) -> str: return self.group_code_raw.upper() @property def type_of_room_raw(self) -> str: return self.hotel_raw @property def no_of_rooms(self) -> int: return sum(item.quantity for item in self.room_items) @property def pending_item_count(self) -> int: return sum(1 for item in self.room_items if item.needs_review) @property def confirmed_room_quantity(self) -> int: return sum( item.quantity for item in self.room_items if not item.needs_review ) def canonical_source(self) -> Mapping[str, object]: return { "worksheet": self.worksheet, "row_no": self.row_no, "group_code_raw": self.group_code_raw, "group_code_key": self.group_code_key, "hotel_raw": self.hotel_raw, "room_items": [item.canonical_source() for item in self.room_items], } def sha256(self) -> str: return hashlib.sha256(canonical_json_bytes(self.canonical_source())).hexdigest() @dataclass(frozen=True) class ExcelDocument: source_sha256: str source_byte_size: int worksheets: tuple[str, ...] rows: tuple[ExcelRow, ...] @property def worksheet_count(self) -> int: return len(self.worksheets) @property def distinct_group_code_count(self) -> int: return len({row.group_code_key for row in self.rows}) @property def extracted_item_count(self) -> int: return sum(len(row.room_items) for row in self.rows) @property def pending_item_count(self) -> int: return sum(row.pending_item_count for row in self.rows) @property def room_quantity(self) -> int: return sum(row.no_of_rooms for row in self.rows) @property def confirmed_room_quantity(self) -> int: return sum(row.confirmed_room_quantity for row in self.rows) def canonical_json_bytes(value: object) -> bytes: return json.dumps( value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") def validate_excel_filename(value: object) -> str: if ( not isinstance(value, str) or not 1 <= len(value) <= 255 or value in {".", ".."} or PurePosixPath(value).name != value or "/" in value or "\\" in value or any(ord(char) < 32 or ord(char) == 127 for char in value) or not value.lower().endswith(".xlsx") ): raise BookingExcelError( "BOOKING_EXCEL_FILENAME_INVALID", "请选择 .xlsx 格式的 Excel 文件", 400, ) return value def _normalized_header(value: object) -> str: if not isinstance(value, str): return "" normalized = unicodedata.normalize("NFKC", value).strip().upper() return re.sub(r"[\s._\-/\\::()()]+", "", normalized) HEADER_ALIASES = { "tour_code": frozenset( { "TOURCODE", "GROUPCODE", "GOURPCODE", "团号", "团队代码", } ), "hotel": frozenset( { "โรงแรม", } ), } ROOM_BRACKET_PATTERN = re.compile( r"【(?P