343 lines
10 KiB
Python
343 lines
10 KiB
Python
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(" syn-group-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-A;SYN-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()
|