Finance-Accounts/ar-aging-app/backend/app/api/routes/export.py

86 lines
4.0 KiB
Python

"""Generate and download the Accounts Receivable Aging workbook."""
from __future__ import annotations
import os
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session as OrmSession
from ...db import models
from ...services.audit import record as audit
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, request: Request,
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 = ""
audit(db, request, "export_generate", session=s, detail=f"kind={kind}")
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, request: Request, 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.")
audit(db, request, "export_download",
session=s, detail=f"kind={kind} '{os.path.basename(r.path)}'")
db.commit()
return FileResponse(
r.path, filename=os.path.basename(r.path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)