147 lines
5.3 KiB
Python
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"}
|