314 lines
14 KiB
Python
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)
|