diff --git a/.project-docs/30-worklog/tasks/20261009-daily-lifecycle-7bc2.md b/.project-docs/30-worklog/tasks/20261009-daily-lifecycle-7bc2.md
new file mode 100644
index 0000000..2681dab
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20261009-daily-lifecycle-7bc2.md
@@ -0,0 +1,76 @@
+# Task: 日报按日期覆盖、删除及月报同步
+
+## Identity
+
+- Task ID: 20261009-daily-lifecycle-7bc2
+- Mode: Feature
+- Branch: codex/20261009-daily-lifecycle-7bc2-daily-lifecycle
+- Worktree: /Users/chillishark/.codex/worktrees/arr-daily-lifecycle
+- Base commit: 976a4fa7b2bc445e327f412531c6ccc5e7ddc99a
+- Owner: codex
+- Status: Ready for Integration
+
+## Scope
+
+- User-approved daily report lifecycle: one current report per business date, successful new XML/OHIP versions replace old only after acceptance, manual deletion with monthly refresh, grouped date/history UI and consistent selected-date overview.
+
+## Intent And Constraints
+
+- Preserve shared business rules, immutable original/audit facts and live user decisions. Deletion retires the report while retaining source/objects and audit, with no product undo button; deleting a current day must not resurrect an older version. A fresh upload/request may generate a new version. New pending/failed input must not remove the current accepted day. No new Oracle reads or fabricated prices. Use synthetic isolated database acceptance, then safe local activation with backup and no active tasks. Prior release push authorization remains valid.
+
+## Outcome
+
+- Implemented business-date grouping and exact selected-date overview, with current accepted report separate from a new pending/failed version. Shared XML/OHIP rules and processor4.4.0 identity unchanged; a successful new version alone replaces the current day.
+- Added authenticated/CSRF-protected exact-job/date retirement. Serializability, day/month locks, immutable retirement receipts, current-version guard and outbox atomically remove only the requested effective version; old/pending history retirement preserves current facts. No source/object/Finance fact deletion and no automatic old-version restoration.
+- Migration022 adds append-only operator receipts and withdrawn monthly publications, partial live-snapshot uniqueness and immutable withdrawn lineage; grants are confined to known application role. Startup checks022 structure/protection, including missing index/disabled audit trigger rejection. Native XML/API simulation and OHIP owned instances now request schema022 explicitly.
+- Monthly worker rebuilds from remaining current Finance days, refuses stale cutoff/pins, consumes committed/scope-changed events, withdraws an empty month without publishing an empty workbook, and blocks pre-deletion builds from becoming current. Withdrawn publications disappear from downloads, lists and analytics; audit and artifacts remain intact.
+- Frontend offers one date row, explicit current/history/new statuses, date selection, paged history, deletion impact confirmation, safe duplicate submission and stale-response guards; preserves other-date pending work and unsaved prices. Deleted old successful versions are clearly history-only when no current day remains. Chinese/English/Thai wording and empty read-only preview updated.
+- Updated product/deployment docs with022 order, role grants, worker coupling, backed-up upgrade and rollback boundaries. Canonical project memory left unchanged in feature mode.
+- Activated loopback8875 after confirming no active acquisition/processing, graceful launchd stop and cold backup. Private launcher/config/plist now point to this owned worktree, schema008-022. Existing login and production hotel57106 routing retained. No actual daily report retired and no Oracle acquisition submitted.
+
+## Verification
+
+- Backend: initial33 retirement/ingestion checks passed; after adding022 startup gate, final17 retirement/PostgreSQL cases plus15 runtime composition cases passed (32 total). Covers old/new identity, pending price cancellation, busy generation, duplicate/concurrent retirement, source/fact retention, transaction rollback, day/month races and022 gate failures.
+- Monthly:31 repository/service/worker unit checks and7 real isolated PostgreSQL lifecycle cases passed. Includes delete last/middle/latest date, empty whole month, ABA snapshot republication, paused stale builders and immutable withdrawn lineage.
+- Portal:7 real isolated PostgreSQL date/history/download/analytics/read-only role cases passed. Root50 API/legacy/schema/runtime checks passed; later19 API/schema/empty-preview checks passed after preview compatibility was added. HTTP checks cover auth, CSRF, strict date/body and session actor binding.
+- Local entrypoints:19 native XML/API simulation checks passed with real owned PostgreSQL, explicitly verifying new grouped API on the new schema and restart/download/monthly behavior.
+- Frontend:105 JavaScript tests and14 UI/static/privacy tests passed after final history-only status correction. Syntax and whitespace checks passed. Independent review found and resolved old-success label ambiguity and missing local/schema022 startup gate; no remaining blocking issue reported.
+- Independent browser port8891 with synthetic data: two effective days plus one pending replacement. Deleted pending09/16 and verified old effective day stayed; deleted effective09/16 and verified list removed the day, selected09/16 overview stayed empty instead of displaying09/17, and rebuilt September month dropped from2 data rows to1. Real hotel data/services were isolated from this fixture.
+- Actual local upgrade: baseline and after-upgrade hashes/counts of all26 existing business tables match, ignoring only newly added NULL monthly.withdrawn_at.3 current daily versions,4,896 original source files, queue statuses, legacy jobs/months/monthly responses and existing pending tasks match.022 retirement table remains0 rows. Actual grouped September API returns unique09/17 and09/16 rows; pending09/15 and09/13 survive.
+- Actual browser: selected09/13 shows09/13 throughout acquisition/date overview, no borrowed rooms/duration; existing09/16 shows150 rooms, one main row and2 history records with delete controls. No business input saved, deleted or refetched. Screenshots saved privately.
+- Backup: `/Users/chillishark/Library/Application Support/ARR2.0/daily-lifecycle-backup-20261009-025721` (cold owned database, config/source/objects/queues; existing runtime excluded). Private evidence/scripts: `/Users/chillishark/Library/Application Support/ARR2.0/daily-lifecycle-validation-20261009`; captures before/after snapshots, activation, synthetic monthly deletion and live date/history screenshots. No credentials/data committed.
+- Project documentation and task-aware drift gates passed; canonical snapshot migration remains pending for a separate Integration Gate. Final JavaScript syntax and whitespace checks passed. Approved target is arr0918 `https://git.nianxx.cn/shiyuyun/ARR-2.0-0918.git`, main; pre-publication remote HEAD equals base976a4fa. Publish non-force and verify remote HEAD; store exact post-push receipt privately with activation evidence.
+
+## Follow-ups
+
+- Target-host deployment still requires backed-up022 migration, role grants and coordinated new Web/processing/monthly worker. Local acceptance does not claim target-host deployment or full real-report financial business reconciliation.
+- Retain this managed worktree:8875 now runs its code. Do not retire/archive it until runtime has moved to another reviewed checkout. Backup must be restored with matching schema/code/worker if an operator later needs full rollback; no rollback was needed.
+
+## Promotion Candidates
+
+- Targets: canonical domain business-rules/success-criteria and architecture data-flow. Proposal: daily report identity is business date with one current accepted version; replacement only on success, logical retirement with immutable facts, current-day deletion excludes that day from monthly facts, historical deletion preserves current. Evidence: explicit user approval2026-10-09, isolated PostgreSQL concurrency/withdrawal tests and browser lifecycle proof. Future impact: daily/monthly API, deployments and audit expectations. Conflict: extends existing immutable processing/audit design without changing rate/price/PM/cancellation rules; canonical September snapshot is stale. Human confirmation: product behavior already approved; apply only through a separate serialized Integration Gate.
+- Targets: canonical current-state/evidence index. Proposal: local8875 runs this checkout with processor4.4.0/schema022, existing actual data preserved, feature acceptance synthetic and actual portal read-only. Evidence: private activation/baseline/after snapshots and screenshots. Future impact: accurate maintenance/rollback/worktree lifecycle. Conflict: prior production-review checkout is no longer the live code path; primary dirty checkout remains untouched. Human confirmation: no additional decision required to promote verified environment facts, but Integration Gate remains mandatory.
+
+## Concurrent Task Gate
+
+- Passed: unique feature task20261009-daily-lifecycle-7bc2, codex owner, branchcodex/20261009-daily-lifecycle-7bc2-daily-lifecycle, managed worktree/Users/chillishark/.codex/worktrees/arr-daily-lifecycle, base976a4fa7b2bc445e327f412531c6ccc5e7ddc99a. start exited0 and status matched. Existing production-review task is Ready for Integration and runs the current service; its task record was inspected read-only. Its code is included in this base, no concurrent write overlap. Dirty primary checkout remains untouched.
+
+## Project Context Loaded
+
+Read: read-before-planning/planning-gate, own task record, memory-index, positioning, current-state, decision-index, ADR001/004/006/007, system-overview/module-map/data-flow, business-rules/success-criteria, evidence/reflection/commitments/stale indexes and prior production-review outcome.
+
+- Project goal: deterministic shared XML/OHIP daily processing, accepted Finance facts and automatically updated monthly reports.
+- Current focus: business-date report identity/lifecycle, recoverable deletion and date-consistent presentation.
+- Constraints: immutable source/artifacts/audit, atomic activation and outbox, frozen manual-price replay, no invented values or new acquisition, private runtime outside Git.
+- Decisions: successful commits activate one Finance version per date; dedicated monthly worker with current-pin validation and derived scope; user now explicitly approves report retirement/monthly removal and date-grouped display.
+- Evidence/conflicts: canonical memory last integrated September18 is stale regarding October9 production hotel57106/processor4.4.0/migration021 and multi-agent consent; recent task evidence and direct user authorization govern these current facts. No canonical edits in feature mode.
+- Modules: arr_web repository/app/downloads/programmatic/UI, arr_ingestion Finance SQL/protected roles, monthly_reports snapshot/publication/outbox and local runtime migrations.
+- Unknowns: exact existing activation, monthly empty-date/month behavior and race boundaries require code inspection; no unresolved product choice blocks the authorized behavior.
+- Gate: Passed.
+
+## Plan
+
+1. Inspect existing date-version activation and monthly publication; establish a recoverable auditable retirement contract.
+2. Implement protected atomic day/report retirement and monthly refresh, reject unsafe stale/concurrent operations and preserve current version until replacement succeeds.
+3. Group daily list by date with accessible history/delete controls; bind overview to selected day/current published version versus pending replacement.
+4. Verify real isolated PostgreSQL daily/monthly lifecycle, race/idempotent cases, HTTP/auth and UI; safely activate local service without deleting actual reports, capture UI proof and push the reviewed code.
diff --git a/arr_ingestion/postgres.py b/arr_ingestion/postgres.py
index 7eda27f..c31df2e 100644
--- a/arr_ingestion/postgres.py
+++ b/arr_ingestion/postgres.py
@@ -8,7 +8,7 @@ import os
import re
import time
from dataclasses import dataclass
-from datetime import date
+from datetime import date, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any, Callable, Dict, Mapping, Optional, Sequence, Tuple
@@ -215,6 +215,119 @@ class PostgresIngestionRepository(IngestionRepository):
"database connection failed",
) from None
+ def retire_daily_job(
+ self, job_id: str, business_date: date, actor_username: str
+ ) -> Dict[str, Any]:
+ """Retire one exact run, retaining all source, facts and audit lineage."""
+ if not isinstance(job_id, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", job_id) is None:
+ raise IngestionError("DAILY_JOB_NOT_FOUND", "日报记录不存在")
+ if type(business_date) is not date:
+ raise IngestionError("DAILY_DATE_CONFLICT", "报表日期无效,请刷新后重试")
+ actor = self._review_actor(actor_username)
+ return self._run_transaction(
+ lambda cursor: self._retire_daily_job(cursor, job_id, business_date, actor),
+ "daily report could not be retired",
+ )
+
+ @staticmethod
+ def _retirement_receipt(job_id: str, business_date: date, current_removed: bool) -> Dict[str, Any]:
+ return {
+ "job_id": job_id,
+ "business_date": business_date.isoformat(),
+ "is_retired": True,
+ "current_removed": current_removed,
+ "monthly_refresh_required": current_removed,
+ }
+
+ def _retire_daily_job(self, cursor: Any, job_id: str, business_date: date, actor: str) -> Dict[str, Any]:
+ cursor.execute(
+ """SELECT id, business_date, run_status
+ FROM ingestion.processing_runs
+ WHERE run_key = %s AND pipeline_type = 'opera_daily'
+ FOR UPDATE""", (job_id,),
+ )
+ run = cursor.fetchone()
+ if run is None:
+ raise IngestionError("DAILY_JOB_NOT_FOUND", "日报记录不存在")
+ run_id, stored_date, run_status = int(run[0]), run[1], str(run[2])
+ if stored_date != business_date:
+ raise IngestionError("DAILY_DATE_CONFLICT", "报表日期已变化,请刷新后重试")
+ cursor.execute(
+ """SELECT current_removed FROM ingestion.daily_run_retirements
+ WHERE processing_run_id = %s""", (run_id,),
+ )
+ retired = cursor.fetchone()
+ if retired is not None:
+ return self._retirement_receipt(job_id, business_date, bool(retired[0]))
+ if run_status not in {"accepted", "rejected", "failed", "cancelled", "awaiting_review"}:
+ raise IngestionError("DAILY_JOB_BUSY", "任务正在处理,请完成后再删除")
+ cursor.execute(
+ "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
+ (f"finance-daily:{business_date.isoformat()}",),
+ )
+ cursor.execute(
+ """SELECT id, version_status FROM finance.daily_versions
+ WHERE processing_run_id = %s FOR UPDATE""", (run_id,),
+ )
+ versions = cursor.fetchall()
+ if len(versions) > 1:
+ raise IngestionError("DATABASE_STATE_INVALID", "日报版本关联无效")
+ version_id = int(versions[0][0]) if versions else None
+ cursor.execute(
+ """SELECT daily_version_id FROM finance.current_daily_versions
+ WHERE business_date = %s FOR UPDATE""", (business_date,),
+ )
+ current = cursor.fetchone()
+ current_removed = version_id is not None and current is not None and int(current[0]) == version_id
+ if run_status == "awaiting_review":
+ cursor.execute(
+ """SELECT case_key, revision, case_status FROM ingestion.daily_review_cases
+ WHERE processing_run_id = %s FOR UPDATE""", (run_id,),
+ )
+ case = cursor.fetchone()
+ if case is None or str(case[2]) not in {"open", "generation_failed"}:
+ raise IngestionError("DAILY_JOB_BUSY", "任务正在生成日报,请完成后再删除")
+ self._cancel_price_review(cursor, job_id, str(case[0]), int(case[1]), actor)
+ if current_removed:
+ period_start = business_date.replace(day=1)
+ period_end = (period_start.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
+ cursor.execute(
+ "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
+ (f"monthly_channel:{period_start.isoformat()}:{period_end.isoformat()}:*",),
+ )
+ cursor.execute(
+ """DELETE FROM finance.current_daily_versions
+ WHERE business_date = %s AND daily_version_id = %s""", (business_date, version_id),
+ )
+ if cursor.rowcount != 1:
+ raise IngestionError("DAILY_DATE_CONFLICT", "该日有效报表已变化,请刷新后重试")
+ cursor.execute(
+ """UPDATE finance.daily_versions SET version_status = 'superseded', superseded_at = now()
+ WHERE id = %s AND version_status = 'active'""", (version_id,),
+ )
+ if cursor.rowcount != 1:
+ raise IngestionError("DATABASE_STATE_INVALID", "该日有效报表状态无效")
+ # Withdraw downloadable snapshots immediately, before asynchronous rebuilding.
+ cursor.execute(
+ """UPDATE reporting.monthly_runs
+ SET report_status = 'withdrawn', withdrawn_at = now(), updated_at = now()
+ WHERE period_start = %s AND report_status IN ('reserved', 'active', 'superseded', 'failed')""",
+ (period_start,),
+ )
+ self._insert_outbox(
+ cursor, f"daily-version:{version_id}:retired", "daily_version", version_id,
+ "arr.daily_scope_changed",
+ {"daily_version_id": version_id, "business_date": business_date.isoformat(),
+ "period_start": period_start.isoformat(), "action": "delete"},
+ )
+ cursor.execute(
+ """INSERT INTO ingestion.daily_run_retirements
+ (processing_run_id, business_date, daily_version_id, current_removed, actor_username)
+ VALUES (%s, %s, %s, %s, %s)""",
+ (run_id, business_date, version_id, current_removed, actor),
+ )
+ return self._retirement_receipt(job_id, business_date, current_removed)
+
def _run_transaction(
self,
operation: Callable[[Any], Any],
@@ -401,6 +514,44 @@ class PostgresIngestionRepository(IngestionRepository):
"ARR processing requires migration 021")
self._run_transaction(check, "ARR PM schema could not be checked")
+ def assert_daily_lifecycle_schema(self) -> None:
+ """Do not expose date history/deletion with an incomplete 022 upgrade."""
+ def check(cursor):
+ cursor.execute("""SELECT
+ to_regclass('ingestion.daily_run_retirements') IS NOT NULL,
+ (SELECT count(*) = 7 FROM information_schema.columns
+ WHERE table_schema = 'ingestion' AND table_name = 'daily_run_retirements'
+ AND column_name IN ('id','processing_run_id','business_date','daily_version_id',
+ 'current_removed','actor_username','retired_at')),
+ EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'reporting' AND table_name = 'monthly_runs'
+ AND column_name = 'withdrawn_at' AND data_type = 'timestamp with time zone'),
+ EXISTS (SELECT 1 FROM pg_constraint
+ WHERE conrelid = 'reporting.monthly_runs'::regclass
+ AND conname = 'monthly_runs_status_shape'
+ AND pg_get_constraintdef(oid) LIKE '%withdrawn_at%'),
+ EXISTS (SELECT 1 FROM pg_index AS index_row
+ JOIN pg_class AS index_name ON index_name.oid = index_row.indexrelid
+ WHERE index_row.indrelid = 'reporting.monthly_runs'::regclass
+ AND index_name.relname = 'monthly_runs_live_snapshot_unique'
+ AND index_row.indisunique AND index_row.indisvalid
+ AND pg_get_indexdef(index_row.indexrelid) LIKE '%period_start, source_snapshot_sha256%'
+ AND pg_get_expr(index_row.indpred,index_row.indrelid) LIKE '%report_status <>%withdrawn%'),
+ EXISTS (SELECT 1 FROM pg_trigger
+ WHERE tgrelid = to_regclass('ingestion.daily_run_retirements')
+ AND tgname = 'daily_run_retirements_immutable' AND tgenabled = 'O'),
+ EXISTS (SELECT 1 FROM pg_proc
+ WHERE oid = to_regprocedure('finance.validate_current_daily_version()')
+ AND prosrc LIKE '%daily_run_retirements%'),
+ EXISTS (SELECT 1 FROM pg_proc
+ WHERE oid = to_regprocedure('reporting.protect_published_monthly_child()')
+ AND prosrc LIKE '%withdrawn%')""")
+ row = cursor.fetchone()
+ if not row or len(row) != 8 or any(value is not True for value in row):
+ raise IngestionError("DATABASE_MIGRATION_MISSING",
+ "ARR daily report lifecycle requires migration 022")
+ self._run_transaction(check, "ARR daily lifecycle schema could not be checked")
+
def register_job(self, registration: JobRegistration) -> None:
if (
registration.source.role not in {"source_xml", "source_data"}
@@ -1280,6 +1431,17 @@ class PostgresIngestionRepository(IngestionRepository):
"processing job identity conflicts",
)
processing_run_id = int(existing[0])
+ # Pre-022 ingestion remains compatible; once retirement is enabled,
+ # an old run identity can never be reused to undo a user's deletion.
+ cursor.execute("SELECT to_regclass('ingestion.daily_run_retirements')")
+ retirement_table = cursor.fetchone()
+ if retirement_table is not None and retirement_table[0] is not None:
+ cursor.execute(
+ "SELECT 1 FROM ingestion.daily_run_retirements WHERE processing_run_id = %s",
+ (processing_run_id,),
+ )
+ if cursor.fetchone() is not None:
+ raise IngestionError("JOB_RETIRED", "该报表记录已删除,请重新提交新任务")
cursor.execute(
"""
SELECT idempotency_key
@@ -2275,6 +2437,14 @@ class PostgresIngestionRepository(IngestionRepository):
business_date: date,
daily_version_id: int,
) -> None:
+ # Publication and retirement share this month lock. A successful daily
+ # replacement cannot slip between a monthly source check and activation.
+ period_start = business_date.replace(day=1)
+ period_end = (period_start.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
+ cursor.execute(
+ "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))",
+ (f"monthly_channel:{period_start.isoformat()}:{period_end.isoformat()}:*",),
+ )
cursor.execute(
"""
SELECT daily_version_id
diff --git a/arr_web/DIRECT_DATA_ENTRY.md b/arr_web/DIRECT_DATA_ENTRY.md
index 0419a9a..d17e109 100644
--- a/arr_web/DIRECT_DATA_ENTRY.md
+++ b/arr_web/DIRECT_DATA_ENTRY.md
@@ -29,6 +29,21 @@
完整备注、套餐明细和查询出处保留在原始数据中。Trace 不获取,旧报表对应列留空。
这是一套明确的新入口显示规则,不声明与 Oracle 原生报表的文字格式完全一致。
+## 日报覆盖、删除与日期概览(2026-10-09)
+
+日报列表按业务日期展示,一天只展示一行。接口取数和XML上传遵循相同的覆盖规则:新版本成功生成并入库后,才替换该日有效日报并更新月报;新版本尚待核对或失败时,原有效日报仍可下载并继续计入月报。
+
+点击列表中的日期,可查看该日概览;上方日期、房数和耗时始终对应当前查看日期。待处理新版本会单独提示,尚无有效日报时显示空值,不借用其他日期的成功结果。
+
+“历史记录”保留同一天的旧版本和未完成版本。删除前页面会说明影响:
+
+- 删除当前有效日报:该日从月报中移除,系统自动生成剩余日期的月报;整月已无有效日报时,撤下该月报。旧日报不会自动恢复。
+- 删除历史版本或待价格核对版本:保留当前有效日报,月报不变。运行中的版本须等任务停止后再删除。
+
+删除会保留原始数据及操作记录,已删除记录不能继续下载或处理;如需重新纳入,可重新上传或提交新的取数请求。此功能不提供撤销删除按钮。
+
+页面使用`GET /api/daily-reports`按日期分页、`GET /api/daily-reports/{date}`读取所选日期、`GET /api/daily-reports/{date}/history`读取历史;删除使用`DELETE /api/jobs/{job_id}`,正文为`{"business_date":"YYYY-MM-DD"}`,沿用登录及CSRF保护,并记录当前操作者。原`GET /api/jobs`仍供任务记录查询使用。
+
## 字段完善的保存与继续处理
`DataFieldReviews`只接受`collection_complete=true`的完整采集,以当前冻结处理器的费率白名单和字段验证识别阻塞项。
diff --git a/arr_web/README.md b/arr_web/README.md
index 65ce35e..c8b3a7c 100644
--- a/arr_web/README.md
+++ b/arr_web/README.md
@@ -1,5 +1,7 @@
# ARR 2.0 Web
+2026-10-09:日报按日期展示一个有效版本,支持查看历史、删除及月报同步;概览跟随所选日期。发布需要数据库022及配套月报worker,详见[交付说明](../deploy/OHIP_RELEASE_HANDOVER.md#日报版本管理升级2026-10-09)。
+
2026-09-18:[直接数据入口](DIRECT_DATA_ENTRY.md)已接通日期按钮、日报、复核和月报;独立本机模拟8875,未查询实际Oracle。
2026-09-17:[本机接口模拟全流程及按钮联调](LOCAL_API_SIMULATION.md)已在独立8874验证;[原文件重放](LOCAL_XML_REPLAY.md)8873保留。两个入口均明确未连接Oracle,不改变默认运行配置。
@@ -47,6 +49,8 @@ Web 层提供固定页面和审计友好的 API。生产 XML 写路径为:
- `GET /healthz`:仅供容器读取的无详情 readiness 探针
- `POST /api/jobs`
- `GET /api/jobs`
+- `GET /api/daily-reports`、`GET /api/daily-reports/{date}`、`GET /api/daily-reports/{date}/history`:按日期查询有效日报与历史
+- `DELETE /api/jobs/{job_id}`:删除指定日期的日报版本,当前有效版本删除后自动更新月报;需要登录和CSRF
- `GET /api/jobs/{job_id}/trace`
- `GET /api/download/daily`
- `GET /api/monthly-runs?month=YYYY-MM`:读取已持久化的月报版本
diff --git a/arr_web/app.py b/arr_web/app.py
index b4e3964..9cf8ec4 100644
--- a/arr_web/app.py
+++ b/arr_web/app.py
@@ -39,6 +39,7 @@ from arr_web.repository import (
UnavailablePortalRepository,
)
from arr_web.downloads import ArtifactReader, UnavailableArtifactReader
+from arr_web.daily_reports import UnavailableDailyReports
from arr_web.arr_downloads import (
ARRDownloadCoordinator, UnavailableARRDownloads, default_report_date,
validate_report_date, validate_request_id,
@@ -254,6 +255,7 @@ class PortalApplication:
login_attempts: Optional[LoginAttemptLedger] = None,
secure_cookies: bool = False,
arr_downloads: Optional[ARRDownloadCoordinator] = None,
+ daily_reports=None,
) -> None:
self._repository = repository or UnavailablePortalRepository()
self._upload = upload or UnavailableUploadCoordinator()
@@ -273,6 +275,7 @@ class PortalApplication:
self._login_attempts = login_attempts or LoginAttemptLedger()
self._secure_cookies = secure_cookies
self._arr_downloads = arr_downloads or UnavailableARRDownloads()
+ self._daily_reports = daily_reports or UnavailableDailyReports()
def handle(
self,
@@ -425,7 +428,35 @@ class PortalApplication:
offset=offset,
),
)
+ if method == "GET" and route.path == "/api/daily-reports":
+ query = parse_qs(route.query, keep_blank_values=True)
+ month = validate_month(self._one(query, "month", self._current_month()))
+ limit = self._integer(query, "limit", 50, 1, 200)
+ offset = self._integer(query, "offset", 0, 0, 10_000_000)
+ reports, total = self._repository.list_daily_reports(month, limit, offset)
+ return Response.json(200, _paged_success(reports, total=total, limit=limit, offset=offset))
+ if method == "GET" and route.path.startswith("/api/daily-reports/"):
+ parts = route.path[len("/api/daily-reports/"):].split("/")
+ day = validate_report_date(parts[0])
+ if len(parts) == 1:
+ return Response.json(200, success(self._repository.get_daily_report(day)))
+ if len(parts) == 2 and parts[1] == "history":
+ query = parse_qs(route.query, keep_blank_values=True)
+ limit = self._integer(query, "limit", 50, 1, 200)
+ offset = self._integer(query, "offset", 0, 0, 10_000_000)
+ jobs, total = self._repository.list_daily_report_history(day, limit, offset)
+ return Response.json(200, _paged_success(jobs, total=total, limit=limit, offset=offset))
job_parts = route.path.strip("/").split("/")
+ if method == "DELETE" and len(job_parts) == 3 and job_parts[:2] == ["api", "jobs"]:
+ self._require_csrf(normalized_headers)
+ if len(body) > 4096:
+ raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
+ payload = _strict_json(body)
+ if set(payload) != {"business_date"}:
+ raise PortalError("DAILY_DELETE_REQUEST_INVALID", "请确认要删除的日报日期")
+ day = validate_report_date(payload["business_date"])
+ job_id = validate_job_id(job_parts[2])
+ return Response.json(200, success(self._daily_reports.retire(job_id, day, current_session[0])))
if (
method == "GET"
and len(job_parts) == 4
@@ -887,11 +918,13 @@ class PortalApplication:
)
def _arr_download_snapshot(self, task: Optional[dict]) -> Optional[dict]:
- if task and task.get("status") == "needs_review" and task.get("job_id"):
+ if task and task.get("status") in {"needs_review", "succeeded"} and task.get("job_id"):
try:
status = self._repository.get_job_trace(task["job_id"])["job"]["status"]
except (PortalDataError, KeyError):
return task
+ if status == "deleted":
+ return {**task, "status": "deleted", "is_retired": True, "can_retry": False}
if status in {"accepted", "succeeded", "failed", "rejected", "cancelled"}:
return {**task, "status": "succeeded" if status in {"accepted", "succeeded"} else "failed", "can_retry": False}
return task
diff --git a/arr_web/daily_reports.py b/arr_web/daily_reports.py
new file mode 100644
index 0000000..555fb40
--- /dev/null
+++ b/arr_web/daily_reports.py
@@ -0,0 +1,27 @@
+"""Authenticated daily-report retirement, separate from read-only portal access."""
+from dataclasses import dataclass
+from datetime import date
+
+from arr_ingestion.contracts import IngestionError
+from arr_web.contracts import PortalError
+
+
+class UnavailableDailyReports:
+ def retire(self, job_id, business_date, actor):
+ raise PortalError("DAILY_RETIREMENT_UNAVAILABLE", "日报删除服务暂不可用", 503)
+
+
+@dataclass
+class ProgramDailyReports:
+ repository: object
+
+ def retire(self, job_id, business_date, actor):
+ try:
+ result = self.repository.retire_daily_job(job_id, date.fromisoformat(business_date), actor)
+ except IngestionError as error:
+ status = 404 if error.code == "DAILY_JOB_NOT_FOUND" else (
+ 409 if error.code in {"DAILY_DATE_CONFLICT", "DAILY_JOB_BUSY", "REVIEW_STATE_INVALID",
+ "JOB_RETIRED", "DAILY_RETIREMENT_CONFLICT"} else 503)
+ raise PortalError(error.code, "日报记录已变化,请刷新后重试" if status == 409 else
+ "日报删除未完成,请稍后重试", status) from None
+ return {**result, "status": "deleted", "removed_from_month": result["current_removed"]}
diff --git a/arr_web/local_ohip.py b/arr_web/local_ohip.py
index a475993..c778def 100644
--- a/arr_web/local_ohip.py
+++ b/arr_web/local_ohip.py
@@ -224,7 +224,7 @@ def open_portal(root, port):
load_key(credential_file)
access = AccessState(root)
with job_lock(root):
- database = ReplayDatabase(root, schema_version=21)
+ database = ReplayDatabase(root, schema_version=22)
try:
database.start()
def factory(*, root, snapshot, **dependencies):
diff --git a/arr_web/local_replay.py b/arr_web/local_replay.py
index ca90fef..ff3b657 100644
--- a/arr_web/local_replay.py
+++ b/arr_web/local_replay.py
@@ -22,6 +22,7 @@ from arr_web.app import PortalApplication, RuntimeHealth
from arr_web.arr_downloads import PersistentARRDownloads
from arr_web.auth import LoginCredentials
from arr_web.downloads import ManagedObjectArtifactReader
+from arr_web.daily_reports import ProgramDailyReports
from arr_web.local_replay_database import ReplayDatabase
from arr_web.local_xml_replay import (PROJECT, LocalReplayPortal, NativeXMLReplayExecutor, NativeXMLSnapshot,
SourceDateDownloads, document)
@@ -81,6 +82,7 @@ class ReplayRuntime:
monthly_repository, service)
self.portal_repository = PostgresPortalRepository("local-replay", connect=database.connect)
app = PortalApplication(repository=self.portal_repository, price_reviews=review,
+ daily_reports=ProgramDailyReports(self.repository),
upload=review if enable_upload else None,
artifact_reader=ManagedObjectArtifactReader(self.store),
credentials=LoginCredentials(**document(root / "login.json")),
@@ -132,7 +134,7 @@ def open_instance(root: Path, port: int, *, executor_factory=None, portal_type=L
"processor_version": policy.processor_version, "rule_set_sha256": policy.rule_set_sha256}
if fingerprint(document(root / "instance.json")) != fingerprint(expected):
raise ValueError("replay_instance_identity_or_policy_changed")
- database = ReplayDatabase(root)
+ database = ReplayDatabase(root, schema_version=22)
try:
database.start()
runtime = ReplayRuntime(root, database, snapshot, policy, port,
diff --git a/arr_web/local_replay_database.py b/arr_web/local_replay_database.py
index 68e262e..c122fa1 100644
--- a/arr_web/local_replay_database.py
+++ b/arr_web/local_replay_database.py
@@ -15,7 +15,7 @@ from integrations.ohip.capture_job import atomic_json, private_directory
class ReplayDatabase:
def __init__(self, root: Path, *, schema_version: int = 21):
- if schema_version not in (18, 19, 20, 21):
+ if schema_version not in (18, 19, 20, 21, 22):
raise ValueError("unsupported_local_schema")
self.schema_version = schema_version
self.root = root
diff --git a/arr_web/local_xml_replay.py b/arr_web/local_xml_replay.py
index 2987d3a..d49906d 100644
--- a/arr_web/local_xml_replay.py
+++ b/arr_web/local_xml_replay.py
@@ -178,7 +178,8 @@ class LocalReplayPortal:
allowed = (self.allow_xml_upload and path == "/api/jobs") or path in {"/api/login", "/api/logout", "/api/arr-downloads"} or bool(
re.fullmatch(r"/api/arr-downloads/[a-f0-9]{32}/retry", path)) or bool(
re.fullmatch(r"/api/arr-downloads/[a-f0-9]{32}/data-review/(?:items/[^/]+|finalize)", path)) or bool(
- re.fullmatch(r"/api/jobs/[^/]+/review/(?:items/[0-9]+|finalize|cancel)", path))
+ re.fullmatch(r"/api/jobs/[^/]+/review/(?:items/[0-9]+|finalize|cancel)", path)) or bool(
+ method == "DELETE" and re.fullmatch(r"/api/jobs/[^/]+", path))
if method != "GET" and not allowed:
return self._error("REPLAY_ROUTE_DISABLED", 405)
cookie = SimpleCookie()
diff --git a/arr_web/preview.py b/arr_web/preview.py
index a8e87b4..5df758d 100644
--- a/arr_web/preview.py
+++ b/arr_web/preview.py
@@ -14,6 +14,15 @@ from arr_web.server import serve
class EmptyPreviewRepository(UnavailablePortalRepository):
+ def list_daily_reports(self, month_key, limit=50, offset=0):
+ return [], 0
+
+ def get_daily_report(self, business_date):
+ return {"business_date": business_date, "current_job": None, "latest_job": None, "history_count": 0}
+
+ def list_daily_report_history(self, business_date, limit=50, offset=0):
+ return [], 0
+
def list_jobs(self, month_key, limit=50, offset=0):
return [], 0
diff --git a/arr_web/processing_runtime.py b/arr_web/processing_runtime.py
index b8f4928..d095592 100644
--- a/arr_web/processing_runtime.py
+++ b/arr_web/processing_runtime.py
@@ -81,6 +81,7 @@ def compose_programmatic_processing(
)
repository.assert_cancelled_scope_schema()
repository.assert_pm_scope_schema()
+ repository.assert_daily_lifecycle_schema()
policy = load_processor_policy(project_root)
ingestion = IngestionService(DeliveryValidator(object_store, policy), repository)
processor = LocalDailyProcessor(policy)
diff --git a/arr_web/repository.py b/arr_web/repository.py
index d7a441e..85897f9 100644
--- a/arr_web/repository.py
+++ b/arr_web/repository.py
@@ -27,6 +27,15 @@ class PortalDataError(RuntimeError):
class PortalRepository(Protocol):
+ def list_daily_reports(self, month_key: str, limit: int = 50, offset: int = 0):
+ ...
+
+ def get_daily_report(self, business_date: str):
+ ...
+
+ def list_daily_report_history(self, business_date: str, limit: int = 50, offset: int = 0):
+ ...
+
def list_jobs(
self,
month_key: str,
@@ -78,6 +87,15 @@ class UnavailablePortalRepository:
def _raise() -> None:
raise PortalDataError("DATABASE_UNAVAILABLE", "数据库读取服务暂不可用")
+ def list_daily_reports(self, month_key, limit=50, offset=0):
+ self._raise()
+
+ def get_daily_report(self, business_date):
+ self._raise()
+
+ def list_daily_report_history(self, business_date, limit=50, offset=0):
+ self._raise()
+
def list_jobs(
self,
month_key: str,
@@ -225,6 +243,61 @@ WHERE run.pipeline_type = 'opera_daily'
)
""".strip()
+# Date-level views deliberately leave the original task-log API intact.
+DAILY_REPORT_JOBS_SQL = """
+SELECT run.run_key,
+ CASE run.run_status WHEN 'accepted' THEN 'succeeded'
+ WHEN 'awaiting_review' THEN 'needs_review' WHEN 'rejected' THEN 'failed'
+ ELSE run.run_status END,
+ run.business_date, run.failure_code, run.created_at, run.updated_at,
+ run.finished_at, run.uploaded_filename, source.byte_size,
+ version.retained_rows, version.version_no, daily.sha256,
+ coalesce(rooms.total, 0), review.case_key, review.case_status,
+ review.revision, coalesce(review.completed, 0), coalesce(review.total, 0),
+ version.id, current_version.daily_version_id IS NOT NULL,
+ retirement.id IS NOT NULL
+FROM ingestion.processing_runs AS run
+JOIN ingestion.artifacts AS source ON source.id = run.source_artifact_id
+LEFT JOIN finance.daily_versions AS version ON version.processing_run_id = run.id
+LEFT JOIN finance.current_daily_versions AS current_version
+ ON current_version.daily_version_id = version.id
+LEFT JOIN ingestion.daily_run_retirements AS retirement ON retirement.processing_run_id = run.id
+LEFT JOIN ingestion.artifacts AS daily ON daily.id = version.daily_report_artifact_id
+LEFT JOIN LATERAL (
+ SELECT sum(record.no_of_rooms) AS total FROM finance.daily_records AS record
+ WHERE record.daily_version_id = version.id AND record.outcome = 'retained'
+) AS rooms ON true
+LEFT JOIN LATERAL (
+ SELECT case_row.case_key, case_row.case_status, case_row.revision,
+ count(item.id) AS total,
+ count(item.id) FILTER (WHERE item.real_price IS NOT NULL) AS completed
+ FROM ingestion.daily_review_cases AS case_row
+ LEFT JOIN ingestion.daily_review_items AS item ON item.review_case_id = case_row.id
+ WHERE case_row.processing_run_id = run.id
+ GROUP BY case_row.case_key, case_row.case_status, case_row.revision
+) AS review ON true
+WHERE run.pipeline_type = 'opera_daily' AND run.business_date = ANY(%s)
+ORDER BY run.created_at DESC, run.id DESC
+""".strip()
+
+DAILY_REPORT_DATES_SQL = """
+SELECT DISTINCT run.business_date
+FROM ingestion.processing_runs AS run
+LEFT JOIN ingestion.daily_run_retirements AS retirement ON retirement.processing_run_id = run.id
+WHERE run.pipeline_type = 'opera_daily' AND run.business_date >= %s AND run.business_date < %s
+ AND retirement.id IS NULL
+ORDER BY run.business_date DESC
+LIMIT %s OFFSET %s
+""".strip()
+
+DAILY_REPORT_COUNT_SQL = """
+SELECT count(DISTINCT run.business_date)
+FROM ingestion.processing_runs AS run
+LEFT JOIN ingestion.daily_run_retirements AS retirement ON retirement.processing_run_id = run.id
+WHERE run.pipeline_type = 'opera_daily' AND run.business_date >= %s AND run.business_date < %s
+ AND retirement.id IS NULL
+""".strip()
+
JOB_TRACE_RUN_SQL = """
SELECT
@@ -439,7 +512,7 @@ SELECT
FROM reporting.monthly_runs AS run
LEFT JOIN ingestion.artifacts AS artifact
ON artifact.id = run.workbook_artifact_id
-WHERE run.period_start = %s
+WHERE run.period_start = %s AND run.report_status <> 'withdrawn'
ORDER BY run.version_no DESC, run.id DESC
LIMIT %s OFFSET %s
""".strip()
@@ -448,7 +521,7 @@ LIMIT %s OFFSET %s
MONTHLY_RUNS_COUNT_SQL = """
SELECT count(*)
FROM reporting.monthly_runs AS run
-WHERE run.period_start = %s
+WHERE run.period_start = %s AND run.report_status <> 'withdrawn'
""".strip()
@@ -471,6 +544,7 @@ WITH daily AS (
monthly AS (
SELECT run.period_start, count(*) AS record_count
FROM reporting.monthly_runs AS run
+ WHERE run.report_status <> 'withdrawn'
GROUP BY run.period_start
)
SELECT
@@ -594,8 +668,17 @@ class PostgresPortalRepository:
(start, end, start, end, limit, offset),
)
rows = list(cursor.fetchall())
- return [
- {
+ return [self._job_from_row(row) for row in rows], total
+ except PortalDataError:
+ raise
+ except Exception:
+ raise PortalDataError("DATABASE_QUERY_FAILED", "日报记录查询失败") from None
+ finally:
+ connection.close()
+
+ @staticmethod
+ def _job_from_row(row):
+ return {
"job_id": str(row[0]),
"status": str(row[1]),
"arrival_date": _iso(row[2]),
@@ -615,15 +698,97 @@ class PostgresPortalRepository:
"review_completed_items": int(row[16] or 0),
"review_total_items": int(row[17] or 0),
}
- for row in rows
- ], total
+
+ @classmethod
+ def _report_job(cls, row):
+ job = cls._job_from_row(row)
+ retired = bool(row[20])
+ job.update(daily_version_id=int(row[18]) if row[18] is not None else None,
+ is_current=bool(row[19]) and not retired, is_retired=retired,
+ can_delete=not retired and job["status"] in {"succeeded", "failed", "cancelled", "needs_review"}
+ and job["review_status"] not in {"generating", "generation_processing"})
+ if retired:
+ job["status"] = "deleted"
+ job["daily_report_sha256"] = None
+ return job
+
+ @staticmethod
+ def _date_group(day, jobs):
+ active = [job for job in jobs if not job["is_retired"]]
+ return {"business_date": day.isoformat(),
+ "current_job": next((job for job in active if job["is_current"]), None),
+ "latest_job": active[0] if active else None,
+ "history_count": max(0, len(jobs) - 1)}
+
+ def list_daily_reports(self, month_key, limit=50, offset=0):
+ start, end = self._month_bounds(month_key)
+ connection = self._open()
+ try:
+ with connection.transaction():
+ with connection.cursor() as cursor:
+ self._begin(cursor)
+ cursor.execute(DAILY_REPORT_COUNT_SQL, (start, end))
+ total = int(cursor.fetchone()[0])
+ cursor.execute(DAILY_REPORT_DATES_SQL, (start, end, limit, offset))
+ days = [row[0] for row in cursor.fetchall()]
+ if not days:
+ return [], total
+ cursor.execute(DAILY_REPORT_JOBS_SQL, (days,))
+ jobs = [self._report_job(row) for row in cursor.fetchall()]
+ return [self._date_group(day, [job for job in jobs if job["arrival_date"] == day.isoformat()])
+ for day in days], total
except PortalDataError:
raise
except Exception:
- raise PortalDataError("DATABASE_QUERY_FAILED", "日报记录查询失败") from None
+ raise PortalDataError("DATABASE_QUERY_FAILED", "日报日期查询失败") from None
finally:
connection.close()
+ def get_daily_report(self, business_date):
+ day = date.fromisoformat(business_date)
+ connection = self._open()
+ try:
+ with connection.transaction():
+ with connection.cursor() as cursor:
+ self._begin(cursor)
+ cursor.execute(DAILY_REPORT_JOBS_SQL, ([day],))
+ jobs = [self._report_job(row) for row in cursor.fetchall()]
+ return self._date_group(day, jobs)
+ except PortalDataError:
+ raise
+ except Exception:
+ raise PortalDataError("DATABASE_QUERY_FAILED", "日报日期查询失败") from None
+ finally:
+ connection.close()
+
+ def list_daily_report_history(self, business_date, limit=50, offset=0):
+ day = date.fromisoformat(business_date)
+ connection = self._open()
+ try:
+ with connection.transaction():
+ with connection.cursor() as cursor:
+ self._begin(cursor)
+ cursor.execute(DAILY_REPORT_JOBS_SQL, ([day],))
+ jobs = [self._report_job(row) for row in cursor.fetchall()]
+ return jobs[offset:offset + limit], len(jobs)
+ except PortalDataError:
+ raise
+ except Exception:
+ raise PortalDataError("DATABASE_QUERY_FAILED", "日报历史查询失败") from None
+ finally:
+ connection.close()
+
+ @staticmethod
+ def _is_retired(cursor, job_id):
+ # Old, explicitly supported read-only deployments may predate migration022.
+ cursor.execute("SELECT to_regclass('ingestion.daily_run_retirements')")
+ if cursor.fetchone()[0] is None:
+ return False
+ cursor.execute("""SELECT 1 FROM ingestion.daily_run_retirements AS retirement
+ JOIN ingestion.processing_runs AS run ON run.id = retirement.processing_run_id
+ WHERE run.run_key = %s""", (job_id,))
+ return cursor.fetchone() is not None
+
def get_job_trace(self, job_id: str) -> Dict[str, Any]:
connection = self._open()
try:
@@ -664,6 +829,7 @@ class PostgresPortalRepository:
),
"result_schema_version": str(row[19]) if row[19] else None,
}
+ retired = self._is_retired(cursor, job_id)
run_id = int(row[0])
cursor.execute(JOB_TRACE_ATTEMPTS_SQL, (run_id,))
@@ -815,7 +981,7 @@ class PostgresPortalRepository:
}
for item in cursor.fetchall()
]
- return build_job_trace(
+ trace = build_job_trace(
run,
attempts=attempts,
deliveries=deliveries,
@@ -825,6 +991,9 @@ class PostgresPortalRepository:
review_case=review_case,
review_events=review_events,
)
+ if retired:
+ trace["job"].update(status="deleted", is_retired=True, active=False)
+ return trace
except PortalDataError:
raise
except Exception:
@@ -940,6 +1109,8 @@ class PostgresPortalRepository:
with connection.transaction():
with connection.cursor() as cursor:
self._begin(cursor)
+ if statement == DAILY_DOWNLOAD_SQL and self._is_retired(cursor, str(value)):
+ raise PortalDataError("DOWNLOAD_NOT_FOUND", "该日报已删除")
cursor.execute(statement, (value,))
rows = list(cursor.fetchall())
if len(rows) != 1:
diff --git a/arr_web/run.py b/arr_web/run.py
index 8b4afd7..afcbfe5 100644
--- a/arr_web/run.py
+++ b/arr_web/run.py
@@ -32,6 +32,7 @@ from arr_web.processing_runtime import (
)
from arr_web.server import serve
from arr_web.services import ProgramMonthlyCoordinator
+from arr_web.daily_reports import ProgramDailyReports
from monthly_reports.publishing import OpenpyxlWorkbookBuilder, AtomicReportPublisher
from monthly_reports.repository import DatabaseConfig, PostgresReportRepository
from monthly_reports.service import MonthlyReportService
@@ -257,6 +258,7 @@ def _run(args, login_credentials, resources: ExitStack, *, arr_source: CapturedA
resources.callback(arr_downloads.close, wait=True)
application = PortalApplication(
repository=repository,
+ daily_reports=ProgramDailyReports(processing_input.repository) if processing_input is not None else None,
arr_downloads=arr_downloads,
upload=(
processing_input.coordinator if processing_input is not None else None
diff --git a/arr_web/static/app.js b/arr_web/static/app.js
index 951d6e3..d41e3d0 100644
--- a/arr_web/static/app.js
+++ b/arr_web/static/app.js
@@ -54,6 +54,18 @@
biPollLoading: false,
toastTimer: null,
jobs: [],
+ dailyReports: [],
+ dailyOverviewDate: "",
+ dailyOverview: null,
+ dailyOverviewTimer: null,
+ dailyOverviewRequest: 0,
+ dailyHistoryDate: "",
+ dailyHistoryItems: [],
+ dailyHistoryTotal: 0,
+ dailyHistoryOffset: 0,
+ dailyHistoryLoading: false,
+ dailyDeleteRequest: null,
+ dailyDeleting: false,
selectedJobId: "",
jobTrace: null,
jobTraceLoading: false,
@@ -119,6 +131,7 @@
rejected: ["status.validation_failed", "failed"],
failed: ["status.failed", "failed"],
cancelled: ["status.cancelled", "failed"],
+ deleted: ["daily.deleted", ""],
};
const reportStatus = {
reserved: ["status.generating", "running"],
@@ -522,16 +535,18 @@
feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) || rejected ? " is-error" : ""}`;
const taskError = !task?.job_id && status === "failed" && task.error_code === "ARR_SOURCE_FETCH_FAILED" ? "source_failed"
: !task?.job_id && status === "interrupted" && task.error_code === "ARR_DOWNLOAD_INTERRUPTED" ? "source_interrupted" : status;
- const text = rejected
+ const viewingAnotherDate = task && task.report_date !== dateInput.value && !arrDownloadActive() && !reconciling;
+ const text = viewingAnotherDate ? I18N.t("arr_download.ready") : rejected
? I18N.t("arr_download.submission_rejected", { reason: task.submission_error })
: I18N.t(`arr_download.${taskError}`);
- const taskDate = task?.report_date || state.arrDownloadIntent?.report_date;
+ const taskDate = viewingAnotherDate ? dateInput.value : task?.report_date || state.arrDownloadIntent?.report_date;
$("#arr-download-status").textContent = taskDate ? `${taskDate} · ${text}` : text;
- $("#arr-download-review").hidden = task?.status !== "needs_review" || !task.job_id;
- $("#arr-download-data-review").hidden = !arrDownloadNeedsDataReview();
+ $("#arr-download-review").hidden = viewingAnotherDate || task?.status !== "needs_review" || !task.job_id;
+ $("#arr-download-data-review").hidden = !selectedARRDateNeedsDataReview();
$("#arr-download-data-review").disabled = locked;
renderARRAcquisitionProgress(task);
renderARRPendingReviews();
+ syncDailyOverviewDate(dateInput.value);
}
function clearARRDownloadPoll() {
@@ -560,7 +575,12 @@
state.arrDownloadTask = task;
rememberARRPendingReview(task, { allowAdd: allowPendingAdd });
state.arrDownloadDisconnected = false;
- rememberARRIntent({ request_id: task.request_id, report_date: task.report_date });
+ rememberARRIntent(task.status === "deleted" ? null : { request_id: task.request_id, report_date: task.report_date });
+ if (task.status === "deleted" && state.dailyPriceReviewJobId === task.job_id) {
+ state.dailyPriceReview = null;
+ state.dailyPriceReviewJobId = "";
+ renderDailyPriceReview(null);
+ }
if (!state.arrDownloadDateEdited) $("#arr-download-date").value = task.report_date;
renderARRDownload();
if (arrDownloadNeedsDataReview()) {
@@ -576,7 +596,7 @@
state.jobsOffset = 0;
renderHistoryMonthControl("jobs");
await loadJobs(false, false);
- await selectJob(task.job_id);
+ await selectJob(task.job_id, { syncDate: !state.arrDownloadDateEdited });
state.arrDownloadSynced = signature;
if (task.status === "succeeded" && $("#panel-monthly").classList.contains("is-active")) await loadMonthly(false, true);
}
@@ -627,6 +647,7 @@
}
const storageKey = `arr:last-download:v2:${encodeURIComponent(config.context_id)}:${encodeURIComponent(state.arrDownloadUsername)}`;
if (state.arrDownloadStorageKey !== storageKey) {
+ const contextChanged = state.arrDownloadContextId && state.arrDownloadContextId !== config.context_id;
state.arrDownloadStorageKey = storageKey;
state.arrDownloadContextId = config.context_id;
state.arrDownloadRestored = false;
@@ -636,6 +657,19 @@
state.arrDownloadDisconnected = false;
state.arrDownloadSynced = "";
state.arrDownloadPendingReviews = [];
+ state.dailyOverview = null;
+ state.dailyOverviewDate = "";
+ state.dailyOverviewRequest += 1;
+ window.clearTimeout(state.dailyOverviewTimer);
+ if (contextChanged) {
+ state.dailyReports = [];
+ state.jobs = [];
+ state.dailyHistoryItems = [];
+ state.dailyHistoryTotal = 0;
+ state.dailyHistoryDate = "";
+ renderJobs();
+ renderDailyHistory();
+ }
state.manualReviewMode = "";
state.dailyPriceReview = null;
state.dailyPriceReviewJobId = "";
@@ -1285,40 +1319,280 @@
updateCompanyReportControls();
}
- function renderJobs(jobs) {
- const body = $("#jobs-body");
- if (!jobs.length) {
- body.innerHTML = `
| ${historyEmptyMarkup("jobs", "history.daily_empty", "暂无 Daily Report 记录")} |
`;
- $("#metric-arrival").textContent = "—";
- $("#metric-duration").textContent = "—";
- $("#metric-rooms").textContent = "—";
+ function dailyJobSource(job) {
+ return job?.filename === "ARR.json" ? I18N.t("arr_download.source_label") : job?.filename || "—";
+ }
+
+ function dailyJobStatus(job) {
+ if (!job) return `${escapeHtml(I18N.t("daily.no_current"))}`;
+ const completed = formatInteger(job.review_completed_items || 0);
+ const total = formatInteger(job.review_total_items || 0);
+ return job.status === "needs_review" && job.review_case_id
+ ? ``
+ : chip(job.status, jobStatus);
+ }
+
+ function dailyJobDownload(job) {
+ return state.health.download_ready && job?.status === "succeeded" && job.daily_report_sha256 && !job.is_retired
+ ? `${escapeHtml(I18N.t("common.download"))}`
+ : '—';
+ }
+
+ function dailyOverviewRow() {
+ return (state.dailyOverview?.business_date === state.dailyOverviewDate ? state.dailyOverview : null)
+ || state.dailyReports.find((day) => day.business_date === state.dailyOverviewDate);
+ }
+
+ function renderDailyOverview() {
+ const date = state.dailyOverviewDate;
+ const day = dailyOverviewRow();
+ const task = state.arrDownloadTask?.report_date === date ? state.arrDownloadTask : null;
+ const latest = day?.latest_job;
+ const current = day?.current_job;
+ const taskPending = task && ["queued", "downloading", "processing", "needs_data_review", "needs_review", "interrupted"].includes(task.status);
+ const replacement = latest && latest.job_id !== current?.job_id && !latest.is_retired;
+ const jobPending = replacement && !["succeeded", "accepted", "cancelled", "rejected", "failed"].includes(latest.status);
+ const failed = !taskPending && !jobPending && (task?.status === "failed" || (replacement && ["failed", "rejected"].includes(latest.status)));
+ const pending = Boolean(taskPending || jobPending);
+ const status = taskPending ? task.status : jobPending ? latest.status : "";
+ const review = ["needs_data_review", "needs_review"].includes(status);
+ let hint = I18N.t("daily.no_current");
+ if (pending) hint = I18N.t(review ? "daily.overview_review" : ["queued", "downloading"].includes(status) ? "daily.overview_fetching" : "daily.overview_processing");
+ else if (current) hint = I18N.t("daily.current");
+ else if (failed) hint = I18N.t("status.failed");
+ $("#metric-arrival").textContent = validARRDate(date) ? formatDate(date) : "—";
+ $("#metric-arrival-note").textContent = hint;
+ $("#metric-current-version").hidden = !current || (!pending && !failed);
+ $("#metric-current-version").textContent = I18N.t("daily.current_preserved");
+ const showResult = Boolean(current && !pending);
+ $("#metric-duration").textContent = showResult ? duration(current) : "—";
+ $("#metric-rooms").textContent = showResult ? formatInteger(current.no_of_rooms) : "—";
+ $("#metric-duration-note").textContent = I18N.t(showResult ? "metric.duration_from_submit" : "daily.after_generation");
+ $("#metric-rooms-note").textContent = I18N.t(showResult ? "metric.rooms" : "daily.after_generation");
+ }
+
+ function syncDailyOverviewDate(date) {
+ if (!validARRDate(date)) {
+ state.dailyOverviewDate = "";
+ state.dailyOverview = null;
+ state.dailyOverviewRequest += 1;
+ window.clearTimeout(state.dailyOverviewTimer);
+ renderDailyOverview();
return;
}
- body.innerHTML = jobs.map((job) => {
- const sourceLabel = job.filename === "ARR.json"
- ? (I18N?.t("arr_download.source_label") || "接口获取")
- : (job.filename || "—");
- const completedItems = formatInteger(job.review_completed_items || 0);
- const totalItems = formatInteger(job.review_total_items || 0);
- const statusMarkup = job.status === "needs_review" && job.review_case_id
- ? ``
- : chip(job.status, jobStatus);
- const canDownload = state.health.download_ready && job.status === "succeeded" && job.daily_report_sha256;
- return `
-
- | ${escapeHtml(sourceLabel)} |
- ${statusMarkup}${job.failure_code ? `${escapeHtml(job.failure_code)}` : ""} |
- ${escapeHtml(job.arrival_date ? formatDate(job.arrival_date) : "—")} |
- ${formatInteger(job.no_of_rooms)} |
- ${duration(job)} |
- ${formatDate(job.created_at, true)} |
- ${canDownload ? `${escapeHtml(I18N?.t("common.download") || "下载")}` : '—'} |
+ if (state.dailyOverviewDate !== date) {
+ state.dailyOverviewDate = date;
+ state.dailyOverview = null;
+ state.dailyOverviewRequest += 1;
+ window.clearTimeout(state.dailyOverviewTimer);
+ if (!state.dailyReports.some((day) => day.business_date === date)) {
+ state.dailyOverviewTimer = window.setTimeout(() => loadDailyOverview(date), 250);
+ }
+ renderJobs();
+ return;
+ }
+ renderDailyOverview();
+ }
+
+ async function loadDailyOverview(date = state.dailyOverviewDate) {
+ if (!validARRDate(date)) return;
+ const sequence = ++state.dailyOverviewRequest;
+ const context = state.arrDownloadContextId;
+ try {
+ const day = await api(`/api/daily-reports/${encodeURIComponent(date)}`);
+ if (state.dailyOverviewDate !== date || state.dailyOverviewRequest !== sequence || state.arrDownloadContextId !== context) return;
+ if (day?.business_date === date) state.dailyOverview = day;
+ renderDailyOverview();
+ } catch (_) { /* Do not substitute a different date's result when this day cannot be read. */ }
+ }
+
+ async function selectDailyReportDate(date) {
+ if (!validARRDate(date) || state.dailyDeleting || state.arrDownloadBusy || dailyPriceReviewHasUnsavedChanges()) {
+ if (dailyPriceReviewHasUnsavedChanges()) showToast(I18N.t("daily_review.save_before_switch"), true);
+ return;
+ }
+ $("#arr-download-date").value = date;
+ state.arrDownloadDateEdited = true;
+ renderARRDownload();
+ if (!dailyOverviewRow()) await loadDailyOverview(date);
+ }
+
+ function renderJobs() {
+ const body = $("#jobs-body");
+ if (!state.dailyReports.length) {
+ body.innerHTML = `
| ${historyEmptyMarkup("jobs", "history.daily_empty", "暂无 Daily Report 记录")} |
`;
+ renderDailyOverview();
+ return;
+ }
+ body.innerHTML = state.dailyReports.map((day) => {
+ const current = day.current_job;
+ const latest = day.latest_job;
+ const replacement = latest && latest.job_id !== current?.job_id && !latest.is_retired ? latest : null;
+ const primary = current || replacement;
+ const historicalOnly = !current && latest && ["succeeded", "accepted"].includes(latest.status);
+ const date = day.business_date;
+ const deleteButton = primary?.can_delete
+ ? `` : "";
+ const pendingMarkup = current && replacement
+ ? `${escapeHtml(I18N.t("daily.replacement"))}${dailyJobStatus(replacement)}
` : "";
+ return `
+ | ${escapeHtml(dailyJobSource(primary))}${current || historicalOnly ? `${escapeHtml(I18N.t(current ? "daily.current" : "daily.history_only"))}` : ""} |
+ ${historicalOnly ? `${escapeHtml(I18N.t("daily.no_current"))}` : dailyJobStatus(primary)}${pendingMarkup} |
+ |
+ ${current ? formatInteger(current.no_of_rooms) : "—"} |
+ ${current ? duration(current) : "—"} |
+ ${primary?.created_at ? formatDate(primary.created_at, true) : "—"} |
+ ${dailyJobDownload(current)}${deleteButton} |
`;
}).join("");
- const latest = jobs.find((job) => job.status === "succeeded");
- $("#metric-arrival").textContent = latest?.arrival_date ? formatDate(latest.arrival_date) : "—";
- $("#metric-duration").textContent = latest ? duration(latest) : "—";
- $("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—";
+ renderDailyOverview();
+ }
+
+ function renderDailyHistory() {
+ $("#daily-history-description").textContent = I18N.t("daily.history_date", { date: state.dailyHistoryDate });
+ const items = state.dailyHistoryItems;
+ $("#daily-history-body").innerHTML = items.length ? items.map((job) => {
+ const label = I18N.t(job.is_retired ? "daily.deleted" : job.is_current ? "daily.current" : "daily.historical");
+ const remove = job.can_delete && !job.is_retired
+ ? `` : "";
+ return `| ${escapeHtml(dailyJobSource(job))}${escapeHtml(label)} | ${job.is_retired ? `${escapeHtml(label)}` : dailyJobStatus(job)} | ${job.status === "succeeded" ? formatInteger(job.no_of_rooms) : "—"} | ${formatDate(job.created_at, true)} | ${dailyJobDownload(job)}${remove} |
`;
+ }).join("") : `| ${escapeHtml(I18N.t(state.dailyHistoryLoading ? "daily.loading" : "daily.history_empty"))} |
`;
+ const total = state.dailyHistoryTotal;
+ const offset = state.dailyHistoryOffset;
+ $("#daily-history-page-summary").textContent = I18N.t("pagination.summary", { total: formatInteger(total), start: formatInteger(total ? offset + 1 : 0), end: formatInteger(Math.min(total, offset + HISTORY_PAGE_SIZE)) });
+ $("#daily-history-prev").disabled = state.dailyHistoryLoading || state.dailyDeleting || offset === 0;
+ $("#daily-history-next").disabled = state.dailyHistoryLoading || state.dailyDeleting || offset + HISTORY_PAGE_SIZE >= total;
+ $("#daily-history-close").disabled = state.dailyDeleting;
+ }
+
+ async function loadDailyHistory(date = state.dailyHistoryDate) {
+ if (!validARRDate(date) || state.dailyHistoryLoading) return;
+ state.dailyHistoryDate = date;
+ state.dailyHistoryLoading = true;
+ renderDailyHistory();
+ const context = state.arrDownloadContextId;
+ const offset = state.dailyHistoryOffset;
+ try {
+ const page = readPage(await api(`/api/daily-reports/${encodeURIComponent(date)}/history?limit=${HISTORY_PAGE_SIZE}&offset=${offset}`, { returnEnvelope: true }));
+ if (state.dailyHistoryDate !== date || state.arrDownloadContextId !== context) return;
+ state.dailyHistoryItems = page.items;
+ state.dailyHistoryTotal = page.total;
+ $("#daily-history-error").hidden = true;
+ } catch (error) {
+ if (state.dailyHistoryDate !== date || state.arrDownloadContextId !== context) return;
+ $("#daily-history-error").textContent = error.message;
+ $("#daily-history-error").hidden = false;
+ } finally {
+ state.dailyHistoryLoading = false;
+ renderDailyHistory();
+ }
+ }
+
+ async function openDailyHistory(date, trigger) {
+ if (!validARRDate(date) || state.dailyDeleting || state.dailyHistoryLoading) return;
+ state.dailyHistoryDate = date;
+ state.dailyHistoryItems = [];
+ state.dailyHistoryOffset = 0;
+ state.dailyHistoryTotal = 0;
+ $("#daily-history-error").hidden = true;
+ const dialog = $("#daily-history-dialog");
+ dialog._returnFocus = trigger;
+ renderDailyHistory();
+ if (!dialog.open) {
+ if (typeof dialog.showModal === "function") dialog.showModal();
+ else dialog.setAttribute("open", "");
+ }
+ $("#daily-history-close").focus();
+ await loadDailyHistory(date);
+ }
+
+ function closeDailyHistory() {
+ if (state.dailyDeleting) return;
+ const dialog = $("#daily-history-dialog");
+ if (typeof dialog.close === "function") dialog.close();
+ else dialog.removeAttribute("open");
+ if (dialog._returnFocus?.isConnected) dialog._returnFocus.focus();
+ }
+
+ function openDailyDelete(button) {
+ if (dailyPriceReviewHasUnsavedChanges()) {
+ showToast(I18N.t("daily_review.save_before_switch"), true);
+ return;
+ }
+ if (state.dailyDeleting || state.dailyPriceReviewMutating || state.arrDataReviewMutating) return;
+ const jobId = button.dataset.dailyDelete;
+ const date = button.dataset.dailyDate;
+ const current = button.dataset.dailyCurrent === "true";
+ if (!jobId || !validARRDate(date)) return;
+ state.dailyDeleteRequest = { jobId, date, current, trigger: button };
+ $("#daily-delete-description").textContent = I18N.t(current ? "daily.delete_current_description" : "daily.delete_history_description", { date });
+ $("#daily-delete-note").textContent = I18N.t("daily.delete_note");
+ $("#daily-delete-error").hidden = true;
+ const dialog = $("#daily-delete-dialog");
+ if (typeof dialog.showModal === "function") dialog.showModal();
+ else dialog.setAttribute("open", "");
+ $("#daily-delete-cancel").focus();
+ }
+
+ function closeDailyDelete(force = false) {
+ if (state.dailyDeleting && !force) return;
+ const request = state.dailyDeleteRequest;
+ state.dailyDeleteRequest = null;
+ const dialog = $("#daily-delete-dialog");
+ if (typeof dialog.close === "function") dialog.close();
+ else dialog.removeAttribute("open");
+ if (request?.trigger?.isConnected) request.trigger.focus();
+ }
+
+ async function confirmDailyDelete() {
+ const request = state.dailyDeleteRequest;
+ if (!request || state.dailyDeleting) return;
+ state.dailyDeleting = true;
+ $("#daily-delete-dialog").setAttribute("aria-busy", "true");
+ $("#daily-delete-cancel").disabled = true;
+ $("#daily-delete-submit").disabled = true;
+ $("#daily-delete-error").hidden = true;
+ renderJobs();
+ renderDailyHistory();
+ let deleted = false;
+ try {
+ const result = await api(`/api/jobs/${encodeURIComponent(request.jobId)}`, {
+ method: "DELETE", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ business_date: request.date }),
+ });
+ deleted = true;
+ // Once deletion succeeds, never present a later refresh failure as a failed deletion.
+ if (state.dailyPriceReviewJobId === request.jobId) {
+ state.dailyPriceReview = null;
+ state.dailyPriceReviewJobId = "";
+ renderDailyPriceReview(null);
+ }
+ state.arrDownloadPendingReviews = state.arrDownloadPendingReviews.filter((task) => task.job_id !== request.jobId);
+ if (state.arrDownloadTask?.job_id === request.jobId) {
+ await acceptARRDownloadTask({ ...state.arrDownloadTask, status: "deleted", can_retry: false, is_retired: true }, { sync: false });
+ }
+ closeDailyDelete(true);
+ showToast(I18N.t(result.current_removed ? "daily.deleted_current" : "daily.deleted_history"));
+ await Promise.allSettled([loadJobs(false, false), loadDailyOverview(state.dailyOverviewDate), loadHistoryMonths()]);
+ if ($("#daily-history-dialog").open) await loadDailyHistory(request.date);
+ if ($("#panel-monthly").classList.contains("is-active")) await loadMonthly(false, true);
+ if ($("#panel-bi").classList.contains("is-active")) await loadMonthsAndAnalytics();
+ } catch (error) {
+ $("#daily-delete-error").textContent = error.message;
+ $("#daily-delete-error").hidden = false;
+ } finally {
+ state.dailyDeleting = false;
+ $("#daily-delete-dialog").setAttribute("aria-busy", "false");
+ $("#daily-delete-cancel").disabled = false;
+ $("#daily-delete-submit").disabled = false;
+ renderJobs();
+ renderDailyHistory();
+ if (deleted) {
+ const focusTarget = $("#daily-history-dialog").open ? $("#daily-history-close")
+ : $(`[data-daily-date-select="${request.date}"]`) || $("#refresh-jobs");
+ focusTarget.focus();
+ }
+ }
}
function reviewPrice(value) {
@@ -1525,6 +1799,11 @@
}
setDailyPriceReviewMessage();
await loadDailyPriceReview(jobId, { reset: jobId !== state.dailyPriceReviewJobId, scroll });
+ if (state.dailyPriceReviewJobId === jobId && validARRDate(state.dailyPriceReview?.business_date)) {
+ $("#arr-download-date").value = state.dailyPriceReview.business_date;
+ state.arrDownloadDateEdited = true;
+ renderARRDownload();
+ }
}
async function saveDailyPriceReviewItem(button) {
@@ -1924,7 +2203,7 @@
}
}
- async function selectJob(jobId) {
+ async function selectJob(jobId, { syncDate = true } = {}) {
if (!jobId) return;
clearTracePoll();
const changed = state.selectedJobId !== jobId;
@@ -1932,30 +2211,47 @@
if (changed) state.jobTrace = null;
renderJobs(state.jobs);
await loadJobTrace(true, !changed);
+ const date = state.jobs.find((job) => job.job_id === jobId)?.arrival_date
+ || (state.jobTrace?.job?.job_id === jobId ? state.jobTrace.job.business_date : "");
+ if (syncDate && state.selectedJobId === jobId && validARRDate(date)) {
+ $("#arr-download-date").value = date;
+ renderARRDownload();
+ }
}
async function loadJobs(showErrors = false, loadTrace = true) {
if (state.jobsLoading) return;
state.jobsLoading = true;
+ const requestedMonth = historyMonth("jobs");
+ const context = state.arrDownloadContextId;
+ let requestedOffset = state.jobsOffset;
+ const isCurrent = () => historyMonth("jobs") === requestedMonth
+ && state.arrDownloadContextId === context && state.jobsOffset === requestedOffset;
renderPagination("jobs", state.jobsTotal, state.jobsOffset, true);
try {
- const month = encodeURIComponent(historyMonth("jobs"));
+ const month = encodeURIComponent(requestedMonth);
let page = readPage(await api(
- `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
+ `/api/daily-reports?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${requestedOffset}`,
{ returnEnvelope: true },
));
+ if (!isCurrent()) return;
if (!page.items.length && page.total > 0 && state.jobsOffset >= page.total) {
state.jobsOffset = pageOffset(page.total, state.jobsOffset, -1);
+ requestedOffset = state.jobsOffset;
page = readPage(await api(
- `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
+ `/api/daily-reports?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${requestedOffset}`,
{ returnEnvelope: true },
));
+ if (!isCurrent()) return;
}
- state.jobs = page.items;
+ state.dailyReports = page.items;
+ if (page.items.some((day) => day.business_date === state.dailyOverviewDate)) state.dailyOverview = null;
+ state.jobs = [...new Map(page.items.flatMap((day) => [day.current_job, day.latest_job])
+ .filter(Boolean).map((job) => [job.job_id, job])).values()];
state.jobsTotal = page.total;
renderJobs(state.jobs);
if (!state.selectedJobId && state.jobs.length) {
- await selectJob(state.jobs[0].job_id);
+ await selectJob(state.jobs[0].job_id, { syncDate: false });
} else if (loadTrace && state.selectedJobId) {
await loadJobTrace(showErrors, Boolean(state.jobTrace));
} else if (!state.jobs.length && !state.selectedJobId) {
@@ -1966,11 +2262,13 @@
setTraceLiveState(I18N?.t("task.live_unselected") || "未选择任务");
}
} catch (error) {
+ if (!isCurrent()) return;
$("#jobs-body").innerHTML = `| ${escapeHtml(I18N?.t("daily.unavailable") || "日报记录暂时无法读取")} |
`;
if (showErrors) showToast(error.message, true);
} finally {
state.jobsLoading = false;
renderPagination("jobs", state.jobsTotal, state.jobsOffset);
+ if (!isCurrent()) await loadJobs(false, false);
}
}
@@ -3341,6 +3639,11 @@
renderARRDataReview();
renderARRCalendar();
renderJobs(state.jobs);
+ renderDailyHistory();
+ if (state.dailyDeleteRequest) {
+ $("#daily-delete-description").textContent = I18N.t(state.dailyDeleteRequest.current ? "daily.delete_current_description" : "daily.delete_history_description", { date: state.dailyDeleteRequest.date });
+ $("#daily-delete-note").textContent = I18N.t("daily.delete_note");
+ }
renderPagination("jobs", state.jobsTotal, state.jobsOffset, state.jobsLoading);
renderMonthly(state.monthlyRuns);
renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, state.monthlyLoading);
@@ -3553,12 +3856,56 @@
changeHistoryPage("companyReportsTotal", "companyReportsOffset", 1, () => loadCompanyReportHistory(false))
);
$("#jobs-body").addEventListener("click", (event) => {
+ const dateTrigger = event.target.closest("[data-daily-date-select]");
+ if (dateTrigger) { selectDailyReportDate(dateTrigger.dataset.dailyDateSelect); return; }
+ const historyTrigger = event.target.closest("[data-daily-history]");
+ if (historyTrigger) { openDailyHistory(historyTrigger.dataset.dailyHistory, historyTrigger); return; }
+ const deleteTrigger = event.target.closest("[data-daily-delete]");
+ if (deleteTrigger) { openDailyDelete(deleteTrigger); return; }
const reviewTrigger = event.target.closest("[data-open-price-review]");
if (reviewTrigger) {
event.preventDefault();
openDailyPriceReview(reviewTrigger.dataset.openPriceReview, true);
}
});
+ $("#daily-history-body").addEventListener("click", async (event) => {
+ const deleteTrigger = event.target.closest("[data-daily-delete]");
+ if (deleteTrigger) { openDailyDelete(deleteTrigger); return; }
+ const reviewTrigger = event.target.closest("[data-open-price-review]");
+ if (reviewTrigger) {
+ closeDailyHistory();
+ await selectDailyReportDate(state.dailyHistoryDate);
+ await openDailyPriceReview(reviewTrigger.dataset.openPriceReview, true);
+ return;
+ }
+ const logTrigger = event.target.closest("[data-daily-job-log]");
+ if (logTrigger) {
+ closeDailyHistory();
+ await selectJob(logTrigger.dataset.dailyJobLog);
+ openTaskLog();
+ }
+ });
+ $("#daily-history-close").addEventListener("click", closeDailyHistory);
+ $("#daily-history-dialog").addEventListener("cancel", (event) => {
+ event.preventDefault();
+ closeDailyHistory();
+ });
+ $("#daily-history-prev").addEventListener("click", async () => {
+ if (state.dailyHistoryLoading || state.dailyDeleting) return;
+ state.dailyHistoryOffset = Math.max(0, state.dailyHistoryOffset - HISTORY_PAGE_SIZE);
+ await loadDailyHistory();
+ });
+ $("#daily-history-next").addEventListener("click", async () => {
+ if (state.dailyHistoryLoading || state.dailyDeleting) return;
+ state.dailyHistoryOffset += HISTORY_PAGE_SIZE;
+ await loadDailyHistory();
+ });
+ $("#daily-delete-cancel").addEventListener("click", () => closeDailyDelete());
+ $("#daily-delete-submit").addEventListener("click", confirmDailyDelete);
+ $("#daily-delete-dialog").addEventListener("cancel", (event) => {
+ event.preventDefault();
+ closeDailyDelete();
+ });
$("#daily-price-review-body").addEventListener("click", (event) => {
const save = event.target.closest("[data-daily-price-review-save]");
if (save) saveDailyPriceReviewItem(save);
diff --git a/arr_web/static/i18n.js b/arr_web/static/i18n.js
index 54a44e1..c698bf5 100644
--- a/arr_web/static/i18n.js
+++ b/arr_web/static/i18n.js
@@ -250,10 +250,41 @@
"upload.stage_processor": ["正在运行固定处理器", "Running the fixed processor", "กำลังเรียกใช้ตัวประมวลผล"],
"upload.stage_validate": ["正在独立验收结果", "Independently validating the result", "กำลังตรวจสอบผลอย่างอิสระ"],
"upload.stage_commit": ["正在提交数据库", "Committing to the database", "กำลังบันทึกลงฐานข้อมูล"],
- "metric.arrival_date": ["最近成功处理日期", "Latest successful processing date", "วันที่ประมวลผลสำเร็จล่าสุด"],
+ "metric.arrival_date": ["当前查看日期", "Selected report date", "วันที่รายงานที่เลือก"],
"metric.duration": ["处理耗时", "Processing time", "เวลาประมวลผล"],
"metric.duration_from_submit": ["从提交至完成", "From submission to completion", "ตั้งแต่ส่งจนเสร็จ"],
"metric.rooms": ["保留记录的售出房数", "Sold rooms in retained records", "จำนวนห้องที่ขายในรายการที่เก็บไว้"],
+ "daily.current": ["当前有效日报", "Current daily report", "รายงานรายวันที่ใช้อยู่"],
+ "daily.no_current": ["无当前有效日报", "No current daily report", "ไม่มีรายงานรายวันที่ใช้อยู่"],
+ "daily.history_only": ["仅保留历史版本", "Historical versions only", "มีเฉพาะรุ่นในประวัติ"],
+ "daily.current_preserved": ["当前有效日报仍保留", "The current daily report remains valid", "รายงานรายวันปัจจุบันยังคงใช้ได้"],
+ "daily.overview_review": ["待人工核对", "Awaiting manual review", "รอการตรวจสอบด้วยตนเอง"],
+ "daily.overview_fetching": ["正在获取该日数据", "Fetching data for this date", "กำลังดึงข้อมูลของวันที่นี้"],
+ "daily.overview_processing": ["正在生成该日日报", "Generating this daily report", "กำลังสร้างรายงานรายวันของวันที่นี้"],
+ "daily.after_generation": ["待新日报生成后显示", "Shown after the new report is generated", "แสดงเมื่อสร้างรายงานใหม่แล้ว"],
+ "daily.replacement": ["新版本", "New version", "รุ่นใหม่"],
+ "daily.history_versions": ["历史记录({count})", "History ({count})", "ประวัติ ({count})"],
+ "daily.historical": ["历史版本", "Historical version", "รุ่นในประวัติ"],
+ "daily.deleted": ["已删除", "Deleted", "ลบแล้ว"],
+ "daily.history_title": ["日报历史记录", "Daily report history", "ประวัติรายงานรายวัน"],
+ "daily.history_date": ["{date} · 全部处理记录", "{date} · All processing records", "{date} · ประวัติการประมวลผลทั้งหมด"],
+ "daily.history_empty": ["暂无历史记录", "No history records", "ยังไม่มีประวัติ"],
+ "daily.history_note": ["每天仅展示当前有效日报;新版本处理成功后替换,月报同步更新。", "One current report per date. A successfully processed version replaces it and updates the monthly report.", "แสดงรายงานที่ใช้อยู่หนึ่งฉบับต่อวัน รุ่นใหม่จะแทนที่เมื่อประมวลผลสำเร็จและอัปเดตรายงานประจำเดือน"],
+ "daily.history_dialog_note": ["旧版本仅供追溯,不计入月报。已删除记录保留操作痕迹,不会自动恢复。", "Old versions are kept for reference and excluded from the monthly report. Deleted records retain their audit history and are not restored automatically.", "เก็บรุ่นเก่าไว้ตรวจสอบย้อนหลังและไม่นับในรายงานประจำเดือน รายการที่ลบยังมีประวัติการดำเนินการและจะไม่กู้คืนอัตโนมัติ"],
+ "daily.history_region": ["日报历史版本", "Daily report versions", "รุ่นของรายงานรายวัน"],
+ "daily.history_pagination": ["日报历史分页", "Daily report history pages", "การแบ่งหน้าประวัติรายงานรายวัน"],
+ "daily.view_log": ["查看日志", "View log", "ดูบันทึก"],
+ "daily.delete_title": ["删除日报", "Delete daily report", "ลบรายงานรายวัน"],
+ "daily.delete_current_description": ["删除 {date} 当前有效日报?该日数据将从月报中移除。", "Delete the current report for {date}? This date will be removed from the monthly report.", "ลบรายงานที่ใช้อยู่ของวันที่ {date} หรือไม่ ข้อมูลวันนี้จะถูกลบออกจากรายงานประจำเดือน"],
+ "daily.delete_history_description": ["删除 {date} 的这条处理记录?当前有效日报和月报不变。", "Delete this processing record for {date}? The current daily and monthly reports will not change.", "ลบประวัติการประมวลผลนี้ของวันที่ {date} หรือไม่ รายงานรายวันปัจจุบันและรายงานประจำเดือนจะไม่เปลี่ยน"],
+ "daily.delete_note": ["原始数据和操作记录保留;删除后不会自动恢复旧版本。", "Original data and audit history are retained. Deleting a report will not reactivate an older version.", "เก็บข้อมูลต้นฉบับและประวัติการดำเนินการไว้ การลบจะไม่เปิดใช้รุ่นเก่าโดยอัตโนมัติ"],
+ "daily.confirm_delete": ["确认删除", "Confirm delete", "ยืนยันการลบ"],
+ "daily.deleted_current": ["该日日报已移除,月报正在同步更新", "Daily report removed; the monthly report is updating", "ลบรายงานรายวันแล้ว กำลังอัปเดตรายงานประจำเดือน"],
+ "daily.deleted_history": ["处理记录已删除,当前日报和月报保持不变", "Record deleted; the current daily and monthly reports are unchanged", "ลบประวัติแล้ว รายงานรายวันปัจจุบันและรายงานประจำเดือนไม่เปลี่ยน"],
+ "arr_download.deleted": ["报表已删除,可重新获取该日数据", "Report deleted; you may fetch this date again", "ลบรายงานแล้ว สามารถดึงข้อมูลวันนี้ใหม่ได้"],
+ "error.daily_delete_invalid": ["删除请求无效,请刷新日报列表后重试", "Invalid delete request. Refresh the daily list and try again.", "คำขอลบไม่ถูกต้อง โปรดรีเฟรชรายการรายวันแล้วลองใหม่"],
+ "error.daily_delete_busy": ["这份报表正在处理,请完成后再删除", "This report is being processed. Try deleting it after processing finishes.", "รายงานนี้กำลังประมวลผล โปรดลองลบเมื่อประมวลผลเสร็จ"],
+ "error.daily_delete_unavailable": ["日报删除服务暂不可用,请稍后重试", "Report deletion is temporarily unavailable. Try again later.", "บริการลบรายงานไม่พร้อมใช้งานชั่วคราว โปรดลองภายหลัง"],
"daily.history": ["Daily Report", "Daily Report", "Daily Report"],
"daily.refresh": ["刷新", "Refresh", "รีเฟรช"],
"daily.download": ["日报", "Daily report", "รายงานรายวัน"],
@@ -678,6 +709,13 @@
};
const ERROR_KEYS = {
+ DAILY_DELETE_REQUEST_INVALID: "error.daily_delete_invalid",
+ DAILY_DATE_CONFLICT: "error.daily_delete_invalid",
+ DAILY_JOB_BUSY: "error.daily_delete_busy",
+ DAILY_RETIREMENT_UNAVAILABLE: "error.daily_delete_unavailable",
+ DAILY_RETIREMENT_CONFLICT: "error.daily_delete_invalid",
+ DAILY_JOB_NOT_FOUND: "error.not_found",
+ JOB_RETIRED: "arr_download.deleted",
ARR_DOWNLOAD_UNAVAILABLE: "arr_download.unavailable",
ARR_DOWNLOAD_DATE_INVALID: "arr_download.date_invalid",
ARR_DOWNLOAD_REQUEST_INVALID: "arr_download.request_invalid",
diff --git a/arr_web/static/index.html b/arr_web/static/index.html
index 4b23709..23ef146 100644
--- a/arr_web/static/index.html
+++ b/arr_web/static/index.html
@@ -124,17 +124,18 @@
ARRIVAL DATE
—
- 最近成功处理日期
+ 当前查看日期
+ 当前有效日报仍保留
处理耗时
—
- 从提交至完成
+ 从提交至完成
NO. OF ROOM
—
- 保留记录的售出房数
+ 保留记录的售出房数
@@ -214,6 +215,7 @@
Daily Report
+
每天仅展示当前有效日报;新版本处理成功后替换,月报同步更新。
@@ -228,7 +230,7 @@
@@ -510,6 +512,29 @@
+
+
+
+