723 lines
23 KiB
Python
723 lines
23 KiB
Python
"""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": [],
|
||
}
|