feat: sync latest ARR implementation

This commit is contained in:
Wyndham ARR
2026-07-31 15:11:42 +08:00
parent d6f8a747fa
commit bf7939dd1a
185 changed files with 17527 additions and 2260 deletions

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
import io
import unittest
import zipfile
from openpyxl import Workbook
from openpyxl.styles import PatternFill
from booking_ingestion.excel import (
BookingExcelError,
REVIEW_CONFIRMED,
REVIEW_PENDING,
build_excel_parse_result,
parse_excel_bytes,
validate_excel_filename,
)
def workbook_bytes(sheets: list[tuple[str, list[list[object]]]]) -> bytes:
workbook = Workbook()
first = True
for title, rows in sheets:
worksheet = workbook.active if first else workbook.create_sheet()
first = False
worksheet.title = title
for row in rows:
worksheet.append(row)
output = io.BytesIO()
workbook.save(output)
workbook.close()
return output.getvalue()
def booking_rows(*rows: list[object]) -> bytes:
return workbook_bytes(
[
(
"BOOKING",
[
["Booking export"],
["NO", "Tour Code", "Tour Days", "โรงแรม", "备注"],
*rows,
],
),
(
"ROOM_TYPE_MASTER",
[["Pattern", "Category"], ["【U-TWN8.5】", "U-TWN"]],
),
]
)
class BookingExcelParserTests(unittest.TestCase):
def test_extracts_tour_code_room_items_and_review_rows(self) -> None:
payload = booking_rows(
[
1,
"\t\nLT260715LD",
"2-2-1",
"17-19 酒店 (【U-TWN8.5】 17,【U-เตียงเสริม13】 2)(ออฟฟิศจ่าย)【ยืนยันเเล้ว】;",
],
[
2,
"LLT260715AC",
"2-2",
"17-19 酒店 (Sup【高级房TWN】 12,Sup【高级房DBL】 9)",
],
[3, "LLT260715AD", "2-3", "17-20 酒店 (【U-TWN13】"],
[4, "LLT260720HB", "2-2", "20-22 酒店 (【U-TWN1.1】 6)"],
[5, "HD260721VIPA", "2-2", "23-26 酒店 (【拼จU-4ค24】 2)"],
[6, "IGNORED", "2-2", "23-26 酒店 (【surcharge】 1)"],
)
document = parse_excel_bytes(payload)
self.assertEqual(document.worksheets, ("BOOKING",))
self.assertEqual([row.group_code_raw for row in document.rows], [
"LT260715LD",
"LLT260715AC",
"LLT260715AD",
"LLT260720HB",
"HD260721VIPA",
])
self.assertEqual(document.extracted_item_count, 7)
self.assertEqual(document.pending_item_count, 2)
mixed = document.rows[0]
self.assertEqual(
[
(item.room_type_code, item.quantity, item.review_status)
for item in mixed.room_items
],
[
("U-TWN", 17, REVIEW_CONFIRMED),
(None, 2, REVIEW_PENDING),
],
)
self.assertEqual(mixed.room_items[1].room_type_raw, "U-เตียงเสริม13")
split = document.rows[1]
self.assertEqual(
[(item.room_type_code, item.quantity) for item in split.room_items],
[("TWN", 12), ("DBL", 9)],
)
self.assertEqual(document.rows[2].room_items[0].quantity, 1)
self.assertEqual(document.rows[2].room_items[0].room_type_code, "U-TWN")
self.assertEqual(document.rows[3].room_items[0].room_type_code, "U-TWN")
self.assertEqual(document.rows[4].room_items[0].quantity, 2)
self.assertIsNone(document.rows[4].room_items[0].room_type_code)
parsed = build_excel_parse_result(mixed)
self.assertEqual(parsed["status"], "needs_review")
self.assertEqual(len(parsed["room_items"]), 2)
self.assertEqual(parsed["warnings"][0]["code"], "BOOKING_ROOM_TYPE_REVIEW_REQUIRED")
def test_physically_last_tour_row_replaces_earlier_and_cancel_removes_it(self) -> None:
payload = booking_rows(
[1, "SAME", "", "酒店 (【U-TWN8.5】 5)"],
[2, "CANCELLED", "", "酒店 (【U-TWN8.5】 3)"],
[3, "SAME", "", "酒店 (【U-DBL12】 2)"],
[4, "CANCELLED", "", "取消"],
)
document = parse_excel_bytes(payload)
self.assertEqual(len(document.rows), 1)
self.assertEqual(document.rows[0].group_code_key, "SAME")
self.assertEqual(document.rows[0].room_items[0].room_type_code, "U-DBL")
self.assertEqual(document.rows[0].room_items[0].quantity, 2)
def test_yellow_formatting_alone_does_not_cancel_a_tour(self) -> None:
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "Booking"
worksheet.append(["NO", "Tour Code", "Tour Days", "โรงแรม", "备注"])
worksheet.append([1, "YELLOW", "", "酒店 (【U-TWN8.5】 3)", ""])
worksheet.append([2, "YELLOW", "", "酒店 (【U-TWN8.5】 4)", ""])
yellow = PatternFill(fill_type="solid", fgColor="FFFF00")
for cell in worksheet[3]:
if cell.value not in (None, ""):
cell.fill = yellow
output = io.BytesIO()
workbook.save(output)
workbook.close()
document = parse_excel_bytes(output.getvalue())
self.assertEqual(len(document.rows), 1)
self.assertEqual(document.rows[0].group_code_key, "YELLOW")
self.assertEqual(document.rows[0].room_items[0].quantity, 4)
def test_unbracketed_room_description_requires_review(self) -> None:
payload = booking_rows(
[1, "SUITE", "", "酒店 (Family Suite Two b/r Garden View 2)"],
)
document = parse_excel_bytes(payload)
item = document.rows[0].room_items[0]
self.assertEqual(item.room_type_raw, "Family Suite")
self.assertEqual(item.quantity, 2)
self.assertEqual(item.review_status, REVIEW_PENDING)
def test_filename_accepts_xlsx_only(self) -> None:
self.assertEqual(validate_excel_filename("预订报表.XLSX"), "预订报表.XLSX")
for invalid in ("report.xls", "report.xlsm", "../report.xlsx", ""):
with self.subTest(invalid=invalid), self.assertRaises(BookingExcelError):
validate_excel_filename(invalid)
def test_non_xlsx_and_macro_payloads_are_rejected(self) -> None:
with self.assertRaises(BookingExcelError) as invalid:
parse_excel_bytes(b"not an xlsx")
self.assertEqual(invalid.exception.code, "BOOKING_EXCEL_INVALID")
source = booking_rows([1, "SYN", "", "酒店 (【U-TWN8.5】 1)"])
original = zipfile.ZipFile(io.BytesIO(source))
output = io.BytesIO()
with original, zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as target:
for info in original.infolist():
target.writestr(info, original.read(info.filename))
target.writestr("xl/vbaProject.bin", b"macro")
with self.assertRaises(BookingExcelError) as macro:
parse_excel_bytes(output.getvalue())
self.assertEqual(macro.exception.code, "BOOKING_EXCEL_MACRO_UNSUPPORTED")
def test_workbook_without_tour_and_hotel_headers_is_rejected(self) -> None:
payload = workbook_bytes(
[("Data", [["Group Code", "Room Type", "Quantity"], ["SYN", "DBL", 1]])]
)
with self.assertRaises(BookingExcelError) as raised:
parse_excel_bytes(payload)
self.assertEqual(raised.exception.code, "BOOKING_EXCEL_HEADERS_MISSING")
def test_required_formula_is_rejected(self) -> None:
formula = booking_rows([1, "SYN", "", "=\"酒店 (【U-TWN8.5】 1)\""])
with self.assertRaises(BookingExcelError) as raised:
parse_excel_bytes(formula)
self.assertEqual(raised.exception.code, "BOOKING_EXCEL_FORMULA_UNSUPPORTED")
if __name__ == "__main__":
unittest.main(verbosity=2)