Files
wyndham-ARR/tests/test_pricing_rules.py
2026-07-29 16:38:05 +08:00

314 lines
14 KiB
Python

from __future__ import annotations
import argparse
import json
import sys
import tempfile
import unittest
from datetime import date
from decimal import Decimal
from pathlib import Path
from openpyxl import Workbook, load_workbook
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SKILL_ROOT = PROJECT_ROOT / "opera-daily-channel-report"
SCRIPTS = SKILL_ROOT / "scripts"
PRICE_REFERENCE = SKILL_ROOT / "references" / "价格对照.xlsx"
sys.path.insert(0, str(SCRIPTS))
import process_reports as core # noqa: E402
def pricing_record(company: str, rate_code: str, amount: str = "999"):
return {
"_SOURCE_INDEX": 1,
"COMPANY_NAME": company,
"RATE_CODE": rate_code,
"EFFECTIVE_RATE_AMOUNT": Decimal(amount),
"CONFIRMATION_NO": "TEST-CONFIRMATION",
"NO_OF_ROOMS": 1,
"NIGHTS": 1,
"REAL PRICE": None,
"TOTAL PRICE": None,
}
class PricingRuleTests(unittest.TestCase):
def test_deterministic_keyword_company_matching(self):
cases = {
"T- LIAN TAI TRAVEL (T": "LIAN TAI",
"T / Q.B.D. TRAVEL GROUP": "QBD",
"T- Rainbow Holiday Service": "RAINBOW/AI",
"T- FENG RUN TRAVEL": "FENGRUN",
"T- HANATOUR TD CO., LTD": "HANA TOUR",
"T / HANA TRAVEL": "HANA TOUR",
"T- HONG TAI TRAVEL": "HONGTAI",
"T / Guangzhou Go-Easy": "GUANGZHOU GO EASY",
"Hong Yun": "HONGYUN",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(core.price_company_key(raw), expected)
def test_multiple_keyword_groups_fail_instead_of_guessing(self):
with self.assertRaises(core.CompanyKeywordAmbiguity) as caught:
core.price_company_key("HANA HONGTAI")
self.assertEqual(caught.exception.matches, ("HANA TOUR", "HONGTAI"))
record = pricing_record("HANA HONGTAI", "LBMS")
with self.assertRaises(core.ProcessingFailure) as caught_price:
core.apply_prices([record], {})
self.assertEqual(caught_price.exception.errors[0].code, "PRICE_COMPANY_AMBIGUOUS")
with self.assertRaises(core.ProcessingFailure) as caught_route:
core.route_standard("HANA HONGTAI", "LBMS")
self.assertEqual(caught_route.exception.errors[0].code, "MONTHLY_COMPANY_AMBIGUOUS")
def test_zero_price_requires_both_company_and_rate_conditions(self):
records = [
pricing_record("T / Guangzhou Go-Easy", "LBSM", "12345"),
pricing_record("T- Rainbow Holiday Service", "lbms", "67890"),
]
core.apply_prices(records, {})
self.assertEqual([record["REAL PRICE"] for record in records], [Decimal(0), Decimal(0)])
self.assertEqual([record["TOTAL PRICE"] for record in records], [Decimal(0), Decimal(0)])
for company, rate_code in (
("T- Guangzhou Go Easy", "GRPA1"),
("T- Rainbow Holiday Se", "LBW1"),
("T- HANATOUR TD CO., L", "LBMS"),
):
with self.subTest(company=company, rate_code=rate_code):
with self.assertRaises(core.ProcessingFailure) as caught:
core.apply_prices([pricing_record(company, rate_code)], {})
self.assertEqual(caught.exception.errors[0].code, "PRICE_UNMATCHED")
def test_latest_price_reference_is_normalized_on_load(self):
price_map = core.load_price_map(PRICE_REFERENCE.resolve())
self.assertEqual(len(price_map), 73)
rainbow_rules = [
total for (company, _rate, _amount), total in price_map.items() if company == "RAINBOW/AI"
]
self.assertEqual(len(rainbow_rules), 9)
self.assertTrue(all(total == Decimal(0) for total in rainbow_rules))
self.assertEqual(price_map[("RAINBOW/AI", "LBSM", Decimal("900"))], Decimal(0))
self.assertEqual(price_map[("HANA TOUR", "WHKR2100B", Decimal("2100"))], Decimal("2100"))
self.assertEqual(price_map[("HONGTAI", "LBKB", Decimal("900"))], Decimal("1800"))
self.assertEqual(price_map[("FENGRUN", "GLSPCB", Decimal("1800"))], Decimal("1800"))
def test_duplicate_detection_uses_normalized_company_identity(self):
with tempfile.TemporaryDirectory() as temp_dir:
price_path = Path(temp_dir) / "duplicate.xlsx"
workbook = Workbook()
sheet = workbook.active
sheet.title = "Sheet1"
sheet.append(["COMPANY'S NAME", "RATE CODE", "Opera展示的价格", "总价"])
sheet.append(["T- HANATOUR TD CO., L", "WHKR2100B", 2100, 2100])
sheet.append(["HANA TOUR", "WHKR2100B", 2100, 2100])
workbook.save(price_path)
workbook.close()
with self.assertRaises(core.ProcessingFailure) as caught:
core.load_price_map(price_path.resolve())
self.assertEqual(caught.exception.errors[0].code, "PRICE_DUPLICATE_KEY")
def test_standard_route_uses_same_fuzzy_identity(self):
cases = {
("T / Q.B.D. TRAVEL GROUP", "GRPA1"): "QBD",
("T- LIAN TAI TRAVEL", "LTLT"): "LIANTAI-FIT",
("T- LIAN TAI TRAVEL", "GRPA1"): "LIANTAI-GROUP",
("T- Rainbow Holiday Service", "LBW1"): "DY-AI-Easy-KB",
("T- FENG RUN TRAVEL", "GLSPCB"): "FENGRUN",
("T / Guangzhou Go-Easy", "GRPA1"): "DY-AI-Easy-KB",
}
for arguments, expected in cases.items():
with self.subTest(arguments=arguments):
self.assertEqual(core.route_standard(*arguments), expected)
def test_legacy_monthly_header_contract_is_rejected(self):
template = SKILL_ROOT / "assets" / "channel-report-template.xlsx"
workbook = load_workbook(template, data_only=False)
try:
workbook[core.KB_SHEET].cell(1, 20).value = None
with self.assertRaises(core.ProcessingFailure) as caught:
core.read_existing_monthly(workbook, date(2026, 7, 27))
self.assertIn(
"MONTHLY_HEADER_MISMATCH",
{error.code for error in caught.exception.errors},
)
finally:
workbook.close()
def test_existing_monthly_derived_values_are_revalidated(self):
template = SKILL_ROOT / "assets" / "channel-report-template.xlsx"
workbook = load_workbook(template, data_only=False)
try:
sheet = workbook[core.KB_SHEET]
values = {
"ARRIVAL": date(2026, 7, 26),
"DEPARTURE": date(2026, 7, 27),
"NIGHTS": 1,
"ADULTS": 1,
"CHILDREN": 0,
"BLOCK_CODE": "",
"NO_OF_ROOMS": 2,
"COMPANY_NAME": "T- Rainbow Holiday Service",
"CONFIRMATION_NO": "TEST-EXISTING",
"DISP_ROOM_NO": "TEST-ROOM-EXISTING",
"RATE_AMOUNT": 900,
"FULL_NAME": "TEST GUEST EXISTING",
"RES_COMMENT": "",
"TRACE_TEXT": "",
"PRODUCTS": "",
"RATE_CODE": "LBMS",
"ROOM_CATEGORY_LABEL": "TEST",
"REAL PRICE": 0,
"TOTAL PRICE": 1,
core.KB_HEADER: 100,
}
for column, header in enumerate(core.KB_CHANNEL_HEADERS, 1):
sheet.cell(2, column).value = values[header]
with self.assertRaises(core.ProcessingFailure) as caught:
core.read_existing_monthly(workbook, date(2026, 7, 27))
codes = {error.code for error in caught.exception.errors}
self.assertIn("MONTHLY_TOTAL_PRICE_INVALID", codes)
self.assertIn("MONTHLY_KB_INVALID", codes)
finally:
workbook.close()
def test_end_to_end_daily_and_monthly_zero_prices(self):
xml = """<?xml version="1.0" encoding="UTF-8"?>
<RES_DETAIL>
<LIST_G_GROUP_BY1>
<G_GROUP_BY1>
<GROUPBY1_SORT_COL>20260727</GROUPBY1_SORT_COL>
<GROUPBY1_COL>27-07-26</GROUPBY1_COL>
<LIST_G_RESERVATION>
<G_RESERVATION>
<ADULTS>2</ADULTS><BLOCK_CODE></BLOCK_CODE><CF_CHILDREN>0</CF_CHILDREN>
<COMPANY_NAME>T / Guangzhou Go-Easy</COMPANY_NAME>
<CONFIRMATION_NO>TEST-GOEASY</CONFIRMATION_NO><DISP_ROOM_NO>TEST-ROOM-1</DISP_ROOM_NO>
<EFFECTIVE_RATE_AMOUNT>12345</EFFECTIVE_RATE_AMOUNT><FULL_NAME>TEST GUEST ONE</FULL_NAME>
<NO_OF_ROOMS>1</NO_OF_ROOMS><PRODUCTS></PRODUCTS><RATE_CODE>LBSM</RATE_CODE>
<ROOM_CATEGORY_LABEL>TEST</ROOM_CATEGORY_LABEL>
<TRUNC_BEGIN>2026-07-27</TRUNC_BEGIN><TRUNC_END>2026-07-28</TRUNC_END>
</G_RESERVATION>
<G_RESERVATION>
<ADULTS>1</ADULTS><BLOCK_CODE></BLOCK_CODE><CF_CHILDREN>1</CF_CHILDREN>
<COMPANY_NAME>T- Rainbow Holiday Service</COMPANY_NAME>
<CONFIRMATION_NO>TEST-RAINBOW</CONFIRMATION_NO><DISP_ROOM_NO>TEST-ROOM-2</DISP_ROOM_NO>
<EFFECTIVE_RATE_AMOUNT>67890</EFFECTIVE_RATE_AMOUNT><FULL_NAME>TEST GUEST TWO</FULL_NAME>
<NO_OF_ROOMS>1</NO_OF_ROOMS><PRODUCTS></PRODUCTS><RATE_CODE>LBMS</RATE_CODE>
<ROOM_CATEGORY_LABEL>TEST</ROOM_CATEGORY_LABEL>
<TRUNC_BEGIN>2026-07-27</TRUNC_BEGIN><TRUNC_END>2026-07-29</TRUNC_END>
</G_RESERVATION>
<G_RESERVATION>
<ADULTS>2</ADULTS><BLOCK_CODE>TEST-BLOCK</BLOCK_CODE><CF_CHILDREN>0</CF_CHILDREN>
<COMPANY_NAME>T / Q.B.D. TRAVEL GROUP</COMPANY_NAME>
<CONFIRMATION_NO>TEST-QBD</CONFIRMATION_NO><DISP_ROOM_NO>TEST-ROOM-3</DISP_ROOM_NO>
<EFFECTIVE_RATE_AMOUNT>900</EFFECTIVE_RATE_AMOUNT><FULL_NAME>TEST GUEST THREE</FULL_NAME>
<NO_OF_ROOMS>2</NO_OF_ROOMS><PRODUCTS></PRODUCTS><RATE_CODE>GRPA1</RATE_CODE>
<ROOM_CATEGORY_LABEL>TEST</ROOM_CATEGORY_LABEL>
<TRUNC_BEGIN>2026-07-27</TRUNC_BEGIN><TRUNC_END>2026-07-30</TRUNC_END>
</G_RESERVATION>
</LIST_G_RESERVATION>
</G_GROUP_BY1>
</LIST_G_GROUP_BY1>
</RES_DETAIL>
"""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
xml_path = temp_path / "synthetic.xml"
output_dir = temp_path / "output"
result_path = output_dir / "result.json"
xml_path.write_text(xml, encoding="utf-8")
exit_code = core.process(
argparse.Namespace(
xml=str(xml_path.resolve()),
monthly_base=None,
output_dir=str(output_dir.resolve()),
result_json=str(result_path.resolve()),
)
)
self.assertEqual(exit_code, 0)
result = json.loads(result_path.read_text(encoding="utf-8"))
self.assertEqual(result["status"], "success")
daily_path = output_dir / result["outputs"]["daily_report"]
monthly_path = output_dir / result["outputs"]["monthly_report"]
daily = load_workbook(daily_path, data_only=False)
try:
confirmation_column = core.DAILY_HEADERS.index("CONFIRMATION_NO") + 1
real_price_column = core.DAILY_HEADERS.index("REAL PRICE") + 1
total_column = core.DAILY_HEADERS.index("TOTAL PRICE") + 1
prices = {
daily.active.cell(row, confirmation_column).value: (
daily.active.cell(row, real_price_column).value,
daily.active.cell(row, total_column).value,
)
for row in range(2, daily.active.max_row + 1)
if daily.active.cell(row, confirmation_column).value
}
self.assertEqual(
prices,
{
"TEST-GOEASY": (0, 0),
"TEST-RAINBOW": (0, 0),
"TEST-QBD": (1800, 10800),
},
)
for row in range(2, daily.active.max_row + 1):
if daily.active.cell(row, confirmation_column).value:
for column in (real_price_column, total_column):
self.assertNotEqual(daily.active.cell(row, column).data_type, "f")
finally:
daily.close()
monthly = load_workbook(monthly_path, data_only=False)
try:
self.assertIn(core.KB_SHEET, monthly.sheetnames)
target = monthly[core.KB_SHEET]
companies = [
target.cell(row, 8).value
for row in range(2, target.max_row + 1)
if target.cell(row, 8).value
]
self.assertCountEqual(
companies,
["T / Guangzhou Go-Easy", "T- Rainbow Holiday Service"],
)
target_values = {
target.cell(row, 9).value: (
target.cell(row, 18).value,
target.cell(row, 19).value,
target.cell(row, 20).value,
)
for row in range(2, target.max_row + 1)
if target.cell(row, 9).value
}
self.assertEqual(
target_values,
{
"TEST-GOEASY": (0, 0, 100),
"TEST-RAINBOW": (0, 0, 100),
},
)
qbd = monthly["QBD"]
self.assertEqual(qbd.cell(2, 9).value, "TEST-QBD")
self.assertEqual(qbd.cell(2, 18).value, 1800)
self.assertEqual(qbd.cell(2, 19).value, 10800)
for cell in (qbd.cell(2, 18), qbd.cell(2, 19)):
self.assertNotEqual(cell.data_type, "f")
for row in range(2, target.max_row + 1):
for column in (18, 19, 20):
self.assertNotEqual(target.cell(row, column).data_type, "f")
finally:
monthly.close()
if __name__ == "__main__":
unittest.main(verbosity=2)