from __future__ import annotations import unittest from datetime import date from decimal import Decimal from monthly_reports.contracts import ( KB_HEADER, ChannelObservation, DailyVersionPin, ErrorCode, MonthlyFact, MonthlyReportError, MonthlySnapshot, STANDARD_SHEETS, ) from monthly_reports.core import build_monthly_report def fact( record_id: int, *, business_date: date = date(2026, 7, 2), daily_version_id: int = 102, channel: str = "QBD", rooms: int = 1, nights: int = 2, real_price: Decimal = Decimal("450.00"), ) -> MonthlyFact: return MonthlyFact( daily_record_id=record_id, daily_version_id=daily_version_id, business_date=business_date, channel_key=channel, arrival=business_date, departure=date.fromordinal(business_date.toordinal() + nights), nights=nights, adults=2, children=0, block_code="SYN-BLOCK", no_of_rooms=rooms, company_name="SYN-COMPANY", confirmation_no=f"SYN-{record_id}", disp_room_no=f"SYN-RM-{record_id}", effective_rate_amount=real_price, full_name="SYN-GUEST", res_comment="SYN-COMMENT", trace_text="", products="", rate_code="SYN-RATE", room_category_label="SYN-TYPE", real_price=real_price, total_price=real_price * rooms * nights, kb_amount=Decimal(rooms * 100) if channel == "DY-AI-Easy-KB" else None, ) def snapshot(*facts: MonthlyFact, preferred: tuple[str, ...] = ()) -> MonthlySnapshot: pins = tuple( DailyVersionPin(day, version) for day, version in sorted( {item.business_date: item.daily_version_id for item in facts}.items() ) ) counts: dict[tuple[date, int, str], int] = {} for item in facts: key = (item.business_date, item.daily_version_id, item.channel_key) counts[key] = counts.get(key, 0) + 1 observations = tuple( ChannelObservation(day, version, channel, None, count) for (day, version, channel), count in sorted(counts.items()) ) return MonthlySnapshot(tuple(facts), pins, observations, preferred) class MonthlyReportsCoreTests(unittest.TestCase): def test_standard_sheets_are_retained_and_unknown_order_is_stable(self): report = build_monthly_report( 2026, 7, date(2026, 7, 2), snapshot( fact(2, channel="T- HANATOUR TD CO., L"), fact(1), preferred=("T- HANATOUR TD CO., L",), ), ) self.assertEqual( tuple(channel.worksheet for channel in report.channels), STANDARD_SHEETS + ("T- HANATOUR TD CO., L",), ) self.assertEqual(report.row_count, 2) self.assertEqual(report.channel_manifest[-1], ("T- HANATOUR TD CO., L", 6, 1)) self.assertEqual(report.filename, "各渠道情况-2026年07月-更新至7.2.xlsx") def test_kb_payload_has_extra_typed_amount(self): report = build_monthly_report( 2026, 7, date(2026, 7, 2), snapshot(fact(1, channel="DY-AI-Easy-KB", rooms=2)), ) payload = report.to_workbook_payload() kb = next(item for item in payload["channels"] if item["worksheet"] == "DY-AI-Easy-KB") self.assertEqual(kb["headers"][-1], KB_HEADER) self.assertEqual(kb["rows"][0][KB_HEADER], "200") self.assertEqual(kb["rows"][0]["TOTAL PRICE"], "1800") def test_empty_current_day_is_pinned(self): report = build_monthly_report( 2026, 7, date(2026, 7, 26), MonthlySnapshot( facts=(), daily_versions=(DailyVersionPin(date(2026, 7, 26), 126),), channel_observations=(), ), ) self.assertEqual(report.row_count, 0) self.assertEqual(report.daily_versions[0].daily_version_id, 126) def test_nonempty_report_rejects_as_of_after_greatest_arrival(self): with self.assertRaises(MonthlyReportError) as caught: build_monthly_report( 2026, 7, date(2026, 7, 30), snapshot(fact(1, business_date=date(2026, 7, 27))), ) self.assertEqual(caught.exception.code, ErrorCode.SOURCE_INVALID) self.assertIn("greatest included ARRIVAL", caught.exception.safe_message) def test_mismatched_metric_or_pin_is_rejected(self): source = fact(1) cases = ( MonthlySnapshot( (source,), (DailyVersionPin(source.business_date, 999),), (ChannelObservation(source.business_date, 999, "QBD", 1, 1),), ), MonthlySnapshot( (source,), (DailyVersionPin(source.business_date, source.daily_version_id),), (ChannelObservation(source.business_date, source.daily_version_id, "QBD", 1, 2),), ), ) for current in cases: with self.subTest(current=current): with self.assertRaises(MonthlyReportError) as caught: build_monthly_report(2026, 7, date(2026, 7, 26), current) self.assertEqual(caught.exception.code, ErrorCode.SOURCE_INVALID) if __name__ == "__main__": unittest.main()