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

147 lines
5.3 KiB
Python

"""Reconciliation control: dashboard figures vs the Finance control sheet, with sign-off."""
from __future__ import annotations
import datetime as dt
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session as OrmSession
from ...core.movement import compute_movement
from ...db import models
from ..deps import db_dep, get_session_or_404
router = APIRouter(prefix="/api/sessions", tags=["control"])
# metric key -> (label, is_critical)
METRICS = [
("gross_sales", "Gross Sales", False),
("refunds", "Refunds", False),
("net_revenue", "Net Revenue", False),
("disbursements", "Disbursements", False),
("closing_receivable", "Closing Receivable", True),
]
def _dashboard_metrics(db: OrmSession, session_id: int) -> dict[str, float] | None:
"""Whole-close figures: summed across every marketplace in the session.
(Identical to the single-market numbers for a USA-only close.)
"""
from .ar import _market_list, _movement_for
j = db.query(models.JournalEntry).filter(models.JournalEntry.session_id == session_id).first()
if not j or not j.data:
return None
payload = json.loads(j.data)
totals = {k: 0.0 for k in ("gross_sales", "refunds", "net_revenue",
"disbursements", "closing_receivable")}
for mkt in _market_list(payload):
mv = _movement_for(db, session_id, mkt)
if not mv.get("available"):
continue
lines = mv.pop("journal").get("lines", [])
totals["gross_sales"] += next((l["total"] for l in lines if l["key"] == "Sales"), 0.0)
totals["refunds"] += next((l["total"] for l in lines if l["key"] == "Refunds"), 0.0)
totals["net_revenue"] += mv["net_revenue"]
totals["disbursements"] += mv["received_payouts"]
totals["closing_receivable"] += mv["closing"]
return {k: round(v, 2) for k, v in totals.items()}
class ControlIn(BaseModel):
gross_sales: float | None = None
refunds: float | None = None
net_revenue: float | None = None
disbursements: float | None = None
closing_receivable: float | None = None
tolerance: float | None = None
comment: str | None = None
@router.get("/{session_id}/reconciliation-control")
def get_control(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db)
dash = _dashboard_metrics(db, session_id)
if dash is None:
return {"available": False}
fc = db.query(models.FinanceControl).filter(
models.FinanceControl.session_id == session_id).first()
tol = fc.tolerance if fc and fc.tolerance is not None else 1.0
rows = []
all_ok = True
for key, label, critical in METRICS:
dv = dash[key]
fv = getattr(fc, key) if fc else None
diff = round(dv - fv, 2) if fv is not None else None
if fv is None:
status = "pending"
elif abs(diff) <= tol:
status = "matched"
else:
status = "review"
if critical and status != "matched":
all_ok = False
rows.append({"key": key, "label": label, "critical": critical,
"dashboard": dv, "finance": fv, "difference": diff, "status": status})
return {
"available": True,
"tolerance": tol,
"rows": rows,
"verified_by": fc.verified_by if fc else "",
"verified_at": fc.verified_at.isoformat() if fc and fc.verified_at else None,
"comment": fc.comment if fc else "",
"can_complete": all_ok,
"completed": db.get(models.Session, session_id).status == "completed",
}
def _get_or_create(db: OrmSession, session_id: int) -> models.FinanceControl:
fc = db.query(models.FinanceControl).filter(
models.FinanceControl.session_id == session_id).first()
if fc is None:
fc = models.FinanceControl(session_id=session_id)
db.add(fc)
return fc
@router.put("/{session_id}/reconciliation-control")
def put_control(session_id: int, body: ControlIn, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db)
fc = _get_or_create(db, session_id)
data = body.model_dump(exclude_unset=True)
for k, v in data.items():
setattr(fc, k, v)
db.commit()
return get_control(session_id, db)
class VerifyIn(BaseModel):
verified_by: str
comment: str = ""
@router.post("/{session_id}/reconciliation-control/verify")
def verify_control(session_id: int, body: VerifyIn, db: OrmSession = Depends(db_dep)) -> dict:
get_session_or_404(session_id, db)
fc = _get_or_create(db, session_id)
fc.verified_by = body.verified_by
fc.verified_at = dt.datetime.utcnow()
if body.comment:
fc.comment = body.comment
db.commit()
return get_control(session_id, db)
@router.post("/{session_id}/complete")
def complete_session(session_id: int, db: OrmSession = Depends(db_dep)) -> dict:
s = get_session_or_404(session_id, db)
ctrl = get_control(session_id, db)
if not ctrl.get("available"):
raise HTTPException(400, "Process the closing before completing it.")
if not ctrl.get("can_complete"):
raise HTTPException(400, "Closing receivable is not reconciled with the Finance control sheet.")
s.status = "completed"
db.commit()
return {"status": "completed"}