feat: add public h5 dashboard and publish traceability

This commit is contained in:
Wyndham ARR
2026-08-03 13:04:41 +08:00
parent 7e7470821b
commit 2107f00e32
29 changed files with 1047 additions and 71 deletions

View File

@@ -370,6 +370,9 @@ class PortalApplicationTests(unittest.TestCase):
self.assertIn(".company-page-title", styles)
self.assertIn(".company-fixed-companies", styles)
self.assertIn(".company-source-action", styles)
self.assertIn(".company-source-current", styles)
self.assertIn(".company-job-source", styles)
self.assertIn(".company-history-source", styles)
self.assertIn(".company-review-file", styles)
self.assertIn("overflow-wrap: anywhere", styles)
self.assertIn(".company-review-row.is-pending", styles)
@@ -392,6 +395,8 @@ class PortalApplicationTests(unittest.TestCase):
self.assertNotIn("window.confirm", script)
self.assertIn("companyReportConfirmIsOpen", script)
self.assertIn("confirmCompanyReportAction", script)
self.assertIn("BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED", script)
self.assertIn("requestError.code", script)
self.assertIn("state.companyReportConfirmRequest", script)
self.assertNotIn("company-review-guidance", script)
self.assertNotIn("company-review-description", script)
@@ -400,6 +405,17 @@ class PortalApplicationTests(unittest.TestCase):
self.assertNotIn("Excel 已校验", script)
self.assertNotIn('id="company-source-state"', desktop_text)
self.assertNotIn('id="company-source-summary"', desktop_text)
for required in (
'id="company-source-current"',
'id="company-source-current-filename"',
'id="company-source-current-meta"',
'id="company-source-status"',
'id="company-report-job-source"',
'id="company-report-job-source-file"',
'id="company-report-job-source-meta"',
"来源 Excel",
):
self.assertIn(required, desktop_text)
self.assertIn('String(summary.filename || "").trim()', script)
self.assertIn('$("#company-review-filename").textContent = filename || "未记录文件名"', script)
self.assertLess(
@@ -580,6 +596,36 @@ class PortalApplicationTests(unittest.TestCase):
"COMPANY-A",
)
def test_anonymous_h5_exposes_only_sanitized_read_only_dashboard(self) -> None:
mobile = self.app.handle("GET", "/h5", {})
mobile_css = self.app.handle("GET", "/assets/h5.css", {})
mobile_js = self.app.handle("GET", "/assets/h5.js", {})
months = decoded(self.app.handle("GET", "/api/public/h5/months", {}))
analytics_response = self.app.handle(
"GET",
"/api/public/h5/analytics?month=2026-07",
{},
)
analytics = decoded(analytics_response)
protected_generic = self.app.handle(
"GET",
"/api/analytics?month=2026-07",
{},
)
protected_legacy = self.app.handle("GET", "/api/h5/months", {})
self.assertEqual(mobile.status, 200)
self.assertEqual(mobile_css.status, 200)
self.assertEqual(mobile_js.status, 200)
self.assertEqual(months["data"][0]["month_key"], "2026-07")
self.assertEqual(analytics_response.headers["Cache-Control"], "no-store")
self.assertEqual(analytics["data"]["month_key"], "2026-07")
self.assertIn("channels", analytics["data"])
self.assertNotIn("source_monthly_sha256", analytics["data"])
self.assertNotIn("filename", months["data"][0])
self.assertEqual(protected_generic.status, 401)
self.assertEqual(protected_legacy.status, 401)
def test_health_exposes_only_programmatic_processing_readiness(self) -> None:
payload = decoded(
self.app.handle("GET", "/api/health", self.auth_headers)

View File

@@ -66,9 +66,11 @@ class PortalAuthenticationTests(unittest.TestCase):
health=RuntimeHealth(True, True, False),
)
def test_only_login_assets_and_minimal_readiness_are_public(self) -> None:
def test_h5_shell_is_public_while_desktop_and_detail_routes_stay_private(self) -> None:
desktop = self.app.handle("GET", "/", {})
mobile = self.app.handle("GET", "/h5", {})
mobile_css = self.app.handle("GET", "/assets/h5.css", {})
mobile_js = self.app.handle("GET", "/assets/h5.js", {})
protected_api = self.app.handle("GET", "/api/health", {})
protected_asset = self.app.handle("GET", "/assets/app.js", {})
login_page = self.app.handle("GET", "/login", {})
@@ -76,7 +78,9 @@ class PortalAuthenticationTests(unittest.TestCase):
readiness = self.app.handle("GET", "/healthz", {})
self.assertEqual((desktop.status, desktop.headers["Location"]), (303, "/login?next=%2F"))
self.assertEqual((mobile.status, mobile.headers["Location"]), (303, "/login?next=%2Fh5"))
self.assertEqual(mobile.status, 200)
self.assertEqual(mobile_css.status, 200)
self.assertEqual(mobile_js.status, 200)
self.assertEqual(protected_api.status, 401)
self.assertEqual(decoded(protected_api)["error"]["code"], "AUTH_REQUIRED")
self.assertEqual(protected_asset.status, 303)
@@ -207,11 +211,14 @@ class StaticLoginContractTests(unittest.TestCase):
self.assertIn('<a class="logout-link" id="logout-button" href="#logout" hidden>', desktop_html)
self.assertIn('<a class="mobile-logout" id="h5-logout" href="#logout" hidden>', mobile_html)
self.assertIn("if (!session.username) return", desktop_js)
self.assertIn("if (session.username)", mobile_js)
self.assertIn("if (session?.username)", mobile_js)
self.assertIn("response.status === 401", desktop_js)
self.assertIn('api("/api/logout", { method: "POST" })', desktop_js)
self.assertIn("response.status === 401", mobile_js)
self.assertIn('api("/api/logout", { method: "POST" })', mobile_js)
self.assertIn("/api/public/h5/months", mobile_js)
self.assertIn("/api/public/h5/analytics", mobile_js)
self.assertIn('fetch("/healthz"', mobile_js)
if __name__ == "__main__":

View File

@@ -9,7 +9,7 @@ import time
import unittest
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, Mapping
from typing import Any, Dict, Mapping, Optional
from zoneinfo import ZoneInfo
from arr_web.app import PortalApplication, RuntimeHealth, SessionLedger
@@ -219,9 +219,22 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
now=lambda: datetime(2026, 8, 1, tzinfo=BANGKOK),
)
try:
created = coordinator.create("2026-07", "11-20")
source = {
"source_batch_id": 7,
"source_type": "excel",
"filename": "Booking 报表.xlsx",
"source_rows": 12,
"worksheet_count": 2,
"distinct_group_codes": 8,
"room_quantity": 14,
"activated_at": "2026-07-31T08:00:00+07:00",
"disposition": "current",
}
created = coordinator.create("2026-07", "11-20", source=source)
self.assertEqual(created["source"], source)
job = wait_terminal(coordinator, created["job_id"])
self.assertEqual(job["state"], "succeeded")
self.assertEqual(job["source"], source)
self.assertEqual(job["requested_companies"], list(COMPANY_NAMES))
self.assertEqual(len(job["company_results"]), 5)
self.assertEqual(job["company_results"][1]["warnings"][0]["record_ids"], [2])
@@ -314,7 +327,7 @@ class CompanyReportCoordinatorTests(unittest.TestCase):
class FakeCompanyCoordinator:
def __init__(self) -> None:
self.created: list[tuple[str, str]] = []
self.created: list[tuple[str, str, Optional[Mapping[str, Any]]]] = []
self.listed: list[tuple[object, int, int]] = []
self.descriptor = ArtifactDescriptor(
"company_ten_day_xlsx",
@@ -325,9 +338,15 @@ class FakeCompanyCoordinator:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
def create(self, report_month: str, period: str) -> Dict[str, Any]:
self.created.append((report_month, period))
return {"job_id": "a" * 32, "state": "queued"}
def create(
self,
report_month: str,
period: str,
*,
source: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
self.created.append((report_month, period, source))
return {"job_id": "a" * 32, "state": "queued", "source": source}
def list_jobs(
self,
@@ -477,7 +496,11 @@ class CompanyReportRouteTests(unittest.TestCase):
body,
)
self.assertEqual(response.status, 202)
self.assertEqual(self.coordinator.created, [("2026-07", "01-10")])
self.assertEqual(
self.coordinator.created,
[("2026-07", "01-10", self.booking_sources.source)],
)
self.assertEqual(decoded(response)["data"]["source"]["source_batch_id"], 7)
invalid = self.app.handle(
"POST",
"/api/company-reports/jobs",

View File

@@ -92,6 +92,9 @@ class JobTraceTests(unittest.TestCase):
self.assertEqual(trace["job"]["current_stage"], "downstream")
self.assertFalse(trace["job"]["active"])
self.assertIsNone(trace["job"]["remote_run_id"])
self.assertEqual(trace["job"]["execution_scope"], "arr_runtime")
self.assertEqual(trace["job"]["processor_mode"], "fixed_processor")
self.assertEqual(trace["job"]["remote_dispatch"], "none")
self.assertEqual(trace["job"]["version_no"], 2)
self.assertEqual(trace["job"]["output_rows"], 17)
codes = [item["code"] for item in trace["logs"]]
@@ -121,6 +124,9 @@ class JobTraceTests(unittest.TestCase):
"MCP",
):
self.assertNotIn(private_value, rendered)
self.assertNotIn("本地处理", rendered)
self.assertIn("ARR 固定处理器输出", rendered)
self.assertIn("Finance 数据库提交已完成", rendered)
def test_runtime_failure_is_located_at_processor_boundary(self) -> None:
finished = self.started + timedelta(minutes=4)
@@ -160,6 +166,43 @@ class JobTraceTests(unittest.TestCase):
self.assertGreaterEqual(len(errors), 2)
self.assertTrue(all(item["stage"] == "processor" for item in errors))
def test_legacy_direct_mcp_trace_exposes_remote_semantics(self) -> None:
finished = self.started + timedelta(minutes=4)
trace = build_job_trace(
{
"job_id": "arrjob-legacy-001",
"run_status": "failed",
"public_status": "failed",
"delivery_mode": "direct_mcp",
"failure_code": "REMOTE_FAILED",
"failure_message": "remote processor failed",
"created_at": self.started,
"updated_at": finished,
"finished_at": finished,
"filename": "source.xml",
"byte_size": 99,
"source_sha256": "d" * 64,
},
attempts=[
{
"attempt_no": 1,
"attempt_status": "failed",
"remote_run_id": "remote-run-123",
"failure_code": "REMOTE_FAILED",
"failure_message": "remote processor failed",
"created_at": self.started + timedelta(seconds=1),
"started_at": self.started + timedelta(seconds=2),
"finished_at": finished,
}
],
refreshed_at=finished,
)
self.assertEqual(trace["job"]["execution_scope"], "remote_agent")
self.assertEqual(trace["job"]["processor_mode"], "legacy_direct_mcp")
self.assertEqual(trace["job"]["remote_dispatch"], "mcp")
self.assertEqual(trace["job"]["remote_run_id"], "remote-run-123")
def test_active_run_ends_with_current_programmatic_state(self) -> None:
trace = build_job_trace(
{

View File

@@ -9,7 +9,13 @@ from dataclasses import replace
from datetime import date
from pathlib import Path
from company_reports.publishing import ArtifactToolBuilder, AtomicReportPublisher
from company_reports.publishing import (
ArtifactToolBuilder,
AtomicReportPublisher,
BuiltWorkbook,
sha256_file,
)
from company_reports.repository import ReservedReport
from company_reports.service import CompanyReportService, RunRequest
from tests.test_company_reports_service import FakeRepository, synthetic_snapshot
@@ -94,16 +100,50 @@ class CompanyReportIntegrationTests(unittest.TestCase):
(output_root / "2026" / "07" / "HanaTour-July-2026.xlsx").is_file()
)
def test_same_snapshot_rerun_rebuilds_full_month_with_equal_semantics(self):
def test_same_snapshot_rerun_reuses_publication_when_binary_changes(self):
node = node_binary()
if not node:
self.skipTest("Node.js is unavailable")
class StableVersionRepository(FakeRepository):
def reserve_report(self, report):
reservation = ReservedReport(9001, 1, report.company)
self.reserved.append(reservation)
return reservation
class RebuildingBuilder:
def __init__(self, delegate):
self.delegate = delegate
self.binary_sha256s = []
def build(self, report, work_dir):
built = self.delegate.build(report, work_dir)
with built.path.open("ab") as handle:
handle.write(
f"\nsynthetic-rebuild-marker-{len(self.binary_sha256s)}".encode(
"utf-8"
)
)
handle.flush()
os.fsync(handle.fileno())
os.chmod(built.path, 0o600)
rebuilt = BuiltWorkbook(
path=built.path,
sha256=sha256_file(built.path),
byte_size=built.path.stat().st_size,
summary=built.summary,
)
self.binary_sha256s.append(rebuilt.sha256)
return rebuilt
with tempfile.TemporaryDirectory(prefix="company-report-integration-") as temp_dir:
project_root = Path(temp_dir) / "project"
output_root = project_root / "outputs" / "company_reports"
project_root.mkdir(parents=True)
repository = FakeRepository(synthetic_snapshot())
builder = ArtifactToolBuilder(BUILDER_SCRIPT, node_binary=node, timeout_seconds=90)
repository = StableVersionRepository(synthetic_snapshot())
builder = RebuildingBuilder(
ArtifactToolBuilder(BUILDER_SCRIPT, node_binary=node, timeout_seconds=90)
)
publisher = AtomicReportPublisher(project_root, output_root)
service = CompanyReportService(
repository,
@@ -124,21 +164,26 @@ class CompanyReportIntegrationTests(unittest.TestCase):
second_semantic = second.companies[0].artifact["semantic_sha256"]
self.assertEqual(len(first_semantic), 64)
self.assertEqual(first_semantic, second_semantic)
self.assertNotEqual(builder.binary_sha256s[0], builder.binary_sha256s[1])
self.assertEqual(
first.companies[0].artifact["sha256"],
second.companies[0].artifact["sha256"],
)
self.assertEqual(
first.companies[0].period_row_counts,
second.companies[0].period_row_counts,
)
self.assertEqual(first.companies[0].version_no, 1)
self.assertEqual(second.companies[0].version_no, 2)
self.assertEqual(second.companies[0].version_no, 1)
month_root = output_root / "2026" / "07"
filename = "QBD-July-2026.xlsx"
current = month_root / filename
first_archive = month_root / "archive" / "v0001" / filename
second_archive = month_root / "archive" / "v0002" / filename
self.assertTrue(first_archive.is_file())
self.assertTrue(second_archive.is_file())
self.assertEqual(current.read_bytes(), second_archive.read_bytes())
self.assertFalse((month_root / "archive" / "v0002").exists())
first_archive_bytes = first_archive.read_bytes()
self.assertEqual(current.read_bytes(), first_archive_bytes)
self.assertEqual(current.stat().st_mode & 0o777, 0o600)
self.assertEqual(len(repository.activated), 2)
@@ -147,14 +192,9 @@ class CompanyReportIntegrationTests(unittest.TestCase):
encoding="utf-8"
)
)
second_result = json.loads(
(month_root / "archive" / "v0002" / "QBD-v0002.result.json").read_text(
encoding="utf-8"
)
)
self.assertEqual(
first_result["artifact"]["semantic_sha256"],
second_result["artifact"]["semantic_sha256"],
second.companies[0].artifact["semantic_sha256"],
)
serialized = json.dumps(second.to_dict(), ensure_ascii=False)
self.assertNotIn("SYN-GROUP-A", serialized)

View File

@@ -53,6 +53,22 @@ def empty_report():
)
def synthetic_built_workbook(
work_dir: Path,
filename: str,
content: bytes,
semantic_sha256: str,
) -> BuiltWorkbook:
path = work_dir / filename
path.write_bytes(content)
return BuiltWorkbook(
path=path,
sha256=sha256_file(path),
byte_size=path.stat().st_size,
summary={"semantic_sha256": semantic_sha256},
)
class CompanyReportPublishingTests(unittest.TestCase):
def test_publish_archives_prior_current_and_activates_new_version(self):
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
@@ -135,6 +151,135 @@ class CompanyReportPublishingTests(unittest.TestCase):
self.assertEqual(repository.failed[0], reservation)
self.assertEqual(repository.failed[1], ErrorCode.PUBLISH_FAILED)
def test_same_semantic_retry_reuses_first_publication_when_binary_changes(self):
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
project_root = Path(temp_dir) / "project"
output_root = project_root / "outputs" / "company_reports"
work_dir = project_root / "staging"
project_root.mkdir(parents=True)
work_dir.mkdir()
report = empty_report()
semantic_sha256 = "a" * 64
reservation = ReservedReport(46, 4, report.company)
repository = FakeRepository()
publisher = AtomicReportPublisher(project_root, output_root)
first_built = synthetic_built_workbook(
work_dir,
report.filename,
b"synthetic-first-workbook",
semantic_sha256,
)
first = publisher.publish(
report, reservation, first_built, repository, work_dir
)
archive_before = first.archive_path.read_bytes()
result_before = first.result_path.read_bytes()
current_before = first.current_path.read_bytes()
second_built = synthetic_built_workbook(
work_dir,
report.filename,
b"synthetic-rebuilt-workbook-with-different-bytes",
semantic_sha256,
)
self.assertNotEqual(first_built.sha256, second_built.sha256)
second = publisher.publish(
report, reservation, second_built, repository, work_dir
)
self.assertEqual(second.archive_path.read_bytes(), archive_before)
self.assertEqual(second.result_path.read_bytes(), result_before)
self.assertEqual(second.current_path.read_bytes(), current_before)
self.assertEqual(second.artifact.sha256, first.artifact.sha256)
self.assertEqual(second.result_json.sha256, first.result_json.sha256)
self.assertEqual(repository.activated[1].sha256, first.artifact.sha256)
self.assertIsNone(repository.failed)
def test_semantic_mismatch_does_not_replace_existing_publication(self):
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
project_root = Path(temp_dir) / "project"
output_root = project_root / "outputs" / "company_reports"
work_dir = project_root / "staging"
project_root.mkdir(parents=True)
work_dir.mkdir()
report = empty_report()
reservation = ReservedReport(47, 5, report.company)
repository = FakeRepository()
publisher = AtomicReportPublisher(project_root, output_root)
first_built = synthetic_built_workbook(
work_dir, report.filename, b"synthetic-first-workbook", "a" * 64
)
first = publisher.publish(
report, reservation, first_built, repository, work_dir
)
archive_before = first.archive_path.read_bytes()
result_before = first.result_path.read_bytes()
current_before = first.current_path.read_bytes()
second_built = synthetic_built_workbook(
work_dir, report.filename, b"synthetic-conflicting-workbook", "b" * 64
)
with self.assertRaises(PublicationError) as caught:
publisher.publish(report, reservation, second_built, repository, work_dir)
self.assertEqual(caught.exception.code, ErrorCode.PUBLISH_FAILED)
self.assertEqual(first.archive_path.read_bytes(), archive_before)
self.assertEqual(first.result_path.read_bytes(), result_before)
self.assertEqual(first.current_path.read_bytes(), current_before)
self.assertEqual(repository.failed[0], reservation)
self.assertEqual(repository.failed[1], ErrorCode.PUBLISH_FAILED)
def test_partial_existing_publication_fails_closed(self):
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
project_root = Path(temp_dir) / "project"
output_root = project_root / "outputs" / "company_reports"
work_dir = project_root / "staging"
project_root.mkdir(parents=True)
work_dir.mkdir()
report = empty_report()
reservation = ReservedReport(48, 6, report.company)
repository = FakeRepository()
publisher = AtomicReportPublisher(project_root, output_root)
built = synthetic_built_workbook(
work_dir, report.filename, b"synthetic-first-workbook", "c" * 64
)
first = publisher.publish(report, reservation, built, repository, work_dir)
first.result_path.unlink()
with self.assertRaises(PublicationError) as caught:
publisher.publish(report, reservation, built, repository, work_dir)
self.assertEqual(caught.exception.code, ErrorCode.PUBLISH_FAILED)
self.assertTrue(first.archive_path.is_file())
self.assertFalse(first.result_path.exists())
self.assertEqual(repository.failed[0], reservation)
def test_corrupt_existing_archive_fails_closed(self):
with tempfile.TemporaryDirectory(prefix="company-report-publish-test-") as temp_dir:
project_root = Path(temp_dir) / "project"
output_root = project_root / "outputs" / "company_reports"
work_dir = project_root / "staging"
project_root.mkdir(parents=True)
work_dir.mkdir()
report = empty_report()
reservation = ReservedReport(49, 7, report.company)
repository = FakeRepository()
publisher = AtomicReportPublisher(project_root, output_root)
built = synthetic_built_workbook(
work_dir, report.filename, b"synthetic-first-workbook", "d" * 64
)
first = publisher.publish(report, reservation, built, repository, work_dir)
first.archive_path.write_bytes(b"corrupted-archive")
with self.assertRaises(PublicationError) as caught:
publisher.publish(report, reservation, built, repository, work_dir)
self.assertEqual(caught.exception.code, ErrorCode.PUBLISH_FAILED)
self.assertEqual(first.current_path.read_bytes(), b"synthetic-first-workbook")
self.assertEqual(repository.failed[0], reservation)
if __name__ == "__main__":
unittest.main()