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_missing_group_code_keeps_each_fact_with_blank_booking_room(self): facts = [ finance_fact( 2, group="", block_code="SYN-BLOCK-B", total_price=Decimal("1800"), ), finance_fact( 1, group="", block_code="SYN-BLOCK-A", total_price=Decimal("900"), ), ] report = build_company_report( "QBD", 2026, 7, date(2026, 7, 10), snapshot(facts, [], {}) ) self.assertTrue(report.valid) self.assertEqual(report.errors, ()) self.assertEqual(report.row_count, 2) rows = report.periods[0].rows self.assertEqual([row.record_ids for row in rows], [(1,), (2,)]) self.assertTrue(all(row.booking_room == "" for row in rows)) self.assertTrue(all(row.res_comment == "" for row in rows)) self.assertTrue(all(not row.duplicate_group for row in rows)) self.assertEqual( [row.total_booking_price for row in rows], ["【SYN-RM2】900×1 = 900", "【SYN-RM2】1,800×1 = 1,800"], ) def test_unmatched_group_code_keeps_segment_with_blank_booking_room(self): facts = [ finance_fact(1, block_code="SYN-BLOCK-A", total_price=Decimal("900")), finance_fact(2, block_code="SYN-BLOCK-B", total_price=Decimal("1800")), ] cases = ( ({}, {}), ({"SYN-GROUP-A": 700}, {"SYN-GROUP-A": 700}), ) for parse_versions, expected_booking_versions in cases: with self.subTest(parse_versions=parse_versions): report = build_company_report( "QBD", 2026, 7, date(2026, 7, 10), snapshot(facts, [], parse_versions), ) self.assertTrue(report.valid) self.assertEqual(report.errors, ()) self.assertEqual(report.row_count, 1) row = report.periods[0].rows[0] self.assertEqual(row.res_comment, "SYN-GROUP-A") self.assertEqual(row.booking_room, "") self.assertEqual(row.block_code, "SYN-BLOCK-A;SYN-BLOCK-B") self.assertEqual( row.total_booking_price, "【SYN-RM2】900×1 + 【SYN-RM2】1,800×1 = 2,700", ) self.assertTrue(row.multi_price_review) self.assertEqual( [warning.code for warning in report.warnings], [WarningCode.MULTI_PRICE_REVIEW], ) self.assertEqual(report.booking_versions, expected_booking_versions) def test_invalid_source_facts_block_the_company_report(self): cases = ( ( 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, ), ) 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()