feat: prepare ARR for controlled public deployment
This commit is contained in:
313
tests/test_pricing_rules.py
Normal file
313
tests/test_pricing_rules.py
Normal file
@@ -0,0 +1,313 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user