Files
2026-07-31 15:11:42 +08:00

723 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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<label>[^】]{1," + str(MAX_ROOM_LABEL_LENGTH) + r"})】"
r"(?P<spacing>\s*)(?P<quantity>[0-9]{1,4})?"
)
U_TWN_PATTERN = re.compile(r"^U[-‐‑‒–—]?TWN(?:[0-9]+(?:\.[0-9]+)?)?$", re.I)
U_DBL_PATTERN = re.compile(r"^U[-‐‑‒–—]?DBL(?:[0-9]+(?:\.[0-9]+)?)?$", re.I)
CANCELLATION_PATTERN = re.compile(
r"取消|ยกเลิก|(?<![A-Za-z])CANCEL(?:L?ED)?(?![A-Za-z])",
re.I,
)
IGNORED_NON_ROOM_TOKENS = (
"SURCHARGE",
"ABFCHILD",
"ห้องไกด์",
)
UNBRACKETED_ROOM_NAMES = (
"6+4",
"FAMILY SUITE",
"JUNIOR SUITE",
"SUPERIOR GARDEN",
)
def _archive_preflight(payload: bytes) -> None:
if not payload:
raise BookingExcelError("BOOKING_EXCEL_EMPTY", "Excel 文件为空", 400)
if len(payload) > MAX_UPLOAD_BYTES:
raise BookingExcelError(
"BOOKING_EXCEL_TOO_LARGE",
"Excel 文件超过 25 MB",
413,
)
stream = BytesIO(payload)
if not zipfile.is_zipfile(stream):
raise BookingExcelError(
"BOOKING_EXCEL_INVALID",
"文件不是有效的 .xlsx 工作簿",
)
stream.seek(0)
try:
with zipfile.ZipFile(stream) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
"Excel 文件结构过大,无法安全处理",
)
names = set()
expanded = 0
for entry in entries:
name = entry.filename.replace("\\", "/")
path = PurePosixPath(name)
if (
not name
or name.startswith("/")
or path.is_absolute()
or "." in path.parts
or ".." in path.parts
or entry.flag_bits & 0x1
):
raise BookingExcelError(
"BOOKING_EXCEL_INVALID",
"Excel 文件结构无效",
)
names.add(name)
expanded += entry.file_size
if expanded > MAX_ARCHIVE_BYTES:
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
"Excel 文件展开后过大,无法安全处理",
)
if "[Content_Types].xml" not in names or "xl/workbook.xml" not in names:
raise BookingExcelError(
"BOOKING_EXCEL_INVALID",
"文件不是有效的 .xlsx 工作簿",
)
if any(name.lower().endswith("vbaproject.bin") for name in names):
raise BookingExcelError(
"BOOKING_EXCEL_MACRO_UNSUPPORTED",
"不支持包含宏的 Excel 文件",
)
except BookingExcelError:
raise
except (OSError, zipfile.BadZipFile, zipfile.LargeZipFile):
raise BookingExcelError(
"BOOKING_EXCEL_INVALID",
"文件不是有效的 .xlsx 工作簿",
) from None
def _header_columns(cells: Iterable[object]) -> dict[str, int] | None:
matches: dict[str, list[int]] = {key: [] for key in HEADER_ALIASES}
for column_no, cell in enumerate(cells, start=1):
value = getattr(cell, "value", cell)
header = _normalized_header(value)
if not header:
continue
for key, aliases in HEADER_ALIASES.items():
if header in aliases:
matches[key].append(column_no)
if not all(matches[key] for key in matches):
return None
if any(len(matches[key]) != 1 for key in matches):
raise BookingExcelError(
"BOOKING_EXCEL_HEADERS_AMBIGUOUS",
"Excel 表头重复,无法确定 Tour Code 或โรงแรม列",
)
return {key: values[0] for key, values in matches.items()}
def normalize_tour_code(
value: object,
*,
worksheet: str,
row_no: int,
) -> str:
if not isinstance(value, str):
raise BookingExcelError(
"BOOKING_EXCEL_ROW_INVALID",
f"工作表“{worksheet}”第 {row_no} 行的 Tour Code 格式无效",
)
normalized = unicodedata.normalize("NFKC", value)
normalized = re.sub(r"\s+", "", normalized)
if (
not normalized
or len(normalized) > MAX_TOUR_CODE_LENGTH
or any(ord(char) < 32 or ord(char) == 127 for char in normalized)
):
raise BookingExcelError(
"BOOKING_EXCEL_ROW_INVALID",
f"工作表“{worksheet}”第 {row_no} 行的 Tour Code 格式无效",
)
return normalized
def _hotel_text(value: object, *, worksheet: str, row_no: int) -> str:
if value in (None, ""):
return ""
if not isinstance(value, str):
raise BookingExcelError(
"BOOKING_EXCEL_ROW_INVALID",
f"工作表“{worksheet}”第 {row_no} 行的โรงแรม格式无效",
)
normalized = unicodedata.normalize("NFKC", value).strip()
if len(normalized) > MAX_HOTEL_TEXT_LENGTH or any(
(ord(char) < 32 and char not in "\t\r\n") or ord(char) == 127
for char in normalized
):
raise BookingExcelError(
"BOOKING_EXCEL_ROW_INVALID",
f"工作表“{worksheet}”第 {row_no} 行的โรงแรม格式无效",
)
return normalized
def _cell_value(cell: object, *, worksheet: str, row_no: int) -> object:
if getattr(cell, "data_type", None) == "f":
raise BookingExcelError(
"BOOKING_EXCEL_FORMULA_UNSUPPORTED",
f"工作表“{worksheet}”第 {row_no} 行的必填字段不能使用公式",
)
return getattr(cell, "value", None)
def _compact_label(value: str) -> str:
normalized = unicodedata.normalize("NFKC", value).strip().upper()
return re.sub(r"\s+", "", normalized)
def _canonical_room_type(label: str) -> Optional[str]:
compact = _compact_label(label)
if U_TWN_PATTERN.fullmatch(compact):
return "U-TWN"
if U_DBL_PATTERN.fullmatch(compact):
return "U-DBL"
if compact in {"高级房TWN", "TWN"}:
return "TWN"
if compact in {"高级房DBL", "DBL"}:
return "DBL"
return None
def _ignored_non_room(label: str) -> bool:
compact = _compact_label(label)
return any(token in compact for token in IGNORED_NON_ROOM_TOKENS)
def _parenthesized_segments(value: str) -> list[str]:
segments: list[str] = []
stack: list[int] = []
for index, char in enumerate(value):
if char in "(":
stack.append(index)
elif char in ")" and stack:
start = stack.pop()
if not stack:
segments.append(value[start + 1 : index])
if stack:
segments.append(value[stack[0] + 1 :])
return segments
def _unbracketed_room_item(segment: str, item_no: int) -> Optional[ExtractedRoomItem]:
upper = unicodedata.normalize("NFKC", segment).upper()
label = next((name for name in UNBRACKETED_ROOM_NAMES if name in upper), None)
if label is None:
return None
quantity_match = re.search(r"(?:^|\s)([0-9]{1,4})\s*$", segment)
quantity = int(quantity_match.group(1)) if quantity_match else 1
if not 1 <= quantity <= 9999:
quantity = 1
display_label = {
"FAMILY SUITE": "Family Suite",
"JUNIOR SUITE": "Junior Suite",
"SUPERIOR GARDEN": "Superior Garden",
}.get(label, label)
return ExtractedRoomItem(
item_no=item_no,
room_type_raw=display_label,
room_type_code=None,
quantity=quantity,
review_status=REVIEW_PENDING,
source_fragment=segment.strip(),
)
def extract_room_items(hotel_raw: str) -> tuple[ExtractedRoomItem, ...]:
segments = _parenthesized_segments(hotel_raw)
bracket_segments = [segment for segment in segments if "" in segment]
items: list[ExtractedRoomItem] = []
saw_ignored_item = False
for segment in bracket_segments:
for match in ROOM_BRACKET_PATTERN.finditer(segment):
label = match.group("label").strip()
if _ignored_non_room(label):
saw_ignored_item = True
continue
quantity = int(match.group("quantity") or "1")
room_type_code = _canonical_room_type(label)
items.append(
ExtractedRoomItem(
item_no=len(items) + 1,
room_type_raw=label,
room_type_code=room_type_code,
quantity=quantity,
review_status=(
REVIEW_CONFIRMED
if room_type_code is not None
else REVIEW_PENDING
),
source_fragment=match.group(0).strip(),
)
)
if len(items) > MAX_EXTRACTED_ITEMS:
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
"Excel 可提取房型数量超出支持范围",
)
if items or saw_ignored_item:
return tuple(items)
for segment in segments:
item = _unbracketed_room_item(segment, len(items) + 1)
if item is not None:
items.append(item)
if items:
return tuple(items)
return (
ExtractedRoomItem(
item_no=1,
room_type_raw=PENDING_ROOM_TYPE_LABEL,
room_type_code=None,
quantity=1,
review_status=REVIEW_PENDING,
source_fragment="",
),
)
def _row_is_cancelled(cells: tuple[object, ...]) -> bool:
text = " ".join(
str(getattr(cell, "value", ""))
for cell in cells
if getattr(cell, "value", None) not in (None, "")
)
return CANCELLATION_PATTERN.search(text) is not None
def parse_excel_bytes(payload: bytes) -> ExcelDocument:
_archive_preflight(payload)
try:
from openpyxl import load_workbook # type: ignore[import-not-found]
except ImportError:
raise BookingExcelError(
"BOOKING_EXCEL_SERVICE_UNAVAILABLE",
"Excel 校验服务暂不可用",
503,
) from None
try:
workbook = load_workbook(
BytesIO(payload),
read_only=True,
data_only=False,
keep_links=False,
)
except Exception:
raise BookingExcelError(
"BOOKING_EXCEL_INVALID",
"文件不是有效的 .xlsx 工作簿",
) from None
latest_by_tour: dict[str, tuple[int, Optional[ExcelRow]]] = {}
data_sheets: list[str] = []
sequence = 0
source_rows_seen = 0
extracted_items_seen = 0
try:
if not workbook.worksheets or len(workbook.worksheets) > MAX_WORKSHEETS:
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
"Excel 工作表数量超出支持范围",
)
for worksheet in workbook.worksheets:
title = str(worksheet.title or "").strip()
if not title or len(title) > 31:
raise BookingExcelError(
"BOOKING_EXCEL_INVALID",
"Excel 工作表名称无效",
)
if (
isinstance(worksheet.max_column, int)
and worksheet.max_column > MAX_WORKSHEET_COLUMNS
) or (
isinstance(worksheet.max_row, int)
and worksheet.max_row > MAX_WORKSHEET_ROWS
):
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
f"工作表“{title}”的范围超出支持限制",
)
header_row_no = 0
columns: dict[str, int] | None = None
for scan_no, cells in enumerate(
worksheet.iter_rows(
min_row=1,
max_row=MAX_HEADER_ROWS,
max_col=MAX_WORKSHEET_COLUMNS,
),
start=1,
):
columns = _header_columns(cells)
if columns is not None:
header_row_no = scan_no
break
if columns is None:
continue
data_sheets.append(title)
row_width = min(
MAX_WORKSHEET_COLUMNS,
max(
max(columns.values()),
int(worksheet.max_column or max(columns.values())),
),
)
for row_no, cells in enumerate(
worksheet.iter_rows(
min_row=header_row_no + 1,
max_row=MAX_WORKSHEET_ROWS,
max_col=row_width,
),
start=header_row_no + 1,
):
cells = tuple(cells)
tour_value = _cell_value(
cells[columns["tour_code"] - 1],
worksheet=title,
row_no=row_no,
)
hotel_value = _cell_value(
cells[columns["hotel"] - 1],
worksheet=title,
row_no=row_no,
)
if tour_value in (None, ""):
continue
tour_code = normalize_tour_code(
tour_value,
worksheet=title,
row_no=row_no,
)
group_code_key = tour_code.upper()
sequence += 1
source_rows_seen += 1
if source_rows_seen > MAX_SOURCE_ROWS:
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
"Excel 数据行超过 50000 行",
)
if _row_is_cancelled(cells):
latest_by_tour[group_code_key] = (sequence, None)
continue
hotel_raw = _hotel_text(
hotel_value,
worksheet=title,
row_no=row_no,
)
room_items = extract_room_items(hotel_raw)
extracted_items_seen += len(room_items)
if extracted_items_seen > MAX_EXTRACTED_ITEMS:
raise BookingExcelError(
"BOOKING_EXCEL_LIMIT_EXCEEDED",
"Excel 可提取房型数量超出支持范围",
)
latest_by_tour[group_code_key] = (
sequence,
ExcelRow(
worksheet=title,
row_no=row_no,
group_code_raw=tour_code,
hotel_raw=hotel_raw or PENDING_ROOM_TYPE_LABEL,
room_items=room_items,
)
if room_items
else None,
)
finally:
workbook.close()
rows = tuple(
row
for _, row in sorted(latest_by_tour.values(), key=lambda value: value[0])
if row is not None
)
if not data_sheets:
raise BookingExcelError(
"BOOKING_EXCEL_HEADERS_MISSING",
"Excel 中未找到同时包含 Tour Code 和โรงแรม的工作表",
)
if not rows:
raise BookingExcelError(
"BOOKING_EXCEL_NO_DATA",
"Excel 中没有可提取的 Booking 数据",
)
return ExcelDocument(
source_sha256=hashlib.sha256(payload).hexdigest(),
source_byte_size=len(payload),
worksheets=tuple(data_sheets),
rows=rows,
)
def rule_set_sha256() -> str:
rules = {
"headers": {key: sorted(value) for key, value in HEADER_ALIASES.items()},
"u_twn": U_TWN_PATTERN.pattern,
"u_dbl": U_DBL_PATTERN.pattern,
"ignored_non_room": list(IGNORED_NON_ROOM_TOKENS),
"unbracketed_room_names": list(UNBRACKETED_ROOM_NAMES),
"cancellation": CANCELLATION_PATTERN.pattern,
}
digest = hashlib.sha256()
digest.update(PROCESSOR_NAME.encode("ascii"))
digest.update(b"\0")
digest.update(PROCESSOR_VERSION.encode("ascii"))
digest.update(b"\0")
digest.update(canonical_json_bytes(rules))
return digest.hexdigest()
def build_excel_parse_result(
row: ExcelRow,
room_items: Optional[Iterable[ExtractedRoomItem]] = None,
) -> Mapping[str, object]:
selected_items = tuple(room_items) if room_items is not None else row.room_items
pending = [item for item in selected_items if item.needs_review]
status = "needs_review" if pending else "accepted"
row_sha256 = row.sha256()
return {
"result_schema_version": RESULT_SCHEMA_VERSION,
"status": status,
"source_row": {
"worksheet": row.worksheet,
"row_no": row.row_no,
"sha256": row_sha256,
},
"processor_version": PROCESSOR_VERSION,
"rule_set_sha256": rule_set_sha256(),
"group_code_raw": row.group_code_raw,
"group_code_key": row.group_code_key,
"type_of_room_raw": row.hotel_raw,
"no_of_rooms": sum(item.quantity for item in selected_items),
"room_items": [
{
"item_no": item.item_no,
"room_type_raw": item.room_type_raw,
"room_type_code": item.room_type_code,
"quantity": item.quantity,
"unit_price": None,
"currency_code": None,
"price_token_raw": None,
"source_fragment": item.source_fragment,
}
for item in selected_items
],
"warnings": [
{
"code": "BOOKING_ROOM_TYPE_REVIEW_REQUIRED",
"message": "房型需人工确认",
}
for _ in pending
],
"errors": [],
}