feat: sync latest ARR implementation
This commit is contained in:
722
booking_ingestion/excel.py
Normal file
722
booking_ingestion/excel.py
Normal file
@@ -0,0 +1,722 @@
|
||||
"""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": [],
|
||||
}
|
||||
Reference in New Issue
Block a user