"""Background preparation and observable progress for batch report downloads.""" import threading import time from apps.admin_console.services import report_export _lock = threading.Lock() _states: dict[str, dict] = {} def archive_path(batch_id: str): return report_export.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" def _snapshot(state: dict) -> dict: elapsed = max(0, (state.get("finished_at") or time.monotonic()) - state["started_at"]) completed, total = state["completed"], state["total"] eta = ( max(0, round(elapsed / completed * (total - completed))) if completed and total > completed else None ) return { "status": state["status"], "stage": state["stage"], "completed": completed, "total": total, "elapsed_seconds": round(elapsed), "eta_seconds": eta, "error": state.get("error"), } def status(batch_id: str, batch: dict) -> dict: with _lock: state = _states.get(batch_id) if state: return _snapshot(state) if batch.get("report_ready") and archive_path(batch_id).is_file(): return { "status": "ready", "stage": "报告包已就绪", "completed": 0, "total": 0, "elapsed_seconds": 0, "eta_seconds": None, "error": None, } return { "status": "idle", "stage": "尚未开始打包", "completed": 0, "total": 0, "elapsed_seconds": 0, "eta_seconds": None, "error": None, } def prepare(batch_id: str, batch: dict) -> dict: with _lock: current = _states.get(batch_id) if current and current["status"] == "preparing": return _snapshot(current) if batch.get("report_ready") and archive_path(batch_id).is_file(): _states.pop(batch_id, None) return status_from_cache() state = { "status": "preparing", "stage": "等待打包", "completed": 0, "total": len(report_export.store.jobs(batch_id)), "started_at": time.monotonic(), } _states[batch_id] = state def update(completed: int, total: int, stage: str) -> None: with _lock: state.update(completed=completed, total=total, stage=stage) def run() -> None: try: with report_export.EXPORT_LOCK: latest = report_export.store.get("batch", batch_id) if not (latest and latest.get("report_ready") and archive_path(batch_id).is_file()): report_export.export_batch(batch_id, progress=update) with _lock: state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic()) except Exception as exc: with _lock: state.update( status="failed", stage="打包失败", error=str(exc)[:500], finished_at=time.monotonic(), ) threading.Thread(target=run, name=f"batch-archive-{batch_id[:8]}", daemon=True).start() return status(batch_id, batch) def status_from_cache() -> dict: return { "status": "ready", "stage": "报告包已就绪", "completed": 0, "total": 0, "elapsed_seconds": 0, "eta_seconds": None, "error": None, } def forget(batch_id: str) -> None: with _lock: _states.pop(batch_id, None)