"""Generate and download the Accounts Receivable Aging workbook.""" from __future__ import annotations import os from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi.responses import FileResponse from sqlalchemy.orm import Session as OrmSession from ...db import models from ...services.jobs import run_export, run_summary_export from ..deps import db_dep, ensure_not_blocked, get_session_or_404 router = APIRouter(prefix="/api/sessions", tags=["export"]) @router.post("/{session_id}/export") def start_export(session_id: int, background: BackgroundTasks, kind: str = "full", db: OrmSession = Depends(db_dep)) -> dict: """kind='summary' → compact Finance pack (fast); kind='full' → complete audit workbook.""" if kind not in ("full", "summary"): raise HTTPException(400, "kind must be 'full' or 'summary'.") s = get_session_or_404(session_id, db) # An export is the number leaving the building — never generate one from a blocked close. ensure_not_blocked(s) # The full workbook RE-COMPUTES its marketplace tabs from the source files using the # current bank receipts, while the AR Ledger / Finance Summary sheets bound into the same # file come from the last processing run. With unapplied receipts those two halves # disagree — the tabs would show one receivable and the ledger sheet another. if s.needs_reprocess: raise HTTPException( 409, "Bank receipts or the payout mode changed after the last run. Re-process the " "closing first — otherwise the workbook's marketplace tabs and its AR Ledger " "sheet would report different receivables.", ) if s.status not in ("processed", "exporting", "completed"): raise HTTPException(400, "Process the session before exporting.") if s.status == "exporting": raise HTTPException(409, "Export already in progress.") # Flip status synchronously so the frontend starts polling immediately. s.status = "exporting" s.progress_stage = "Queued for export" s.progress_pct = 0.0 s.progress_rows_done = 0 s.progress_rows_total = 0 s.eta_seconds = 0 s.error = "" db.commit() background.add_task(run_summary_export if kind == "summary" else run_export, session_id) return {"started": True, "kind": kind} @router.get("/{session_id}/exports") def list_exports(session_id: int, db: OrmSession = Depends(db_dep)) -> list[dict]: get_session_or_404(session_id, db) rows = db.query(models.ExportRecord).filter( models.ExportRecord.session_id == session_id).order_by( models.ExportRecord.generated_at.desc()).all() return [{"id": r.id, "kind": r.kind or "full", "sha256": r.sha256, "size_bytes": r.size_bytes, "generated_at": r.generated_at.isoformat() if r.generated_at else None, "available": bool(r.path and os.path.exists(r.path))} for r in rows] @router.get("/{session_id}/export/download") def download_export(session_id: int, kind: str = "full", db: OrmSession = Depends(db_dep)): s = get_session_or_404(session_id, db) # A workbook generated before a control started failing must not keep circulating. ensure_not_blocked(s) q = db.query(models.ExportRecord).filter(models.ExportRecord.session_id == session_id) if kind in ("full", "summary"): q = q.filter(models.ExportRecord.kind == kind) r = q.order_by(models.ExportRecord.generated_at.desc()).first() if not r or not r.path or not os.path.exists(r.path): raise HTTPException(404, "No export available; generate it first.") return FileResponse( r.path, filename=os.path.basename(r.path), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", )