From 2107f00e32c4edbfdc08e8df6f763f79ef963370 Mon Sep 17 00:00:00 2001 From: Wyndham ARR Date: Mon, 3 Aug 2026 13:04:41 +0800 Subject: [PATCH] feat: add public h5 dashboard and publish traceability --- .../ADR-005-public-read-only-h5-dashboard.md | 29 ++++ .project-docs/10-decisions/decision-index.md | 1 + .project-docs/20-architecture/data-flow.md | 8 +- .project-docs/20-architecture/module-map.md | 6 +- .../20-architecture/system-overview.md | 4 +- .project-docs/30-worklog/current-state.md | 39 ++++- .project-docs/30-worklog/task-history.md | 5 + .project-docs/40-domain/business-rules.md | 5 +- .project-docs/50-evidence/evidence-index.md | 4 + .../2026-08-03-arr-job-trace-semantics.md | 43 ++++++ ...2026-08-03-company-report-publish-retry.md | 46 ++++++ ...8-03-company-source-report-traceability.md | 41 +++++ .../topics/2026-08-03-public-h5-dashboard.md | 30 ++++ FRONTEND_HANDOFF.md | 6 +- arr_web/app.py | 83 ++++++++-- arr_web/company_jobs.py | 104 ++++++++++++- arr_web/job_trace.py | 32 +++- arr_web/static/app.js | 96 +++++++++++- arr_web/static/h5.js | 25 +-- arr_web/static/i18n.js | 11 ++ arr_web/static/index.html | 20 ++- arr_web/static/styles.css | 17 +- company_reports/publishing.py | 111 ++++++++++++++ tests/test_arr_web.py | 46 ++++++ tests/test_arr_web_auth.py | 13 +- tests/test_arr_web_company_reports.py | 37 ++++- tests/test_arr_web_job_trace.py | 43 ++++++ tests/test_company_reports_integration.py | 68 ++++++-- tests/test_company_reports_publishing.py | 145 ++++++++++++++++++ 29 files changed, 1047 insertions(+), 71 deletions(-) create mode 100644 .project-docs/10-decisions/ADR-005-public-read-only-h5-dashboard.md create mode 100644 .project-docs/50-evidence/topics/2026-08-03-arr-job-trace-semantics.md create mode 100644 .project-docs/50-evidence/topics/2026-08-03-company-report-publish-retry.md create mode 100644 .project-docs/50-evidence/topics/2026-08-03-company-source-report-traceability.md create mode 100644 .project-docs/50-evidence/topics/2026-08-03-public-h5-dashboard.md diff --git a/.project-docs/10-decisions/ADR-005-public-read-only-h5-dashboard.md b/.project-docs/10-decisions/ADR-005-public-read-only-h5-dashboard.md new file mode 100644 index 0000000..7c9f42b --- /dev/null +++ b/.project-docs/10-decisions/ADR-005-public-read-only-h5-dashboard.md @@ -0,0 +1,29 @@ +# ADR-005: Public Read-Only H5 Dashboard + +## Status + +Accepted — 2026-08-03 + +## Context + +ARR Web login protects Finance operations, but the mobile H5 dashboard is a read-only aggregate view intended for +broader viewing. Making only the HTML page public would leave its data requests behind the login gate. Making generic +desktop APIs public would widen access to jobs, traces, operational health, filenames and other operator data. + +## Decision + +1. Publish the H5 page/assets anonymously. +2. Add purpose-built anonymous `GET /api/public/h5/months` and `GET /api/public/h5/analytics` routes. +3. Return only aggregate dashboard data and month/date coverage from those routes; omit source hashes, filenames, + traces, detailed health, source coordinates, guest/detail rows and operational metadata. +4. Keep desktop pages, generic and legacy APIs, jobs/traces, downloads, uploads, Booking/company/report operations and + mutations behind the existing server-side session/CSRF boundary. +5. Keep `/healthz` as the only public readiness signal; public JSON responses remain `no-store`. + +## Consequences + +- Anyone with the H5 URL can view the aggregate channel/room dashboard and all month options returned by the public H5 + month index. +- Finance/operator workflows remain authenticated and the public H5 surface has no write capability. +- Deployment must treat the aggregate metrics, channel names and financial totals as intentionally public data and should + apply normal HTTPS, access logging and rate/abuse controls at the edge. diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index 07cd240..b187928 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -4,6 +4,7 @@ | ID | Decision | Status | Date | Applies To | Detail | |---|---|---|---|---|---| +| ADR-005 | 手机 H5 看板通过专用只读聚合接口公开访问;桌面及运营接口继续认证 | Accepted | 2026-08-03 | Web access boundary, H5, Channel BI | [ADR-005](ADR-005-public-read-only-h5-dashboard.md) | | ADR-004 | ARR2.0 owns deterministic processing, artifact validation and terminal ingestion; no Agent/MCP in production XML flow | Accepted | 2026-07-30 | Upload, processing runtime, OSS ACL, validation, deployment | [ADR-004](ADR-004-arr-owned-programmatic-processing.md) | | ADR-001 | 月报由独立 worker 自动触发;“更新至”取最新纳入的 `ARRIVAL`;发布后页面自动新增;`TOTAL PRICE` 为公式字段 | Implemented | 2026-07-29 | 月报触发、持久化、页面同步、XLSX 输出 | [ADR-001](ADR-001-automatic-monthly-trigger-and-total-price-formula.md) | diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index cceec92..810c86f 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -2,9 +2,13 @@ ## Web Access Boundary -1. An anonymous browser requesting `/` or `/h5` is redirected to `/login` with an allowlisted return target; anonymous API/download requests receive JSON `401 AUTH_REQUIRED`. +1. An anonymous browser requesting `/` is redirected to `/login` with an allowlisted return target. `/h5` and its H5 + assets are public; anonymous access is limited to `/api/public/h5/months`, `/api/public/h5/analytics` and the + no-detail `/healthz` signal. Other anonymous API/download requests receive JSON `401 AUTH_REQUIRED`. 2. `POST /api/login` accepts strict same-origin JSON, verifies runtime-owned credentials under a bounded per-client attempt ledger and issues a random server-side session plus CSRF token in an `HttpOnly`, `SameSite=Strict` cookie. -3. Every portal page, detailed health response, read API, upload/mutation, task trace and artifact download validates the authenticated session. Mutations additionally validate the session CSRF token. +3. Every desktop portal page, detailed health response, generic read API, upload/mutation, task trace and artifact + download validates the authenticated session. Public H5 endpoints return only sanitized aggregate dashboard data; + mutations additionally validate the session CSRF token. 4. `POST /api/logout` validates CSRF, revokes the server-side session and expires the cookie. `/healthz` remains a no-detail anonymous readiness signal for container orchestration. 5. `GET /api/history-months` is an authenticated read-only discovery path. It combines daily processing/monthly publication counts from PostgreSQL with durable company-job counts; each desktop history then reads its selected month through the existing paged list endpoint. diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index d95cdf2..67191fc 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -15,10 +15,10 @@ | `arr_storage/aliyun_oss_v2.py` | Encrypted/unversioned OSS adapter | Writes all objects private | | `arr_web/downloads.py` | OSS daily + controlled local report download routing | Rechecks metadata, size and SHA-256 | | `arr_web/job_trace.py` | Programmatic persisted-fact trace | No external trace store | -| `arr_web/app.py`, `arr_web/repository.py`, `arr_web/company_jobs.py` | Authenticated portal routes, paged history reads and read-only history-month discovery | Default-deny login gate protects pages/APIs/downloads; daily/monthly counts and rows share a repeatable-read snapshot; `/api/history-months` merges daily/monthly database counts with company job-state counts; company totals/slices share one lock | +| `arr_web/app.py`, `arr_web/repository.py`, `arr_web/company_jobs.py` | Authenticated portal routes plus public H5 aggregate routes, paged history reads and read-only history-month discovery | Default-deny login gate protects the desktop/API/download surface; purpose-built `/api/public/h5/*` exposes only sanitized aggregate metrics; daily/monthly counts and rows share a repeatable-read snapshot; `/api/history-months` merges daily/monthly database counts with company job-state counts; company totals/slices share one lock | | `arr_web/server.py` | Standard-library HTTP transport | Dispatches GET/POST/PATCH/DELETE with one bounded body reader; real socket tests cover review update/delete and missing/oversized lengths | | `arr_web/static/login.html`, `login.css`, `login.js` | Responsive ARR login gateway | Labeled form, password visibility, generic inline failures, safe desktop/H5 return target and reduced-motion support | -| `arr_web/static/app.js`, `arr_web/static/h5.js` | Authenticated client state, rendering and polling | Desktop daily/monthly/company histories own independent viewing-month state and default to the latest non-empty month; company generation month remains separate. Desktop also includes 50-row Booking draft review/edit, the draft's validated uploaded filename below the review title, individual/all-visible selection, count-aware in-page delete confirmation and activation; company generation keeps the fixed five-company context beside the page title, a four-card upload/period setup row, cumulative CO display labels, short centered period actions and an in-page generation confirmation dialog; session-expiry redirect and CSRF logout remain shared; monthly versions auto-refresh the selected viewing month every four seconds | +| `arr_web/static/app.js`, `arr_web/static/h5.js` | Authenticated desktop and anonymous-capable H5 client state, rendering and polling | Desktop daily/monthly/company histories own independent viewing-month state and default to the latest non-empty month; company generation month remains separate. H5 reads only public aggregate endpoints and may retain optional logout for an authenticated session. Desktop also includes 50-row Booking draft review/edit, the draft's validated uploaded filename below the review title, individual/all-visible selection, count-aware in-page delete confirmation and activation; company generation keeps the fixed five-company context beside the page title, a four-card upload/period setup row, cumulative CO display labels, short centered period actions and an in-page generation confirmation dialog; session-expiry redirect and CSRF logout remain shared; monthly versions auto-refresh the selected viewing month every four seconds | | `monthly_reports/worker.py` | Dedicated outbox consumer | Lease/reclaim, retry/dead-letter, success acknowledgement after activation | | `monthly_reports/repository.py` | Monthly snapshot and publication repository | Derives scope from `ARRIVAL`; persists metadata/lineage/artifact identities | | `monthly_reports/xlsx/build_workbook.mjs` | Monthly XLSX builder and reopen validator | Exact row-relative `TOTAL PRICE` formulas only in column S | @@ -26,7 +26,7 @@ | `database/012_monthly_report_publication.sql` | Additive metadata-only publication schema | Applied after immutable 008–011 baseline | | `database/014_booking_current_source_batch.sql` | Booking full-workbook current-source pointer and view scoping | Formally applied on 2026-07-31; batch 1 remains selected | | `database/015_booking_excel_review_drafts.sql` | Item-level Booking extraction draft state | Formally applied and empty; basic latest-state review only, with no actor/reason/revision history and no DB-enforced zero-pending activation | -| `compose.yaml`, `deploy/` | Web-login + Caddy-HTTPS template and worker deployment boundary | Requires Web credentials; only `/healthz` is anonymously probed; no MCP port/domain/service | +| `compose.yaml`, `deploy/` | Web-login + Caddy-HTTPS template and worker deployment boundary | Requires Web credentials for the desktop/operational surface; public H5 aggregate routes and `/healthz` remain anonymously reachable; no MCP port/domain/service | | `tests/test_arr_programmatic.py` | Real success/failure vertical slices | Primary ARR2.0 acceptance proof | Historical ARR1 compatibility modules and migrations remain for audit/tests but are not imported, installed or deployed diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 608cb28..633d182 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -31,7 +31,9 @@ from committed `ARRIVAL` facts, publishes a validated workbook, and records meta ## Important Boundaries - The uploaded source is re-materialized from committed object storage before processing. -- Login documents/assets, `POST /api/login` and minimal `/healthz` readiness are the only anonymous Web routes. Portal documents redirect to login; business APIs, detailed health, uploads, traces and downloads return `401` without an authenticated server-side session. +- Login documents/assets, the H5 read-only page/assets, purpose-built public H5 aggregate endpoints, `POST /api/login` + and minimal `/healthz` readiness are anonymous Web routes. The desktop portal redirects to login; generic business + APIs, detailed health, uploads, traces and downloads return `401` without an authenticated server-side session. - Operator credentials come only from runtime `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD`; the application fails closed when either is absent. Authenticated mutations also require the per-session CSRF token. - Processor exit code and JSON status must agree; output paths are confined to an isolated temporary directory. - Validation completes before the database transaction; any transaction failure leaves no partial active version. diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 8dc5300..9f9f715 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -10,7 +10,38 @@ download identity. The company-channel detail generator now separates generation permission from calendar completeness: a current-month or historical C/O period can be generated from the current committed Finance snapshot, while future report months stay blocked. A not-yet-ended period keeps its fixed C/O cutoff and must be rerun after later source facts arrive if the final -workbook needs those facts. +workbook needs those facts. Duplicate publication of the same semantic snapshot is idempotent: the first validated +archive/result pair remains authoritative even if a retry rebuilds different XLSX bytes. + +## Completed On 2026-08-03 + +- Made the mobile H5 dashboard publicly readable without weakening the desktop/operator boundary. Anonymous users can + load `/h5`, its H5 assets and purpose-built `/api/public/h5/months` plus `/api/public/h5/analytics` aggregate routes; + the public projection omits source hashes and operational metadata. Generic analytics, legacy H5 APIs, desktop pages, + detailed health, jobs/traces, downloads and all write paths remain authenticated. H5 keeps optional authenticated + logout, uses no-detail `/healthz` for its connection indicator, and 71 `test_arr_web*` tests plus syntax checks pass. + No live service restart or public deployment was performed. +- Clarified ARR trace semantics for internet-facing deployment. `artifact_callback` traces now expose additive + `execution_scope=arr_runtime`, `processor_mode=fixed_processor` and `remote_dispatch=none`; legacy `direct_mcp` + traces identify remote Agent/MCP delivery. User-visible messages no longer call ARR output “本地处理”, and the + queued downstream message explicitly says Finance database commit is complete while the outbox waits for its + consumer. Persisted `local-...` delivery keys and existing trace fields remain unchanged. Trace/Web tests (27) plus + Python/JavaScript syntax and diff checks pass; no live task or business data was mutated. +- Fixed company-report duplicate publication. A retry now validates the existing archive/result pair against the + deterministic reservation and builder `semantic_sha256`, verifies the archive hash against the result JSON, and + reuses the first successful artifact metadata instead of comparing the new XLSX binary SHA. Partial, corrupt or + semantically conflicting pairs fail closed; an existing `current` file is not overwritten, while a missing one can be + repaired from the authoritative archive. First-publication rollback behavior and monthly publication code are + unchanged. Publisher tests pass 6/6, company-report tests pass 32 with five optional ArtifactTool skips, Web + company/trace tests pass 13/13 and Python compilation is clean. No live report rerun or business-data mutation was + performed; the historical 2026-07 `11-20` retry remains to be rerun as a separate acceptance action. +- Added Booking Excel to company-report traceability. New report jobs persist a safe source summary captured server-side + at submission (batch id, filename, activation time and counts); the company page now shows the active source, job + detail shows the submission-time source, and history adds a source column. Re-uploading the same active workbook is + treated as an informational refresh that keeps the existing source and report history; it does not re-extract or + auto-generate a report. Historical jobs without source metadata remain readable and show that the source was not + recorded. Full discovery passes 360 tests with 10 optional skips; no live upload, activation, report generation or + service restart was performed because the available browser session was unauthenticated. ## Completed On 2026-08-02 @@ -318,6 +349,10 @@ workbook needs those facts. - Booking review persistence passed a real-PostgreSQL transaction-only vertical slice: draft creation exposed two items with one pending, edit confirmed the pending item, activation produced one source row with quantity three, and the outer rollback restored batch 1 with no synthetic draft, source or artifact residue. +- A 2026-08-03 diagnosis reproduced the 2026-07 `11-20` company-report retry failure: all five companies failed at + `publish` after an earlier same-period success because repeated XLSX builds had identical semantic SHA-256 values but + different binary SHA-256 values. Publisher idempotency now reuses a complete matching archive/result pair and fails + closed on partial or conflicting state; the historical retry still needs a deliberate live rerun. - Isolated authenticated browser checks covered the upload-first desktop flow, editable review table, fixed-company text and responsive 375/390-pixel layouts with no horizontal overflow or console errors. @@ -351,4 +386,4 @@ workbook needs those facts. ## Last Updated -2026-07-31 +2026-08-03 diff --git a/.project-docs/30-worklog/task-history.md b/.project-docs/30-worklog/task-history.md index 2e34963..762fb8c 100644 --- a/.project-docs/30-worklog/task-history.md +++ b/.project-docs/30-worklog/task-history.md @@ -4,6 +4,11 @@ | Date | Task | Outcome | Docs Updated | |---|---|---|---| +| 2026-08-03 | Make the mobile H5 dashboard publicly readable | Added public H5 shell/assets and purpose-built sanitized aggregate month/analytics endpoints; desktop, generic APIs, legacy H5 APIs, detailed health, jobs/traces, downloads and writes remain protected. Anonymous H5 loading plus authenticated compatibility is covered by 71 Web tests and syntax checks; no live restart or deployment occurred | Current state/history, architecture, business rules, ADR-005, H5 public-access evidence/index, scoped planning record | +| 2026-08-03 | Clarify ARR trace semantics for internet deployment | Added additive execution-scope, processor-mode and remote-dispatch fields; replaced misleading “本地处理” copy and clarified that a pending downstream event follows a completed Finance commit. Existing delivery IDs/trace fields remain compatible; 27 trace/Web tests plus Python/JavaScript syntax and diff checks pass, with no live mutation | Current state/history, ARR trace-semantics evidence/index, scoped planning record | +| 2026-08-03 | Add Booking Excel to company-report traceability | New company-report jobs store the server-captured active source summary; the page shows the current source, job detail/history show the source Excel, and identical active uploads become an informational refresh instead of a generic extraction error. Old jobs remain compatible without source metadata. Focused Web/company tests and the 360-test suite pass with 10 optional skips; no live mutation or restart was performed | Current state, source-to-report traceability evidence/index, scoped planning record | +| 2026-08-03 | Fix duplicate company-report publication | Made same-reservation/same-semantic retries reuse the first validated archive/result identity even when a rebuilt XLSX has different binary bytes. Partial, corrupt or semantic-conflict states fail closed; an existing current file is preserved and a missing one can be repaired from the archive. Publisher tests pass 6/6, company-report tests pass 32 with five optional ArtifactTool skips, Web company/trace tests pass 13/13 and compilation is clean. No live rerun or business-data mutation was performed | Current state, company-report retry evidence/index, scoped plan | +| 2026-08-03 | Diagnose 2026-07 company-report `正式表格发布失败` | Found the historical `11-20` retry failed for all five companies at publication after an earlier same-period success. A read-only double-build probe showed identical semantic workbook identities but different binary XLSX SHA-256 values; the immutable archive/result identity rejects that retry. No code, report, database or output state was changed | Current state, company-report retry evidence/index | | 2026-08-02 | Restore prior-month history across Daily Report, monthly processing and company-channel detail | Added a read-only merged history-month count endpoint and three independent month navigators with latest-non-empty defaults, month-aware empty/pagination states and Chinese/English/Thai responsive copy. Company generation/history months are separate and monthly polling follows only the viewing month. A live read confirms July 30/4/4 records, 356 tests pass, isolated 1440/900/390 QA is overflow-free, and the restarted 8766 runtime is ready without business writes | Current state/history, history-visibility evidence/index, stale item, scoped planning record | | 2026-08-02 | Diagnose missing prior-month history and design its display | Proved the data was retained: July has 30 daily jobs, 4 monthly publications and 4 company jobs, while the August-default frontend view is empty. Daily/monthly loaders hard-code the Bangkok current month; company history hides its month filtering inside generation setup. Proposed visible per-history month navigation, recent-record empty states and separation of company generation/history months; no product code, runtime or business data changed | History-visibility evidence/index, stale item, scoped planning record | | 2026-07-31 | Tighten Channel BI top spacing | Raised the desktop BI panel by 20px toward the four-tab bar and applied a lighter 10px mobile adjustment. Other panels, tab behavior and BI data are unchanged; JavaScript syntax and 39 focused Web tests pass | Current state/history, Channel BI utility evidence | diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index a851516..924e535 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -2,7 +2,10 @@ ## Durable Rules -- Human access to desktop/H5 pages, detailed health, business APIs, uploads, traces and downloads requires an authenticated ARR Web session. Only login assets, `POST /api/login` and no-detail `/healthz` readiness are anonymous. +- Human access to the desktop Finance workspace, detailed health, generic business APIs, uploads, traces and downloads + requires an authenticated ARR Web session. The H5 mobile dashboard is an intentional anonymous read-only exception: + only its page/assets, `/api/public/h5/months`, `/api/public/h5/analytics` and no-detail `/healthz` are public; the H5 + projection contains aggregate channel/room metrics and omits source hashes and operational metadata. - The single-operator login identity is injected as `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD`; real credentials are never stored in source or returned/logged. Missing credentials fail Web startup closed. - Login failures use a generic message and bounded attempt blocking. Logout revokes the server-side session; authenticated mutations continue to require CSRF. - The user uploads XML; the user does not submit a report year, report month, cutoff date, or separate monthly-generation request in the primary workflow. diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 69a9c60..acc83c5 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -4,6 +4,10 @@ Use this index for searchable, traceable evidence records. | Date | Topic | Status | Source | Detail | |---|---|---|---|---| +| 2026-08-03 | Public mobile H5 dashboard boundary | Implemented; deployment/restart pending | [Evidence topic](topics/2026-08-03-public-h5-dashboard.md) | Anonymous H5 page/assets and purpose-built `/api/public/h5/*` aggregate routes are allowed. The projection omits `source_monthly_sha256`; desktop, generic/legacy APIs, detailed health, jobs/traces, downloads and writes remain protected. `test_arr_web*` discovery passes 71 tests; no live service or business data was changed. | +| 2026-08-03 | ARR job trace semantics for internet deployment | Implemented; live deployment acceptance pending | [Evidence topic](topics/2026-08-03-arr-job-trace-semantics.md) | Trace projection adds `execution_scope`, `processor_mode` and `remote_dispatch`; `artifact_callback` is identified as ARR fixed-processor runtime execution and legacy `direct_mcp` as remote Agent/MCP delivery. “本地处理” copy is removed, pending downstream explicitly follows Finance commit, and persisted `local-...` delivery identities are retained. Focused trace/Web tests pass; no live task or business write occurred. | +| 2026-08-03 | Booking Excel to company-report traceability | Implemented; authenticated browser acceptance pending | [Evidence topic](topics/2026-08-03-company-source-report-traceability.md) | New report jobs persist a safe source summary captured at submission; current source, job detail and history expose the filename/batch context. Identical active uploads refresh context without re-extraction or automatic generation. Full discovery passes 360 tests with 10 optional skips; no live mutation or restart occurred. | +| 2026-08-03 | 2026-07 company-report retry publication failure | Implemented; live historical rerun pending | [Evidence topic](topics/2026-08-03-company-report-publish-retry.md) | July `11-20` job `60bcda9c…` failed for all five companies at `publish` after an earlier same-period success. Publisher idempotency now validates and reuses a complete matching archive/result pair despite different rebuilt XLSX bytes, while partial or conflicting state fails closed. | | 2026-08-02 | Cross-month history visibility | Implemented and runtime-active | [Evidence topic](topics/2026-08-02-history-month-visibility.md) | Three independent history-month navigators now default to the latest non-empty month through a read-only count endpoint; company generation/history months are separated. July resolves to 30 daily/4 monthly/4 company jobs, 356 tests pass, 1440/900/390 browser QA is overflow-free, port 8766 is ready and no business write occurred. | | 2026-07-31 | Channel BI header utilities and compact month selector | Implemented; focused static checks pass | [Evidence topic](topics/2026-07-31-channel-bi-header-utilities.md) | The visible BI panel heading and ready-state 数据库已连接 copy are gone from desktop/H5 while the navigation label, browser metadata and status dot remain. The month selector is compact; task-log/logout use low-emphasis accessible links, and the desktop BI panel now sits 20px closer to the tab bar with a 10px mobile adjustment. No API or business data changed. | | 2026-07-31 | Channel BI copy and data coverage | Implemented; focused checks pass | [Evidence topic](topics/2026-07-31-channel-bi-copy-and-data-coverage.md) | Desktop and H5 remove the BI-only CHANNEL PERFORMANCE, snapshot watermark and visible BI TOTAL PRICE labels. The company-sales panels show localized 数据范围 from additive min_arrival_date plus existing max_arrival_date values returned by the same repeatable-read current-version snapshot. | diff --git a/.project-docs/50-evidence/topics/2026-08-03-arr-job-trace-semantics.md b/.project-docs/50-evidence/topics/2026-08-03-arr-job-trace-semantics.md new file mode 100644 index 0000000..a8a3791 --- /dev/null +++ b/.project-docs/50-evidence/topics/2026-08-03-arr-job-trace-semantics.md @@ -0,0 +1,43 @@ +# ARR Job Trace Semantics for Internet Deployment + +## Question + +Whether the ARR task trace is understandable when ARR is deployed on a server reachable through the internet, rather +than being interpreted as if processing occurred on the operator's computer. + +## Findings + +- The current `artifact_callback` path is owned by ARR's fixed processor. It does not create a remote Agent/MCP run, + so `remote_run_id=null` is expected and is not a failure. +- Persisted delivery keys such as `local-arrjob-...-a1` are delivery/idempotency identities. They are retained for + compatibility and are not a claim about the user's machine or the server's geographic location. +- A successful job with `current_stage=downstream` and `DOWNSTREAM_EVENT_QUEUED` has already committed the Finance + version. `publish_status=pending` means the durable outbox event is waiting for its downstream consumer. + +## Change + +The trace job summary now adds: + +| Field | `artifact_callback` | legacy `direct_mcp` | +|---|---|---| +| `execution_scope` | `arr_runtime` | `remote_agent` | +| `processor_mode` | `fixed_processor` | `legacy_direct_mcp` | +| `remote_dispatch` | `none` | `mcp` | + +User-visible messages now say `ARR 固定处理器输出` and explicitly state `Finance 数据库提交已完成` before the +outbox wait. Existing `delivery_mode`, `remote_run_id`, trace version, persisted delivery IDs and privacy allowlist +remain compatible. + +## Verification + +- `python3 -m unittest tests.test_arr_web_job_trace tests.test_arr_web_task_log_ui tests.test_arr_web` — 27 passed. +- `python3 -m py_compile arr_web/job_trace.py` — passed. +- `node --check arr_web/static/app.js` — passed. +- `git diff --check` — passed. +- No live task was rerun, no service was restarted and no Finance/report/Booking business data was changed. + +## Follow-up + +After deployment, inspect one successful `artifact_callback` trace and confirm the additive header fields appear in the +deployed CLI/API/UI. This is a display/observability acceptance step; it is separate from rerunning the historical +2026-07 company-report `11-20` publication. diff --git a/.project-docs/50-evidence/topics/2026-08-03-company-report-publish-retry.md b/.project-docs/50-evidence/topics/2026-08-03-company-report-publish-retry.md new file mode 100644 index 0000000..f84f79a --- /dev/null +++ b/.project-docs/50-evidence/topics/2026-08-03-company-report-publish-retry.md @@ -0,0 +1,46 @@ +# Evidence Topic: 2026-07 company-report retry publication failure + +## Metadata + +- Date: 2026-08-03 +- Status: Implemented; live historical rerun pending +- Scope: 2026-07 company-channel report, C/O `11-20`, local Web job `60bcda9c8dae49758fb69dc47e0ff9e7` +- Confidence: Fact for recorded job/build observations; inference for the exact publisher guard +- Source: job JSON, prior successful job JSON, read-only controlled PostgreSQL snapshot, source-code inspection and temporary-directory double-build probe +- Last verified: 2026-08-03 +- Stale trigger: changes to the company XLSX builder, artifact identity, reservation-version derivation or publisher idempotency + +## Question + +Why did the Web show `正式表格发布失败` for 2026-07? + +## Evidence + +- The failed job is for report month `2026-07`, period `11-20`, and as-of date `2026-07-20`. All five companies failed at stage `publish` with `COMPANY_REPORT_PUBLISH_FAILED`; there are no record IDs and no source/Booking/business validation errors. +- The immediately preceding job for the same month and period succeeded for all five companies and created archive versions. The failure is therefore a retry of an already published semantic snapshot, not a first-build data failure. +- `company_reports.repository.PostgresReportRepository.reserve_report()` derives the version directory from a deterministic semantic identity containing the company, period, as-of date, processor/rule/schema versions, daily-version pins and Booking-version pins. The retry therefore targets the same archive version path. +- A read-only double-build probe loaded the current controlled July snapshot and built each of the five valid `11-20` workbooks twice in separate temporary directories. For every company, the two binary SHA-256 values differed while the builder's `semantic_sha256` was identical. +- `AtomicReportPublisher._install_once()` rejects an existing archive path when its binary SHA-256 differs from the newly built file, raising `COMPANY_REPORT_PUBLISH_FAILED`. The result JSON also embeds the binary SHA-256, so allowing only the archive copy would still leave the same retry-identity conflict at result validation. +- No database write, report rerun or output repair was performed during this diagnosis. + +## Implemented Fix + +- `AtomicReportPublisher` now checks for an existing archive/result pair before creating legacy/current side effects. A + retry is reusable only when both files are regular files, the result JSON matches the deterministic report identity and + current semantic SHA, and the archive's actual SHA-256 matches the result JSON's stored artifact SHA. +- A matching retry returns the existing archive/result metadata and activates the repository without copying the new + XLSX bytes. An existing current file is left untouched; a missing current file is repaired from the existing archive. +- Partial, corrupt, hash-inconsistent or semantically conflicting publication state fails closed with + `PUBLISH_FAILED`. First publication and activation-failure rollback paths remain covered. +- Regression coverage passed for same-semantic/different-binary retry, semantic mismatch, partial publication and + corrupted archive. The real ArtifactTool integration regression is present but was skipped locally because the + optional `@oai/artifact-tool` dependency is unavailable. +- No live report rerun, output repair or business-data mutation was performed after the code change. + +## Conclusion + +The 2026-07 `11-20` failure was caused by a non-byte-deterministic XLSX build colliding with the publisher's deterministic archive/version identity on retry. The workbook contents were semantically the same, but changing XLSX bytes made the immutable archive and result identity appear conflicting. The existing successful 11-20 artifacts remain valid; the failed retry did not replace them. The publisher now handles this case idempotently by reusing that stable identity. + +## Fix direction + +The publisher-side idempotency fix is implemented and is the active remedy: reuse/validate the existing artifact by semantic identity and preserve one stable binary artifact identity. Making the XLSX builder byte-deterministic remains an optional future optimization, not a prerequisite for retry success. The historical job still needs a deliberate live rerun to verify the deployed path. diff --git a/.project-docs/50-evidence/topics/2026-08-03-company-source-report-traceability.md b/.project-docs/50-evidence/topics/2026-08-03-company-source-report-traceability.md new file mode 100644 index 0000000..6c7b5f1 --- /dev/null +++ b/.project-docs/50-evidence/topics/2026-08-03-company-source-report-traceability.md @@ -0,0 +1,41 @@ +# Booking Excel to company-report traceability + +## Scope + +The Booking Excel upload page could tell the operator that an identical workbook was already active, but the company-report +history and job detail did not show which source was associated with a generated result. This change adds source identity +without exposing workbook rows, guest data or full artifact hashes. + +## Diagnosis + +- `booking_ingestion/excel_review_postgres.py` deduplicates an identical activated artifact and raises + `BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED` with `这份 Excel 已经启用,无需重复提取`. +- `/api/company-reports/source` already returned a privacy-safe source summary, but the frontend used it only as a + readiness boolean. +- `POST /api/company-reports/jobs` accepted only month and period, and persisted no source metadata in the durable job + state. The frontend API helper also discarded error codes, preventing an informational duplicate-upload branch. + +## Implemented behavior + +- The server captures the current active source when a report job is submitted; the browser cannot choose or spoof the + source identity. +- New job records expose only source batch id/type, safe filename, activation time, disposition and summary counts. +- The company page shows the current active workbook and summary counts. +- Job detail and history show the source Excel captured at submission; old job files without this field render + `历史任务未记录来源`/an equivalent localized label. +- Re-uploading the same active workbook clears the selected file, refreshes the current-source and report-history views, + and shows an informational message. It does not create a second extraction or automatically generate a report. +- Report generation remains an explicit month/period action. Upload controls stay blocked while a report job is active, so + the submission-time source remains the trace shown for that job. + +## Verification + +- `./.venv/bin/python -m unittest tests.test_arr_web_company_reports tests.test_arr_web`: 28 tests passed. +- `./.venv/bin/python -m unittest discover -s tests -p 'test_arr_web*.py'`: 69 tests passed. +- `./.venv/bin/python -m unittest discover -s tests`: 360 tests passed, 10 optional skips. +- `node --check arr_web/static/app.js` and Python compilation passed. +- `git diff --check` passed. + +The available in-app browser session landed on the login page without an authenticated session. No login, upload, +activation, report generation, service restart or business-data mutation was attempted; authenticated browser acceptance +remains the deployment follow-up. diff --git a/.project-docs/50-evidence/topics/2026-08-03-public-h5-dashboard.md b/.project-docs/50-evidence/topics/2026-08-03-public-h5-dashboard.md new file mode 100644 index 0000000..d5be48a --- /dev/null +++ b/.project-docs/50-evidence/topics/2026-08-03-public-h5-dashboard.md @@ -0,0 +1,30 @@ +# Public Mobile H5 Dashboard + +## Request + +Make the mobile dashboard publicly viewable while preserving the authenticated Finance/operator surface. + +## Public contract + +- Anonymous `GET /h5`, `/h5.html`, `/assets/h5.css` and `/assets/h5.js` are allowed. `i18n.js` was already public as a + login asset. +- Anonymous `GET /api/public/h5/months` returns only month key, update time and maximum ARRIVAL watermark. +- Anonymous `GET /api/public/h5/analytics?month=YYYY-MM` returns aggregate channel/room metrics and date coverage. + The public projection omits `source_monthly_sha256`, filenames and operational metadata; JSON responses are + `Cache-Control: no-store`. +- H5 uses anonymous `/healthz` for its connection indicator and does not require a CSRF token for read-only data. When a + logged-in operator opens H5, the existing session is still detected and the optional logout control remains available. + +## Protected contract retained + +Anonymous desktop `/`, `/api/health`, generic `/api/months`, `/api/analytics`, legacy `/api/h5/months`, legacy +`/api/monthly/{month}/analytics`, jobs, traces, downloads, uploads, Booking/company/report routes and mutations remain +protected by the existing session/CSRF boundary. + +## Verification + +- `python3 -m unittest tests.test_arr_web_auth tests.test_arr_web` — 26 passed. +- `python3 -m unittest discover -s tests -p 'test_arr_web*.py'` — 71 passed. +- `node --check arr_web/static/h5.js`, `node --check arr_web/static/app.js` and `python3 -m py_compile arr_web/app.py` — passed. +- `git diff --check` — passed. +- No live service restart, public deployment, database write or business/report mutation was performed. diff --git a/FRONTEND_HANDOFF.md b/FRONTEND_HANDOFF.md index ae27a49..cc8ee89 100644 --- a/FRONTEND_HANDOFF.md +++ b/FRONTEND_HANDOFF.md @@ -77,12 +77,16 @@ - `GET /api/analytics?month=YYYY-MM` - `GET /api/h5/months`(旧 H5 月份索引兼容,raw response) - `GET /api/monthly/{month_key}/analytics`(旧 H5 analytics 1.2 兼容,raw response) +- `GET /api/public/h5/months`(匿名 H5 月份索引,只读聚合字段) +- `GET /api/public/h5/analytics?month=YYYY-MM`(匿名 H5 看板,只读聚合字段;不含 source hash/运营元数据) - `GET /api/channel-detail?month=YYYY-MM&worksheet=...` - `GET /api/company-reports/jobs` - `POST /api/company-reports/jobs` - `POST /api/integrations/super-agent/results` -所有公开响应必须 `no-store`;写请求使用同源 session/CSRF,Agent 回写使用机器验签而不是浏览器 CSRF。 +匿名公开范围仅限 H5 页面/资源、上述两个 `/api/public/h5/*` 聚合接口和 `/healthz`;桌面、通用/旧版 H5 +接口、任务、详情健康、下载和写请求仍需 session/CSRF。所有公开响应必须 `no-store`;Agent 回写使用机器验签 +而不是浏览器 CSRF。 ## 7. 本机启动 diff --git a/arr_web/app.py b/arr_web/app.py index 8a614a0..fa03755 100644 --- a/arr_web/app.py +++ b/arr_web/app.py @@ -76,6 +76,21 @@ LOGIN_STATIC_ROUTES = { } LOGIN_DOCUMENT_ROUTES = {"/login", "/login.html"} H5_DOCUMENT_ROUTES = {"/h5", "/h5.html"} +PUBLIC_H5_STATIC_ROUTES = H5_DOCUMENT_ROUTES | { + "/assets/h5.css", + "/assets/h5.js", +} + + +_PUBLIC_H5_DASHBOARD_KEYS = ( + "version", + "month_key", + "updated_at", + "min_arrival_date", + "max_arrival_date", + "overall", + "channels", +) def _paged_success( @@ -97,6 +112,14 @@ def _paged_success( ) +def _public_h5_dashboard(payload: Mapping[str, Any]) -> Dict[str, Any]: + return { + key: payload[key] + for key in _PUBLIC_H5_DASHBOARD_KEYS + if key in payload + } + + def _strict_json(raw: bytes) -> Mapping[str, Any]: def pairs(values: list[tuple[str, Any]]) -> Dict[str, Any]: output: Dict[str, Any] = {} @@ -261,6 +284,31 @@ class PortalApplication: return self._static(route.path) if method == "POST" and route.path == "/api/login": return self._login(body, normalized_headers, client_id) + if method == "GET" and route.path in PUBLIC_H5_STATIC_ROUTES: + return self._static(route.path) + if method == "GET" and route.path == "/api/public/h5/months": + return Response.json( + 200, + success( + [ + { + "month_key": item.get("month_key"), + "updated_at": item.get("updated_at"), + "max_arrival_date": item.get("max_arrival_date"), + } + for item in self._repository.list_months() + ] + ), + ) + if method == "GET" and route.path == "/api/public/h5/analytics": + query = parse_qs(route.query, keep_blank_values=True) + month = validate_month( + self._one(query, "month", self._current_month()) + ) + return Response.json( + 200, + success(_public_h5_dashboard(self._dashboard_payload(month))), + ) if current_session is None: return self._authentication_required(method, route.path) if method == "GET" and route.path in STATIC_ROUTES: @@ -410,17 +458,7 @@ class PortalApplication: error.safe_message, 422, ) from None - try: - dashboard = self._repository.read_dashboard(month) - except PortalDataError as error: - if error.code == "ANALYTICS_MONTH_NOT_FOUND": - raise PortalError( - "MONTHLY_NOT_FOUND", - "找不到该月份的正式月报。", - 404, - ) from None - raise - return Response.json(200, dashboard) + return Response.json(200, self._dashboard_payload(month)) if method == "GET" and route.path == "/api/channel-detail": query = parse_qs(route.query, keep_blank_values=True) month = validate_month(self._one(query, "month", self._current_month())) @@ -648,7 +686,8 @@ class PortalApplication: "请先完成或放弃当前 Excel 提取草稿", 409, ) - if self._booking_sources.current() is None: + current_source = self._booking_sources.current() + if current_source is None: raise PortalError( "BOOKING_EXCEL_SOURCE_REQUIRED", "请先上传并校验 Excel 报表", @@ -671,7 +710,13 @@ class PortalApplication: ) return Response.json( 202, - success(self._company_reports.create(report_month, period)), + success( + self._company_reports.create( + report_month, + period, + source=current_source, + ) + ), ) raise PortalError("ROUTE_NOT_FOUND", "页面或接口不存在", 404) except PortalDataError as error: @@ -701,6 +746,18 @@ class PortalApplication: {"Cache-Control": "no-store"}, ) + def _dashboard_payload(self, month: str) -> Dict[str, Any]: + try: + return self._repository.read_dashboard(month) + except PortalDataError as error: + if error.code == "ANALYTICS_MONTH_NOT_FOUND": + raise PortalError( + "MONTHLY_NOT_FOUND", + "找不到该月份的正式月报。", + 404, + ) from None + raise + def _login( self, body: bytes, diff --git a/arr_web/company_jobs.py b/arr_web/company_jobs.py index 5dd4760..c25aeb4 100644 --- a/arr_web/company_jobs.py +++ b/arr_web/company_jobs.py @@ -122,7 +122,13 @@ class ProgramCompanyReportExecutor: class CompanyReportCoordinator(Protocol): - def create(self, report_month: str, period: str) -> Dict[str, Any]: + def create( + self, + report_month: str, + period: str, + *, + source: Optional[Mapping[str, Any]] = None, + ) -> Dict[str, Any]: ... def list_jobs( @@ -152,7 +158,14 @@ class UnavailableCompanyReportCoordinator: 503, ) - def create(self, report_month: str, period: str) -> Dict[str, Any]: + def create( + self, + report_month: str, + period: str, + *, + source: Optional[Mapping[str, Any]] = None, + ) -> Dict[str, Any]: + _ = source raise self._unavailable() def list_jobs( @@ -220,8 +233,87 @@ class PersistentCompanyReportCoordinator: self._condition.notify_all() self._thread.join(timeout=10) - def create(self, report_month: str, period: str) -> Dict[str, Any]: + @staticmethod + def _source_metadata( + source: Optional[Mapping[str, Any]], + ) -> Optional[Dict[str, Any]]: + if source is None: + return None + if not isinstance(source, Mapping): + raise PortalError( + "COMPANY_REPORT_SOURCE_INVALID", + "当前 Excel 数据源信息无效", + 409, + ) + + source_batch_id = source.get("source_batch_id") + source_type = source.get("source_type") + filename = source.get("filename") + activated_at = source.get("activated_at") + disposition = source.get("disposition") + if ( + isinstance(source_batch_id, bool) + or not isinstance(source_batch_id, int) + or source_batch_id <= 0 + or not isinstance(source_type, str) + or source_type not in {"excel", "historical"} + or ( + filename is not None + and ( + not isinstance(filename, str) + or not filename + or len(filename) > 255 + or PurePosixPath(filename).name != filename + ) + ) + or ( + activated_at is not None + and (not isinstance(activated_at, str) or len(activated_at) > 80) + ) + or not isinstance(disposition, str) + or not disposition + or len(disposition) > 40 + ): + raise PortalError( + "COMPANY_REPORT_SOURCE_INVALID", + "当前 Excel 数据源信息无效", + 409, + ) + + counts: Dict[str, int] = {} + for key in ( + "source_rows", + "worksheet_count", + "distinct_group_codes", + "room_quantity", + ): + value = source.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise PortalError( + "COMPANY_REPORT_SOURCE_INVALID", + "当前 Excel 数据源信息无效", + 409, + ) + counts[key] = value + + return { + "source_batch_id": source_batch_id, + "source_type": source_type, + "filename": filename, + **counts, + "activated_at": activated_at, + "disposition": disposition, + } + + def create( + self, + report_month: str, + period: str, + *, + source: Optional[Mapping[str, Any]] = None, + ) -> Dict[str, Any]: as_of_date = period_to_as_of(report_month, period) + source_metadata = self._source_metadata(source) current = self._now() if current.tzinfo is None: current = current.replace(tzinfo=COMPANY_REPORT_TIME_ZONE) @@ -258,6 +350,7 @@ class PersistentCompanyReportCoordinator: "period_label": period_label(report_month, period), "as_of_date": as_of_date.isoformat(), "requested_companies": list(COMPANY_NAMES), + "source": source_metadata, "created_at": _utc_now(), "started_at": None, "finished_at": None, @@ -452,6 +545,11 @@ class PersistentCompanyReportCoordinator: "period_label": state.get("period_label"), "as_of_date": state.get("as_of_date"), "requested_companies": list(COMPANY_NAMES), + "source": ( + dict(state["source"]) + if isinstance(state.get("source"), Mapping) + else None + ), "created_at": state.get("created_at"), "started_at": state.get("started_at"), "finished_at": state.get("finished_at"), diff --git a/arr_web/job_trace.py b/arr_web/job_trace.py index f9ae0fe..873eb9b 100644 --- a/arr_web/job_trace.py +++ b/arr_web/job_trace.py @@ -107,6 +107,28 @@ def _failure_stage(code: Any) -> str: return "processor" +def _execution_metadata(delivery_mode: Any) -> Dict[str, str]: + """Describe ARR execution without exposing host or network location.""" + normalized = str(delivery_mode or "").strip().lower() + if normalized == "artifact_callback": + return { + "execution_scope": "arr_runtime", + "processor_mode": "fixed_processor", + "remote_dispatch": "none", + } + if normalized == "direct_mcp": + return { + "execution_scope": "remote_agent", + "processor_mode": "legacy_direct_mcp", + "remote_dispatch": "mcp", + } + return { + "execution_scope": "unknown", + "processor_mode": "unknown", + "remote_dispatch": "unknown", + } + + def _append_log( logs: List[Dict[str, Any]], *, @@ -202,7 +224,7 @@ def _attempt_logs(logs: List[Dict[str, Any]], attempts: Sequence[Mapping[str, An level="success", code="ATTEMPT_SUCCEEDED", title="处理尝试已闭环", - message="本地处理结果已通过 ARR 验收并完成业务提交。", + message="ARR 固定处理器输出已通过验收并完成业务提交。", details=details, ) @@ -229,7 +251,7 @@ def _delivery_logs( level="success", code="ARTIFACT_RESULT_RECEIVED", title="程序输出制品已登记", - message="ARR 已登记本地处理制品并开始独立验收。", + message="ARR 已登记固定处理器输出制品并开始独立验收。", details=details, ) if delivery.get("validated_at"): @@ -426,7 +448,7 @@ def _outbox_logs(logs: List[Dict[str, Any]], events: Sequence[Mapping[str, Any]] message=( "任务失败事实已写入可靠事件队列。" if is_failure - else "数据库提交事件已写入可靠队列,等待下游消费者。" + else "Finance 数据库提交已完成;事件已写入可靠队列,等待下游消费者。" ), details=details, ) @@ -482,6 +504,7 @@ def build_job_trace( """Build one complete trace using only explicitly allowlisted facts.""" logs: List[Dict[str, Any]] = [] + execution = _execution_metadata(run.get("delivery_mode")) _append_log( logs, log_id="run.received", @@ -602,6 +625,9 @@ def build_job_trace( "source_sha256": _safe_text(run.get("source_sha256"), 64), "business_date": _iso(run.get("business_date")), "delivery_mode": _safe_text(run.get("delivery_mode"), 32), + "execution_scope": execution["execution_scope"], + "processor_mode": execution["processor_mode"], + "remote_dispatch": execution["remote_dispatch"], "attempt_no": ( int(latest_attempt.get("attempt_no") or 0) if latest_attempt is not None diff --git a/arr_web/static/app.js b/arr_web/static/app.js index 7d0a4bd..4e9e468 100644 --- a/arr_web/static/app.js +++ b/arr_web/static/app.js @@ -154,7 +154,10 @@ } if (!response.ok || !payload.ok) { const error = payload?.error || {}; - throw new Error(I18N?.errorMessage(error.code, error.message) || error.message || "请求未完成"); + const requestError = new Error(I18N?.errorMessage(error.code, error.message) || error.message || "请求未完成"); + requestError.code = error.code || ""; + requestError.status = response.status; + throw requestError; } return returnEnvelope ? payload : payload.data; } @@ -516,7 +519,7 @@ const lines = [ `$ arr trace --job ${job.job_id || state.selectedJobId || "-"}`, `trace_version=${traceConsoleValue(trace.trace_version)} status=${traceConsoleValue(job.status)} active=${Boolean(job.active)} current_stage=${traceConsoleValue(job.current_stage)}`, - `attempt_no=${traceConsoleValue(job.attempt_no)} remote_run_id=${traceConsoleValue(job.remote_run_id)} delivery_mode=${traceConsoleValue(job.delivery_mode)}`, + `attempt_no=${traceConsoleValue(job.attempt_no)} execution_scope=${traceConsoleValue(job.execution_scope || "unknown")} processor_mode=${traceConsoleValue(job.processor_mode || "unknown")} remote_dispatch=${traceConsoleValue(job.remote_dispatch || "unknown")} remote_run_id=${traceConsoleValue(job.remote_run_id)} delivery_mode=${traceConsoleValue(job.delivery_mode)}`, `created_at=${traceConsoleValue(job.created_at)} updated_at=${traceConsoleValue(job.updated_at)} finished_at=${traceConsoleValue(job.finished_at)}`, `evidence=${JSON.stringify(trace.evidence || {})}`, "--------------------------------------------------------------------------------", @@ -986,6 +989,52 @@ node.hidden = !message; } + function setCompanySourceStatus(message = "") { + const node = $("#company-source-status"); + node.textContent = I18N?.text(message) || message; + node.hidden = !message; + } + + function companySourceFilename(source) { + const filename = String(source?.filename || "").trim(); + return filename || (I18N?.t("company.source_file_missing") || "未记录文件名"); + } + + function companySourceMeta(source) { + const batchId = Number(source?.source_batch_id); + const rows = Number(source?.source_rows); + const rooms = Number(source?.room_quantity); + const batch = Number.isInteger(batchId) && batchId > 0 ? formatInteger(batchId) : "—"; + const rowCount = Number.isInteger(rows) && rows >= 0 ? formatInteger(rows) : "—"; + const roomCount = Number.isInteger(rooms) && rooms >= 0 ? formatInteger(rooms) : "—"; + const activated = source?.activated_at ? formatDate(source.activated_at, true) : (I18N?.t("company.review_no_record") || "暂无"); + return I18N?.t("company.source_meta", { batch, rows: rowCount, rooms: roomCount, activated }) + || `批次 #${batch} · ${rowCount} 条记录 · ${roomCount} 间 · 启用于 ${activated}`; + } + + function companyJobSourceMeta(source) { + const batchId = Number(source?.source_batch_id); + const batch = Number.isInteger(batchId) && batchId > 0 ? formatInteger(batchId) : "—"; + const activated = source?.activated_at ? formatDate(source.activated_at, true) : (I18N?.t("company.review_no_record") || "暂无"); + return I18N?.t("company.job_source_meta", { batch, activated }) + || `批次 #${batch} · 启用于 ${activated}`; + } + + function renderCompanySource(source) { + const panel = $("#company-source-current"); + if (!panel) return; + if (!source) { + panel.hidden = true; + $("#company-source-current-filename").textContent = ""; + $("#company-source-current-meta").textContent = ""; + return; + } + panel.hidden = false; + $("#company-source-current-filename").textContent = companySourceFilename(source); + $("#company-source-current-filename").title = companySourceFilename(source); + $("#company-source-current-meta").textContent = companySourceMeta(source); + } + function updateCompanySourceUploadControls() { const busy = state.companySourceUploading || state.companyReviewMutating || isCompanyReportActive(); const enabled = state.companySourceUploadReady && !busy; @@ -1104,6 +1153,7 @@ function selectCompanySourceFile(file) { setCompanySourceError(); + setCompanySourceStatus(); state.companySourceFile = null; if (file) { if (!file.name.toLowerCase().endsWith(".xlsx")) { @@ -1125,14 +1175,17 @@ async function loadCompanySource(showErrors = false) { if (!state.companySourceUploadReady) { state.companySource = null; + renderCompanySource(null); updateCompanyReportControls(); return; } try { state.companySource = await api("/api/company-reports/source"); + renderCompanySource(state.companySource); setCompanySourceError(); } catch (error) { state.companySource = null; + renderCompanySource(null); setCompanySourceError(I18N?.t("company.source_error") || "当前 Excel 数据源暂时无法读取"); if (showErrors) showToast(error.message, true); } @@ -1172,6 +1225,7 @@ if (!state.companySourceFile || !state.companySourceUploadReady || state.companySourceUploading) return; state.companySourceUploading = true; setCompanySourceError(); + setCompanySourceStatus(); updateCompanySourceUploadControls(); updateCompanyReportControls(); try { @@ -1195,6 +1249,17 @@ ? (I18N?.t("company.extract_pending", { count: formatInteger(pending) }) || `提取完成,${formatInteger(pending)} 条需要人工确认`) : (I18N?.t("company.extract_done") || "提取完成,可以确认并启用")); } catch (error) { + if (error.code === "BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED") { + const message = I18N?.t("company.source_already_active") || "这份 Excel 已是当前启用来源;请在下方按月份查看或生成对应报表"; + state.companySourceFile = null; + $("#company-excel-file").value = ""; + $("#company-selected-file").textContent = I18N?.t("upload.file_not_selected") || "尚未选择文件"; + setCompanySourceError(); + setCompanySourceStatus(message); + await Promise.all([loadCompanySource(true), loadCompanyReportHistory(true)]); + showToast(message); + return; + } setCompanySourceError(error.message || I18N?.t("company.file_extract_failed") || "Excel 文件未能完成提取"); showToast(error.message || I18N?.t("company.file_extract_failed") || "Excel 文件未能完成提取", true); } finally { @@ -1407,6 +1472,8 @@ state.companyReviewMutating = false; clearCompanyReviewSelection(); renderCompanyDraft(null); + renderCompanySource(source); + setCompanySourceStatus(); updateCompanyReportControls(); showToast(I18N?.t("company.review_activated") || "人工核对完成,Booking 数据源已启用"); } catch (error) { @@ -1695,6 +1762,16 @@ $("#company-report-job-as-of").textContent = job.as_of_date ? formatDate(job.as_of_date) : (I18N?.t("company.review_no_record") || "暂无"); $("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds); $("#company-report-warning-count").textContent = I18N?.t("company.report_warning_count", { count: formatInteger(companyWarningCount(job)) }) || `${companyWarningCount(job)} 项`; + const source = job?.source; + const sourcePanel = $("#company-report-job-source"); + sourcePanel.classList.toggle("is-missing", !source); + $("#company-report-job-source-file").textContent = source + ? companySourceFilename(source) + : (I18N?.t("company.job_source_missing") || "历史任务未记录来源"); + $("#company-report-job-source-file").title = source ? companySourceFilename(source) : ""; + $("#company-report-job-source-meta").textContent = source + ? companyJobSourceMeta(source) + : (I18N?.t("company.review_no_record") || "暂无"); $("#company-result-month-end-header").textContent = companyPeriodRange(job.report_month, "21-month-end"); renderCompanyResultRows(job); localStorage.setItem("arr:last-company-report-job", job.job_id); @@ -1706,18 +1783,24 @@ state.companyReportJobs = jobs; const body = $("#company-history-rows"); if (!jobs.length) { - body.innerHTML = `${historyEmptyMarkup("company", "history.company_empty", "暂无公司渠道明细任务")}`; + body.innerHTML = `${historyEmptyMarkup("company", "history.company_empty", "暂无公司渠道明细任务")}`; return; } - body.innerHTML = jobs.map((job) => ` + body.innerHTML = jobs.map((job) => { + const source = job?.source; + const sourceFilename = source ? companySourceFilename(source) : (I18N?.t("company.job_source_missing") || "历史任务未记录来源"); + const sourceMeta = source ? companyJobSourceMeta(source) : (I18N?.t("company.review_no_record") || "暂无"); + return ` ${formatDate(job.created_at, true)} ${escapeHtml(job.report_month ? monthLabel(job.report_month) : (I18N?.t("company.review_no_record") || "暂无"))} ${escapeHtml(companyCutoffLabel(job))} ${escapeHtml(companyStateLabel(job.state))} ${companySuccessCount(job)} / 5 ${companyWarningCount(job)} +
${escapeHtml(sourceFilename)}${escapeHtml(sourceMeta)}
- `).join(""); + `; + }).join(""); $$('[data-company-job-id]', body).forEach((button) => button.addEventListener("click", () => loadCompanyReportJob(button.dataset.companyJobId, true))); } @@ -1754,7 +1837,7 @@ if (target) await loadCompanyReportJob(target.job_id); } } catch (error) { - $("#company-history-rows").innerHTML = `${escapeHtml(I18N?.t("company.draft_error") || "任务记录暂时无法读取")}`; + $("#company-history-rows").innerHTML = `${escapeHtml(I18N?.t("company.draft_error") || "任务记录暂时无法读取")}`; if (restore) showToast(error.message, true); } finally { state.companyHistoryLoading = false; @@ -1888,6 +1971,7 @@ renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, state.monthlyLoading); if (state.analytics) renderAnalytics(state.analytics); else resetAnalytics(I18N?.t("bi.empty") || "暂无渠道与房型数据"); + renderCompanySource(state.companySource); if (state.companySourceDraft?.summary) renderCompanyDraft(state.companySourceDraft); else renderCompanyDraft(null); renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, state.companyHistoryLoading); diff --git a/arr_web/static/h5.js b/arr_web/static/h5.js index 2045312..1b76c41 100644 --- a/arr_web/static/h5.js +++ b/arr_web/static/h5.js @@ -36,10 +36,14 @@ } async function api(path, options = {}) { + const allowUnauthorized = Boolean(options.allowUnauthorized); + const requestOptions = { ...options }; + delete requestOptions.allowUnauthorized; const headers = new Headers(options.headers || {}); if (options.method && options.method !== "GET") headers.set("X-ARR-CSRF", csrf); - const response = await fetch(path, { ...options, headers, credentials: "same-origin" }); + const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" }); if (response.status === 401) { + if (allowUnauthorized) return null; redirectToLogin(); throw new Error(I18N?.errorMessage("SESSION_INVALID", "登录状态已失效") || "登录状态已失效"); } @@ -130,7 +134,7 @@ async function load() { const month = $("#h5-month").value || monthNow(); try { - render(await api(`/api/analytics?month=${encodeURIComponent(month)}`)); + render(await api(`/api/public/h5/analytics?month=${encodeURIComponent(month)}`)); } catch (error) { reset(I18N?.t("bi.empty") || "该月份暂无可用看板数据"); notify(error.message); @@ -145,10 +149,10 @@ async function boot() { const connection = $("#h5-connection"); try { - const session = await api("/api/session"); - csrf = session.csrf_token; + const session = await api("/api/session", { allowUnauthorized: true }); + csrf = session?.csrf_token || ""; const logout = $("#h5-logout"); - if (session.username) { + if (session?.username) { logout.hidden = false; logout.removeAttribute("aria-disabled"); logout.classList.remove("is-busy"); @@ -169,11 +173,14 @@ }); } } catch (_) { - return; + // Session lookup is optional for the public read-only dashboard. } try { - const health = await api("/api/health"); - const databaseReady = Boolean(health.database_ready); + const healthResponse = await fetch("/healthz", { + credentials: "same-origin", + cache: "no-store", + }); + const databaseReady = healthResponse.ok; connection.classList.add(databaseReady ? "ready" : "error"); connection.lastChild.textContent = databaseReady ? "" @@ -190,7 +197,7 @@ connection.setAttribute("aria-label", I18N?.t("status.service_disconnected") || "数据服务未连接"); } let months = []; - try { months = await api("/api/months"); } catch (_) { /* handled by load */ } + try { months = await api("/api/public/h5/months"); } catch (_) { /* handled by load */ } const select = $("#h5-month"); select.innerHTML = months.length ? months.map((item) => ``).join("") diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js index 55f5670..245a1d8 100644 --- a/arr_web/static/i18n.js +++ b/arr_web/static/i18n.js @@ -265,6 +265,15 @@ "company.period_not_finished": ["周期未结束", "Period not complete", "รอบยังไม่สิ้นสุด"], "company.period_finished": ["周期已结束", "Period complete", "รอบสิ้นสุดแล้ว"], "company.source_error": ["当前 Excel 数据源暂时无法读取", "The current Excel source is temporarily unavailable", "ไม่สามารถอ่านแหล่ง Excel ปัจจุบันได้ชั่วคราว"], + "company.current_source": ["当前启用来源", "Active source", "แหล่งข้อมูลที่เปิดใช้งาน"], + "company.source_active": ["已启用", "Active", "เปิดใช้งานแล้ว"], + "company.source_meta": ["批次 #{batch} · {rows} 条记录 · {rooms} 间 · 启用于 {activated}", "Batch #{batch} · {rows} records · {rooms} rooms · Activated {activated}", "ชุดข้อมูล #{batch} · {rows} รายการ · {rooms} ห้อง · เปิดใช้งานเมื่อ {activated}"], + "company.source_file_missing": ["未记录文件名", "File name not recorded", "ไม่ได้บันทึกชื่อไฟล์"], + "company.source_already_active": ["这份 Excel 已是当前启用来源;请在下方按月份查看或生成对应报表", "This Excel is already the active source. Review or generate the corresponding report by month below.", "Excel นี้เป็นแหล่งข้อมูลที่เปิดใช้งานอยู่แล้ว ตรวจสอบหรือสร้างรายงานตามเดือนได้ด้านล่าง"], + "company.job_source": ["来源 Excel(提交时)", "Source Excel (at submission)", "แหล่ง Excel (ขณะส่งงาน)"], + "company.history_source": ["来源 Excel", "Source Excel", "แหล่ง Excel"], + "company.job_source_missing": ["历史任务未记录来源", "Source not recorded for this historical job", "งานย้อนหลังนี้ไม่ได้บันทึกแหล่งข้อมูล"], + "company.job_source_meta": ["批次 #{batch} · 启用于 {activated}", "Batch #{batch} · Activated {activated}", "ชุดข้อมูล #{batch} · เปิดใช้งานเมื่อ {activated}"], "company.draft_error": ["当前提取结果暂时无法读取", "The current extraction is temporarily unavailable", "ไม่สามารถอ่านผลการแยกปัจจุบันได้ชั่วคราว"], "company.file_xlsx": ["请选择 .xlsx 格式的 Excel 文件", "Choose an .xlsx Excel file", "เลือกไฟล์ Excel รูปแบบ .xlsx"], "company.file_empty": ["Excel 文件为空", "The Excel file is empty", "ไฟล์ Excel ว่างเปล่า"], @@ -404,6 +413,7 @@ "error.review_request_invalid": ["人工确认请求无效", "The review request is invalid", "คำขอตรวจสอบไม่ถูกต้อง"], "error.review_item_invalid": ["待确认记录编号无效", "Invalid review item ID", "รหัสรายการตรวจสอบไม่ถูกต้อง"], "error.company_request_invalid": ["渠道明细请求字段无效", "Invalid company detail request fields", "ฟิลด์คำขอรายละเอียดบริษัทไม่ถูกต้อง"], + "error.company_source_already_active": ["这份 Excel 已经启用,无需重复提取", "This Excel is already active; no duplicate extraction is needed", "Excel นี้เปิดใช้งานอยู่แล้ว ไม่จำเป็นต้องแยกข้อมูลซ้ำ"], "error.company_period_invalid": ["期间必须是 01-10、11-20 或 21-month-end", "Period must be 01-10, 11-20 or 21-month-end", "ช่วงเวลาต้องเป็น 01-10, 11-20 หรือ 21-month-end"], "error.company_job_not_found": ["任务不存在", "The job does not exist", "ไม่พบงาน"], "error.download_too_large": ["文件超过下载限制", "The file exceeds the download limit", "ไฟล์มีขนาดเกินขีดจำกัดการดาวน์โหลด"], @@ -504,6 +514,7 @@ BOOKING_EXCEL_PROCESSING_FAILED: "error.booking_extract_failed", BOOKING_EXCEL_REVIEW_OPEN: "error.company_review_open", BOOKING_EXCEL_SOURCE_REQUIRED: "error.company_source_required", + BOOKING_EXCEL_SOURCE_ALREADY_ACTIVATED: "error.company_source_already_active", COMPANY_REPORT_MONTH_IN_FUTURE: "error.company_future", COMPANY_REPORT_ALREADY_RUNNING: "error.company_running", COMPANY_REPORT_GENERATOR_UNAVAILABLE: "error.company_unavailable", diff --git a/arr_web/static/index.html b/arr_web/static/index.html index f685f5b..88093e9 100644 --- a/arr_web/static/index.html +++ b/arr_web/static/index.html @@ -224,6 +224,14 @@

上传 Excel 报表

+
+