226 lines
8.0 KiB
Python
226 lines
8.0 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
from monthly_reports.contracts import (
|
|
ChannelObservation,
|
|
DailyVersionPin,
|
|
ErrorCode,
|
|
MonthlyFact,
|
|
MonthlySnapshot,
|
|
)
|
|
from monthly_reports.publishing import BuildError, BuiltWorkbook, PublicationOutcome
|
|
from monthly_reports.repository import FileMetadata, RepositoryError, ReservedReport
|
|
from monthly_reports.service import MonthlyReportService, RunRequest, write_run_result
|
|
|
|
|
|
def synthetic_snapshot() -> MonthlySnapshot:
|
|
source = MonthlyFact(
|
|
daily_record_id=1,
|
|
daily_version_id=100,
|
|
business_date=date(2026, 7, 8),
|
|
channel_key="QBD",
|
|
arrival=date(2026, 7, 8),
|
|
departure=date(2026, 7, 10),
|
|
nights=2,
|
|
adults=2,
|
|
children=0,
|
|
block_code="SYN-BLOCK-A",
|
|
no_of_rooms=1,
|
|
company_name="SYN-COMPANY",
|
|
confirmation_no="SYN-CONFIRMATION",
|
|
disp_room_no="SYN-ROOM",
|
|
effective_rate_amount=Decimal("450"),
|
|
full_name="SYN-GUEST",
|
|
res_comment="SYN-COMMENT",
|
|
trace_text="",
|
|
products="",
|
|
rate_code="SYN-RATE",
|
|
room_category_label="SYN-TYPE",
|
|
real_price=Decimal("450"),
|
|
total_price=Decimal("900"),
|
|
kb_amount=None,
|
|
)
|
|
return MonthlySnapshot(
|
|
(source,),
|
|
(DailyVersionPin(date(2026, 7, 8), 100),),
|
|
(ChannelObservation(date(2026, 7, 8), 100, "QBD", 1, 1),),
|
|
)
|
|
|
|
|
|
class FakeRepository:
|
|
def __init__(self, load_error=None):
|
|
self.load_error = load_error
|
|
self.failed = []
|
|
self.activated = []
|
|
|
|
def load_snapshot(self, _year, _month, _as_of):
|
|
if self.load_error:
|
|
raise self.load_error
|
|
return synthetic_snapshot()
|
|
|
|
def reserve_report(self, _report):
|
|
return ReservedReport(44, 2)
|
|
|
|
def activate_report(self, reservation, artifact, result_json, semantic_sha256):
|
|
self.activated.append((reservation, artifact, result_json, semantic_sha256))
|
|
|
|
def mark_failed(self, reservation, code, safe_message):
|
|
self.failed.append((reservation, code, safe_message))
|
|
|
|
|
|
class FakeBuilder:
|
|
def __init__(self, fail: bool = False):
|
|
self.fail = fail
|
|
|
|
def build(self, report, work_dir: Path):
|
|
if self.fail:
|
|
raise BuildError(
|
|
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
|
"synthetic builder failure",
|
|
)
|
|
output = work_dir / report.filename
|
|
output.write_bytes(b"synthetic-workbook")
|
|
return BuiltWorkbook(
|
|
output,
|
|
"a" * 64,
|
|
output.stat().st_size,
|
|
{"semantic_sha256": "c" * 64},
|
|
)
|
|
|
|
|
|
class FakePublisher:
|
|
def publish(self, report, reservation, built, repository, work_dir):
|
|
artifact = FileMetadata(
|
|
"monthly_xlsx",
|
|
report.filename,
|
|
f"outputs/monthly_reports/2026/07/archive/v{reservation.version_no:04d}/{report.filename}",
|
|
built.sha256,
|
|
built.byte_size,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
)
|
|
result_json = FileMetadata(
|
|
"result_json",
|
|
"result.json",
|
|
f"outputs/monthly_reports/2026/07/archive/v{reservation.version_no:04d}/result.json",
|
|
"b" * 64,
|
|
1,
|
|
"application/json",
|
|
)
|
|
repository.activate_report(
|
|
reservation,
|
|
artifact,
|
|
result_json,
|
|
str(built.summary["semantic_sha256"]),
|
|
)
|
|
return PublicationOutcome(
|
|
latest_path=work_dir / "latest.xlsx",
|
|
latest_result_path=work_dir / "latest.result.json",
|
|
archive_path=work_dir / report.filename,
|
|
result_path=work_dir / "result.json",
|
|
artifact=artifact,
|
|
result_json=result_json,
|
|
)
|
|
|
|
|
|
class MonthlyReportsServiceTests(unittest.TestCase):
|
|
def test_success_is_privacy_minimized_and_versioned(self):
|
|
repository = FakeRepository()
|
|
with tempfile.TemporaryDirectory(prefix="monthly-report-service-") as temp_dir:
|
|
root = Path(temp_dir)
|
|
service = MonthlyReportService(
|
|
repository,
|
|
FakeBuilder(),
|
|
FakePublisher(),
|
|
root / "staging",
|
|
)
|
|
result = service.run(RunRequest(2026, 7, date(2026, 7, 8)))
|
|
result_path = root / "run.result.json"
|
|
write_run_result(result_path, result)
|
|
serialized = result_path.read_text(encoding="utf-8")
|
|
|
|
self.assertEqual(result.status, "success")
|
|
self.assertEqual(result.exit_code, 0)
|
|
self.assertEqual(result.row_count, 1)
|
|
self.assertEqual(result.report_version_id, 44)
|
|
self.assertEqual(json.loads(serialized)["channel_manifest"][2]["worksheet"], "QBD")
|
|
self.assertNotIn("SYN-GUEST", serialized)
|
|
self.assertNotIn("SYN-CONFIRMATION", serialized)
|
|
self.assertNotIn("SYN-BLOCK", serialized)
|
|
self.assertEqual(len(repository.activated), 1)
|
|
|
|
def test_published_snapshot_replay_reuses_registered_artifact_without_building(self):
|
|
class PublishedRepository(FakeRepository):
|
|
def reserve_report(self, _report):
|
|
return ReservedReport(
|
|
report_version_id=44,
|
|
version_no=2,
|
|
report_status="active",
|
|
published_artifact=FileMetadata(
|
|
"monthly_xlsx",
|
|
"各渠道情况-2026年07月-更新至7.8.xlsx",
|
|
"outputs/monthly_reports/2026/07/archive/v0002/report.xlsx",
|
|
"a" * 64,
|
|
100,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
),
|
|
)
|
|
|
|
class NeverBuilder:
|
|
def build(self, _report, _work_dir):
|
|
raise AssertionError("published replay must not rebuild the workbook")
|
|
|
|
repository = PublishedRepository()
|
|
with tempfile.TemporaryDirectory(prefix="monthly-report-service-") as temp_dir:
|
|
result = MonthlyReportService(
|
|
repository,
|
|
NeverBuilder(),
|
|
FakePublisher(),
|
|
Path(temp_dir) / "staging",
|
|
).run(RunRequest(2026, 7, date(2026, 7, 8)))
|
|
|
|
self.assertEqual(result.status, "success")
|
|
self.assertEqual(result.report_version_id, 44)
|
|
self.assertEqual(result.artifact["sha256"], "a" * 64)
|
|
self.assertEqual(repository.activated, [])
|
|
|
|
def test_builder_failure_marks_reserved_report_failed(self):
|
|
repository = FakeRepository()
|
|
with tempfile.TemporaryDirectory(prefix="monthly-report-service-") as temp_dir:
|
|
result = MonthlyReportService(
|
|
repository,
|
|
FakeBuilder(fail=True),
|
|
FakePublisher(),
|
|
Path(temp_dir) / "staging",
|
|
).run(RunRequest(2026, 7, date(2026, 7, 8)))
|
|
|
|
self.assertEqual(result.status, "failed")
|
|
self.assertEqual(result.error_code, ErrorCode.OUTPUT_VALIDATION_FAILED)
|
|
self.assertEqual(repository.failed[0][0], ReservedReport(44, 2))
|
|
|
|
def test_source_failure_uses_business_or_system_exit_code(self):
|
|
cases = (
|
|
(ErrorCode.SOURCE_SNAPSHOT_STALE, 2),
|
|
(ErrorCode.DATABASE_FAILED, 4),
|
|
)
|
|
for code, exit_code in cases:
|
|
with self.subTest(code=code), tempfile.TemporaryDirectory() as temp_dir:
|
|
repository = FakeRepository(RepositoryError(code, "synthetic source failure"))
|
|
result = MonthlyReportService(
|
|
repository,
|
|
FakeBuilder(),
|
|
FakePublisher(),
|
|
Path(temp_dir) / "staging",
|
|
).run(RunRequest(2026, 7, date(2026, 7, 8)))
|
|
self.assertEqual(result.exit_code, exit_code)
|
|
self.assertEqual(result.error_stage, "source")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|