160 lines
5.3 KiB
Python
160 lines
5.3 KiB
Python
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()
|