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

343 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import unittest
from datetime import date
from decimal import Decimal
from typing import Dict, List, Optional
from company_reports.contracts import (
BatchSnapshot,
BookingRoomItem,
DailyVersionPin,
ErrorCode,
FinanceFact,
WarningCode,
)
from company_reports.core import (
build_company_report,
company_for_fact,
normalize_group_code,
)
def finance_fact(
record_id: int,
*,
daily_version_id: int = 1000,
group: str = "SYN-GROUP-A",
arrival: date = date(2026, 7, 8),
departure: date = date(2026, 7, 10),
nights: int = 2,
room_label: str = "SYN-RM2",
total_price: Decimal = Decimal("900"),
block_code: str = "SYN-BLOCK-A",
company_key: str = "QBD",
channel_key: str = "QBD",
) -> FinanceFact:
return FinanceFact(
daily_record_id=record_id,
daily_version_id=daily_version_id,
business_date=date(2026, 7, 9),
channel_key=channel_key,
company_key=company_key,
company_name="SYN-COMPANY",
block_code=block_code,
group_code_key=group,
res_comment=group,
room_category_label=room_label,
arrival=arrival,
departure=departure,
nights=nights,
total_price=total_price,
booking_source_match_status="matched",
)
def room_item(
item_no: int,
*,
group: str = "SYN-GROUP-A",
arrival: date = date(2026, 7, 8),
departure: date = date(2026, 7, 10),
nights: int = 2,
room_type: str = "SYN-SUP-TWN",
quantity: int = 1,
parse_version_id: int = 700,
segment_no: int = 1,
) -> BookingRoomItem:
return BookingRoomItem(
group_code_key=group,
parse_version_id=parse_version_id,
segment_no=segment_no,
arrival=arrival,
departure=departure,
nights=nights,
item_no=item_no,
room_type_raw=room_type,
quantity=quantity,
)
def snapshot(
facts: List[FinanceFact],
items: List[BookingRoomItem],
parse_versions: Optional[Dict[str, int]] = None,
) -> BatchSnapshot:
pins_by_date = {fact.business_date: fact.daily_version_id for fact in facts}
pins = tuple(
DailyVersionPin(business_date, version_id)
for business_date, version_id in sorted(pins_by_date.items())
)
return BatchSnapshot(
facts=tuple(facts),
room_items=tuple(items),
daily_versions=pins,
group_parse_versions=parse_versions
if parse_versions is not None
else {"SYN-GROUP-A": 700},
)
class CompanyReportCoreTests(unittest.TestCase):
def test_group_code_normalization_and_company_mapping_precedence(self):
self.assertEqual(normalize_group_code(" syngroup-a "), "SYN-GROUP-A")
cases = (
("LIAN TAI", "OTHER", "LianTai"),
("QBD", "OTHER", "QBD"),
("UNMAPPED", "DY-AI-Easy-KB", "DY-AI-Easy-KB"),
("FENGRUN", "OTHER", "FengRun"),
("HANA TOUR", "OTHER", "HanaTour"),
)
for company_key, channel_key, expected in cases:
with self.subTest(expected=expected):
self.assertEqual(
company_for_fact(
finance_fact(
1,
company_key=company_key,
channel_key=channel_key,
)
),
expected,
)
def test_same_stay_is_aggregated_with_static_prices_and_room_quantities(self):
facts = [
finance_fact(1),
finance_fact(2, block_code="SYN-BLOCK-B"),
finance_fact(
3,
room_label="SYN-RM3",
total_price=Decimal("3400"),
block_code="SYN-BLOCK-A",
),
]
items = [
room_item(1, quantity=1),
room_item(2, quantity=1),
room_item(3, room_type="SYN-SUP-TRP", quantity=1),
]
report = build_company_report(
"QBD", 2026, 7, date(2026, 7, 10), snapshot(facts, items)
)
self.assertTrue(report.valid)
self.assertEqual(report.row_count, 1)
row = report.periods[0].rows[0]
self.assertEqual(row.block_code, "SYN-BLOCK-ASYN-BLOCK-B")
self.assertEqual(row.booking_room, "【SYN-SUP-TWN】2【SYN-SUP-TRP】1")
self.assertEqual(
row.total_booking_price,
"【SYN-RM2】900×2 + 【SYN-RM3】3,400×1 = 5,200",
)
self.assertFalse(row.duplicate_group)
self.assertFalse(row.multi_price_review)
self.assertFalse(report.periods[1].active)
self.assertEqual(report.periods[1].rows, ())
def test_same_group_with_different_stays_is_split_and_highlighted(self):
second_arrival = date(2026, 7, 17)
second_departure = date(2026, 7, 21)
facts = [
finance_fact(1),
finance_fact(
2,
arrival=second_arrival,
departure=second_departure,
nights=4,
total_price=Decimal("1800"),
),
]
items = [
room_item(1),
room_item(
2,
arrival=second_arrival,
departure=second_departure,
nights=4,
quantity=2,
segment_no=2,
),
]
report = build_company_report(
"QBD", 2026, 7, date(2026, 7, 31), snapshot(facts, items)
)
self.assertTrue(report.valid)
self.assertEqual([len(period.rows) for period in report.periods], [1, 0, 1])
all_rows = [row for period in report.periods for row in period.rows]
self.assertTrue(all(row.duplicate_group for row in all_rows))
self.assertEqual(
all_rows[1].total_booking_price,
"【SYN-RM2】1,800×1 = 1,800",
)
def test_same_room_type_with_multiple_static_prices_warns_but_succeeds(self):
facts = [
finance_fact(1, total_price=Decimal("900")),
finance_fact(2, total_price=Decimal("1800")),
]
report = build_company_report(
"QBD", 2026, 7, date(2026, 7, 10), snapshot(facts, [room_item(1)])
)
self.assertTrue(report.valid)
self.assertEqual(
[warning.code for warning in report.warnings],
[WarningCode.MULTI_PRICE_REVIEW],
)
row = report.periods[0].rows[0]
self.assertTrue(row.multi_price_review)
self.assertEqual(
row.total_booking_price,
"【SYN-RM2】900×1 + 【SYN-RM2】1,800×1 = 2,700",
)
def test_invalid_source_facts_block_the_company_report(self):
cases = (
(
finance_fact(1, group=""),
[room_item(1)],
{"SYN-GROUP-A": 700},
ErrorCode.GROUP_CODE_MISSING,
),
(
finance_fact(1, nights=3),
[room_item(1)],
{"SYN-GROUP-A": 700},
ErrorCode.NIGHTS_CONFLICT,
),
(
finance_fact(1, total_price=Decimal("-1")),
[room_item(1)],
{"SYN-GROUP-A": 700},
ErrorCode.TOTAL_PRICE_INVALID,
),
(
finance_fact(1),
[room_item(1)],
{},
ErrorCode.BOOKING_PARSE_FAILED,
),
(
finance_fact(1),
[],
{"SYN-GROUP-A": 700},
ErrorCode.ROOM_ITEMS_MISSING,
),
)
for fact, items, versions, expected in cases:
with self.subTest(expected=expected):
report = build_company_report(
"QBD",
2026,
7,
date(2026, 7, 10),
snapshot([fact], items, versions),
)
self.assertFalse(report.valid)
self.assertIn(expected, {error.code for error in report.errors})
with self.assertRaises(ValueError):
report.to_workbook_payload()
def test_departure_controls_period_and_rows_are_deterministic(self):
facts = [
finance_fact(
2,
group="SYN-GROUP-B",
arrival=date(2026, 7, 9),
departure=date(2026, 7, 11),
),
finance_fact(
1,
arrival=date(2026, 7, 6),
departure=date(2026, 7, 10),
nights=4,
),
]
items = [
room_item(
1,
arrival=date(2026, 7, 6),
departure=date(2026, 7, 10),
nights=4,
),
room_item(
2,
group="SYN-GROUP-B",
arrival=date(2026, 7, 9),
departure=date(2026, 7, 11),
),
]
report = build_company_report(
"QBD",
2026,
7,
date(2026, 7, 20),
snapshot(
facts,
items,
{"SYN-GROUP-A": 700, "SYN-GROUP-B": 700},
),
)
self.assertTrue(report.valid)
self.assertEqual([len(period.rows) for period in report.periods], [1, 1, 0])
self.assertEqual(report.periods[0].rows[0].departure, date(2026, 7, 10))
self.assertEqual(report.periods[1].rows[0].departure, date(2026, 7, 11))
def test_empty_company_still_has_three_header_only_periods(self):
report = build_company_report(
"HanaTour",
2026,
2,
date(2026, 2, 28),
snapshot([], [], {}),
)
self.assertTrue(report.valid)
self.assertEqual(report.filename, "HanaTour-February-2026.xlsx")
self.assertEqual(
[period.key for period in report.periods],
["01-10", "11-20", "21-28"],
)
self.assertEqual(report.row_count, 0)
def test_month_names_are_fixed_english_contract_values(self):
report = build_company_report(
"FengRun",
2026,
9,
date(2026, 9, 10),
snapshot([], [], {}),
)
self.assertEqual(report.filename, "FengRun-September-2026.xlsx")
self.assertEqual(report.periods[0].sheet_name, "FengRun 01-10 Sep 2026")
if __name__ == "__main__":
unittest.main()