from __future__ import annotations import json import os import shutil import subprocess import tempfile import unittest from datetime import date from decimal import Decimal from pathlib import Path from monthly_reports.contracts import ( ChannelObservation, DailyVersionPin, MonthlyFact, MonthlySnapshot, ) from monthly_reports.core import build_monthly_report PROJECT_ROOT = Path(__file__).resolve().parents[1] BUILDER = PROJECT_ROOT / "monthly_reports" / "xlsx" / "build_workbook.mjs" def artifact_tool_module() -> str: return os.environ.get("MONTHLY_REPORT_ARTIFACT_TOOL_MODULE", "").strip() def node_binary() -> str: return os.environ.get("MONTHLY_REPORT_NODE_BINARY", "").strip() or shutil.which("node") or "" def report_payload() -> dict: 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-FORMULA-LIKE", no_of_rooms=1, company_name="SYN-COMPANY", confirmation_no="SYN-CONFIRMATION", disp_room_no="SYN-ROOM", effective_rate_amount=Decimal("450.50"), full_name="SYN-GUEST", res_comment="SYN-COMMENT", trace_text="", products="", rate_code="SYN-RATE", room_category_label="SYN-TYPE", real_price=Decimal("450.50"), total_price=Decimal("901.00"), kb_amount=None, ) report = build_monthly_report( 2026, 7, date(2026, 7, 26), MonthlySnapshot( (source,), (DailyVersionPin(date(2026, 7, 8), 100),), (ChannelObservation(date(2026, 7, 8), 100, "QBD", 1, 1),), ), ) return report.to_workbook_payload() @unittest.skipUnless( bool(artifact_tool_module() and Path(artifact_tool_module()).is_file()), "artifact-tool module is not configured", ) class MonthlyReportsXlsxTests(unittest.TestCase): def test_builder_exports_reopens_scans_and_renders_every_channel(self): node = node_binary() if not node: self.skipTest("Node.js is unavailable") with tempfile.TemporaryDirectory(prefix="monthly-report-xlsx-") as temp_dir: root = Path(temp_dir) payload_path = root / "payload.json" payload_path.write_text( json.dumps(report_payload(), ensure_ascii=False), encoding="utf-8", ) output = root / "monthly.xlsx" previews = root / "previews" summary_path = root / "summary.json" completed = subprocess.run( [ node, str(BUILDER), str(payload_path), str(output), str(previews), str(summary_path), ], cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=120, check=False, env=os.environ.copy(), ) self.assertEqual(completed.returncode, 0, completed.stderr) summary = json.loads(summary_path.read_text(encoding="utf-8")) self.assertEqual(summary["sheet_names"][:5], [ "LIANTAI-GROUP", "LIANTAI-FIT", "QBD", "DY-AI-Easy-KB", "FENGRUN" ]) self.assertEqual(summary["row_counts"], [0, 0, 1, 0, 0]) self.assertEqual(summary["formula_count"], 0) self.assertEqual(summary["preview_count"], 5) self.assertEqual(len(list(previews.glob("sheet-*.png"))), 5) self.assertEqual(output.stat().st_mode & 0o777, 0o600) self.assertFalse(Path(f"{output}.inspect.ndjson").exists()) def test_invalid_payload_returns_only_stable_error_code(self): node = node_binary() if not node: self.skipTest("Node.js is unavailable") with tempfile.TemporaryDirectory(prefix="monthly-report-xlsx-") as temp_dir: root = Path(temp_dir) invalid = root / "invalid.json" invalid.write_text( json.dumps({"schema_version": "bad", "sensitive": "SYN-SECRET"}), encoding="utf-8", ) completed = subprocess.run( [ node, str(BUILDER), str(invalid), str(root / "invalid.xlsx"), str(root / "previews"), str(root / "summary.json"), ], cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=30, check=False, env=os.environ.copy(), ) self.assertEqual(completed.returncode, 4) self.assertEqual( json.loads(completed.stderr), { "status": "failed", "code": "MONTHLY_REPORT_OUTPUT_VALIDATION_FAILED", }, ) self.assertNotIn("SYN-SECRET", completed.stderr) if __name__ == "__main__": unittest.main()